Pārlūkot izejas kodu

merge master (build identity)

Bot 23 stundas atpakaļ
vecāks
revīzija
6cd0299fbc

+ 58 - 6
pkg/httpserver/handler.go

@@ -141,11 +141,7 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
 		// Execute using the database-specific executor
 		result, err := exec.Execute(stmt)
 		if err != nil {
-			errorChan <- &HTTPError{
-				Code:    "EXECUTION_ERROR",
-				Message: err.Error(),
-				Status:  http.StatusInternalServerError,
-			}
+			errorChan <- sqlStateHTTPError(err)
 			return
 		}
 
@@ -302,7 +298,8 @@ func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 	} else if executeErr != nil {
-		writeError(w, http.StatusBadRequest, "EXECUTION_ERROR", executeErr.Error(), nil)
+		httpErr := sqlStateHTTPError(executeErr)
+		writeError(w, httpErr.Status, httpErr.Code, httpErr.Message, httpErr.Details)
 		return
 	}
 
@@ -406,6 +403,61 @@ func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, capabilities.Current(), pretty)
 }
 
+// handleVersion reports the build identity and the protocol compatibility
+// floor separately, so clients can pin behavior to a specific engine build.
+func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
+	resp := map[string]interface{}{
+		"name":            "pizzasql",
+		"version":         version.String(),
+		"commit":          version.Commit,
+		"date":            version.Date,
+		"dirty":           version.Dirty != "",
+		"postgres_compat": version.PostgreSQLCompatVersion,
+		"sqlite_compat":   executor.SQLiteCompatVersion,
+	}
+	pretty := r.URL.Query().Get("pretty") == "true"
+	writeJSON(w, http.StatusOK, resp, pretty)
+}
+
+// sqlStateHTTPError maps engine errors onto the same SQLSTATE codes the
+// PostgreSQL endpoint reports. The executor returns plain errors on released
+// builds, so the unique-violation and unsupported-feature markers are matched
+// on their stable message prefixes.
+func sqlStateHTTPError(err error) *HTTPError {
+	if err == nil {
+		return nil
+	}
+	message := err.Error()
+	if strings.HasPrefix(message, "UNIQUE constraint failed: ") {
+		constraint := strings.TrimPrefix(message, "UNIQUE constraint failed: ")
+		return &HTTPError{
+			Status:  http.StatusConflict,
+			Code:    "23505",
+			Message: message,
+			Details: map[string]interface{}{"constraint": constraint},
+		}
+	}
+	if errors.Is(err, storage.ErrSerialization) {
+		return &HTTPError{
+			Status:  http.StatusConflict,
+			Code:    "40001",
+			Message: message,
+		}
+	}
+	if strings.Contains(message, "feature not supported:") {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    "0A000",
+			Message: message,
+		}
+	}
+	return &HTTPError{
+		Status:  http.StatusInternalServerError,
+		Code:    "EXECUTION_ERROR",
+		Message: message,
+	}
+}
+
 // handleHealth handles GET /health
 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
 	resp := map[string]interface{}{

+ 1 - 0
pkg/httpserver/server.go

@@ -161,6 +161,7 @@ func (s *Server) init() *Server {
 	mux.HandleFunc("/schema/tables", s.handleSchemaTables)
 	mux.HandleFunc("/schema/tables/", s.handleSchemaTable)
 	mux.HandleFunc("/health", s.handleHealth)
+	mux.HandleFunc("/version", s.handleVersion)
 	mux.HandleFunc("/capabilities", s.handleCapabilities)
 	mux.HandleFunc("/stats", s.handleStats)
 	mux.HandleFunc("/metrics", s.handleMetrics)

+ 69 - 0
pkg/httpserver/sqlstate_test.go

@@ -0,0 +1,69 @@
+package httpserver
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+type httpErrorBody struct {
+	Error struct {
+		Code    string                 `json:"code"`
+		Message string                 `json:"message"`
+		Details map[string]interface{} `json:"details"`
+	} `json:"error"`
+}
+
+func TestHTTPUniqueViolationReturns23505(t *testing.T) {
+	server := setupKVTestServer(t)
+	queryHTTP(t, server, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)")
+	queryHTTP(t, server, "INSERT INTO users (id, email) VALUES (1, 'a@x')")
+
+	body, _ := json.Marshal(QueryRequest{SQL: "INSERT INTO users (id, email) VALUES (2, 'a@x')"})
+	r := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(string(body)))
+	w := httptest.NewRecorder()
+	server.handleQuery(w, r)
+
+	if w.Code != http.StatusConflict {
+		t.Fatalf("status = %d, want 409; body %s", w.Code, w.Body.String())
+	}
+	var resp httpErrorBody
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if resp.Error.Code != "23505" {
+		t.Fatalf("code = %q, want 23505", resp.Error.Code)
+	}
+	constraint, _ := resp.Error.Details["constraint"].(string)
+	if constraint == "" || !strings.Contains(resp.Error.Message, constraint) {
+		t.Fatalf("details.constraint = %q, message = %q", constraint, resp.Error.Message)
+	}
+}
+
+func TestSQLStateHTTPErrorMapping(t *testing.T) {
+	tests := []struct {
+		name   string
+		err    error
+		status int
+		code   string
+	}{
+		{"unique", fmt.Errorf("UNIQUE constraint failed: uniq_users_email"), http.StatusConflict, "23505"},
+		{"serialization", fmt.Errorf("commit: %w", storage.ErrSerialization), http.StatusConflict, "40001"},
+		{"unsupported", fmt.Errorf("feature not supported: DEFAULT is not constant"), http.StatusBadRequest, "0A000"},
+		{"other", errors.New("boom"), http.StatusInternalServerError, "EXECUTION_ERROR"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := sqlStateHTTPError(tt.err)
+			if got.Status != tt.status || got.Code != tt.code {
+				t.Fatalf("got status=%d code=%s, want status=%d code=%s", got.Status, got.Code, tt.status, tt.code)
+			}
+		})
+	}
+}

+ 39 - 0
pkg/httpserver/version_test.go

@@ -0,0 +1,39 @@
+package httpserver
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
+)
+
+func TestVersionEndpointReportsBuildAndCompatibility(t *testing.T) {
+	server := setupKVTestServer(t)
+
+	r := httptest.NewRequest(http.MethodGet, "/version", nil)
+	w := httptest.NewRecorder()
+	server.server.Handler.ServeHTTP(w, r)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d body %s", w.Code, w.Body.String())
+	}
+	var resp map[string]interface{}
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if got := resp["version"]; got != version.String() {
+		t.Fatalf("version = %v, want %q", got, version.String())
+	}
+	if got := resp["postgres_compat"]; got != version.PostgreSQLCompatVersion {
+		t.Fatalf("postgres_compat = %v, want %q", got, version.PostgreSQLCompatVersion)
+	}
+	if got := resp["sqlite_compat"]; got != executor.SQLiteCompatVersion {
+		t.Fatalf("sqlite_compat = %v, want %q", got, executor.SQLiteCompatVersion)
+	}
+	if _, ok := resp["commit"]; !ok {
+		t.Fatal("response is missing commit")
+	}
+}

+ 12 - 3
pkg/pgserver/connection.go

@@ -20,6 +20,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 
 // Connection represents a client connection
@@ -142,7 +143,15 @@ func (c *Connection) Handle() error {
 	}
 
 	// Send parameter status messages
-	if err := c.sendParameterStatus("server_version", "14.0 (PizzaSQL)"); err != nil {
+	if err := c.sendParameterStatus("server_version", version.PostgreSQLCompatVersion+" (PizzaSQL)"); err != nil {
+		return err
+	}
+	// Build identity is a separate, custom ParameterStatus so the PostgreSQL
+	// compatibility string above stays stable for version-sniffing drivers.
+	if err := c.sendParameterStatus("pizzasql.version", version.String()); err != nil {
+		return err
+	}
+	if err := c.sendParameterStatus("pizzasql.commit", version.Commit); err != nil {
 		return err
 	}
 	if err := c.sendParameterStatus("server_encoding", "UTF8"); err != nil {
@@ -1236,7 +1245,7 @@ func (c *Connection) handleVersionQuery() error {
 		return err
 	}
 
-	row := []interface{}{"PostgreSQL 14.0 (PizzaSQL)"}
+	row := []interface{}{"PostgreSQL " + version.PostgreSQLCompatVersion + " (PizzaSQL)"}
 	if err := c.sendDataRow(row, columns); err != nil {
 		return err
 	}
@@ -1281,7 +1290,7 @@ func (c *Connection) handleShowCommand(sqlUpper string) error {
 
 	if strings.Contains(sqlUpper, "SERVER_VERSION") {
 		name = "server_version"
-		value = "14.0"
+		value = version.PostgreSQLCompatVersion
 	} else if strings.Contains(sqlUpper, "SERVER_ENCODING") {
 		name = "server_encoding"
 		value = "UTF8"

+ 107 - 0
pkg/pgserver/identity_test.go

@@ -0,0 +1,107 @@
+package pgserver
+
+import (
+	"bufio"
+	"encoding/binary"
+	"io"
+	"net"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
+)
+
+func startupMessage(params map[string]string) []byte {
+	size := 4 + 4
+	for k, v := range params {
+		size += len(k) + 1 + len(v) + 1
+	}
+	size++ // terminator
+	buf := make([]byte, 0, size)
+	buf = binary.BigEndian.AppendUint32(buf, uint32(size))
+	buf = binary.BigEndian.AppendUint32(buf, 196608) // protocol 3.0
+	for k, v := range params {
+		buf = append(buf, k...)
+		buf = append(buf, 0)
+		buf = append(buf, v...)
+		buf = append(buf, 0)
+	}
+	return append(buf, 0)
+}
+
+func TestStartupReportsBuildIdentitySeparately(t *testing.T) {
+	kv := testkv.New(t)
+	pool := kv.Pool(2)
+	t.Cleanup(func() { pool.Close() })
+	dbManager := storage.NewDatabaseManager(pool, nil)
+
+	server, client := net.Pipe()
+	t.Cleanup(func() {
+		client.Close()
+		server.Close()
+	})
+
+	conn := NewConnection(server, dbManager, true)
+	done := make(chan error, 1)
+	go func() { done <- conn.Handle() }()
+
+	go client.Write(startupMessage(map[string]string{"user": "tester", "database": "identity"}))
+
+	reader := bufio.NewReader(client)
+	statuses := map[string]string{}
+	for {
+		msg, err := ReadMessage(reader)
+		if err != nil {
+			t.Fatalf("read startup response: %v", err)
+		}
+		if msg.Type == MsgParameterStatus {
+			data := msg.Data
+			key, pos, err := readCString(data, 0)
+			if err != nil {
+				t.Fatalf("ParameterStatus key: %v", err)
+			}
+			value, _, err := readCString(data, pos)
+			if err != nil {
+				t.Fatalf("ParameterStatus value: %v", err)
+			}
+			statuses[key] = value
+		}
+		if msg.Type == MsgReadyForQuery {
+			break
+		}
+	}
+
+	if got := statuses["server_version"]; got != version.PostgreSQLCompatVersion+" (PizzaSQL)" {
+		t.Fatalf("server_version = %q, want PostgreSQL compatibility string", got)
+	}
+	if got := statuses["pizzasql.version"]; got != version.String() {
+		t.Fatalf("pizzasql.version = %q, want %q", got, version.String())
+	}
+	if got := statuses["pizzasql.commit"]; got != version.Commit {
+		t.Fatalf("pizzasql.commit = %q, want %q", got, version.Commit)
+	}
+
+	client.Write([]byte{'X', 0, 0, 0, 4}) // Terminate
+	if err := <-done; err != nil && err != io.EOF {
+		t.Fatalf("Handle: %v", err)
+	}
+}
+
+func TestVersionFunctionKeepsPostgresCompatibilityString(t *testing.T) {
+	c, client := newDataConnection(t)
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("SELECT version()"), 0)})
+	values := dataRowValues(t, msgs[1])
+	if len(values) != 1 || values[0] != "PostgreSQL "+version.PostgreSQLCompatVersion+" (PizzaSQL)" {
+		t.Fatalf("version() = %v", values)
+	}
+}
+
+func TestPizzasqlVersionFunctionReturnsBuild(t *testing.T) {
+	c, client := newDataConnection(t)
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("SELECT pizzasql_version()"), 0)})
+	values := dataRowValues(t, msgs[1])
+	if len(values) != 1 || values[0] != version.String() {
+		t.Fatalf("pizzasql_version() = %v, want %q", values, version.String())
+	}
+}

+ 5 - 0
pkg/version/version.go

@@ -6,6 +6,11 @@ import (
 	"strings"
 )
 
+// PostgreSQLCompatVersion is the PostgreSQL server version reported to clients
+// for driver compatibility. It is intentionally independent of the PizzaSQL
+// build version returned by String() and pizzasql_version().
+const PostgreSQLCompatVersion = "14.0"
+
 var (
 	Version = "dev"
 	Commit  = "unknown"