Prechádzať zdrojové kódy

Merge remote-tracking branch 'origin/master' into fix/alter-table-semantics

Bot 1 deň pred
rodič
commit
5c8e3699fd

+ 209 - 0
pkg/capabilities/capabilities.go

@@ -0,0 +1,209 @@
+// Package capabilities describes the SQL surface an engine build supports.
+//
+// The document is machine-readable and versioned separately from the engine
+// build. Clients should compare SchemaVersion when parsing it and use
+// EngineVersion to pin behavior to a specific PizzaSQL release. Keep this file
+// in sync with the tests it advertises: every claim here must be covered by an
+// engine test in the same repository.
+package capabilities
+
+import (
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/version"
+)
+
+// SchemaVersion changes only when the document's shape or semantics change in a
+// way clients must handle. Additive fields do not require a bump.
+const SchemaVersion = 1
+
+// Feature reports whether a capability is supported and, when it is not,
+// whether the engine rejects the syntax or accepts it with different semantics.
+type Feature struct {
+	Supported bool   `json:"supported"`
+	Status    string `json:"status"`
+	SQLState  string `json:"sqlstate,omitempty"`
+	Notes     string `json:"notes,omitempty"`
+}
+
+// Document is the machine-readable capability description served by
+// GET /capabilities.
+type Document struct {
+	SchemaVersion int    `json:"schema_version"`
+	Engine        string `json:"engine"`
+	EngineVersion string `json:"engine_version"`
+	Commit        string `json:"commit"`
+	GeneratedAt   string `json:"generated_at,omitempty"`
+
+	Protocol      Protocol          `json:"protocol"`
+	Constraints   Constraints       `json:"constraints"`
+	DDL           DDL               `json:"ddl"`
+	Transactions  Transactions      `json:"transactions"`
+	Types         Types             `json:"types"`
+	Parameters    Parameters        `json:"parameters"`
+	JSON          JSON              `json:"json"`
+	ErrorCodes    map[string]string `json:"error_codes"`
+	Documentation Documentation     `json:"documentation"`
+}
+
+type Protocol struct {
+	PostgresCompat string `json:"postgres_compat"`
+	SQLiteCompat   string `json:"sqlite_compat"`
+	SimpleQuery    bool   `json:"simple_query"`
+	ExtendedQuery  bool   `json:"extended_query"`
+	TLS            string `json:"tls"`
+}
+
+type Constraints struct {
+	NotNull       Feature `json:"not_null"`
+	Unique        Feature `json:"unique"`
+	PrimaryKey    Feature `json:"primary_key"`
+	CompositePK   Feature `json:"composite_primary_key"`
+	ForeignKey    Feature `json:"foreign_key"`
+	Check         Feature `json:"check"`
+	DefaultValues Feature `json:"default_values"`
+}
+
+type DDL struct {
+	AddColumn              Feature `json:"add_column"`
+	AddColumnBackfill      Feature `json:"add_column_backfill"`
+	DropColumn             Feature `json:"drop_column"`
+	RenameColumn           Feature `json:"rename_column"`
+	RenameColumnIndexes    Feature `json:"rename_column_updates_indexes"`
+	CreateTableIfNotExists bool    `json:"create_table_if_not_exists"`
+	AddColumnIfNotExists   bool    `json:"add_column_if_not_exists"`
+}
+
+type Transactions struct {
+	Model             string  `json:"model"`
+	Isolation         string  `json:"isolation"`
+	SerializationCode string  `json:"serialization_sqlstate"`
+	RowLocks          Feature `json:"row_locks"`
+	Savepoints        Feature `json:"savepoints"`
+	RetryRecommended  bool    `json:"retry_recommended"`
+}
+
+type Types struct {
+	Integer  bool    `json:"integer"`
+	Bigint   bool    `json:"bigint"`
+	Real     bool    `json:"real"`
+	Text     bool    `json:"text"`
+	Blob     bool    `json:"blob"`
+	Boolean  bool    `json:"boolean"`
+	DateTime bool    `json:"datetime"`
+	JSONText bool    `json:"json_as_text"`
+	JSONB    Feature `json:"jsonb"`
+	Arrays   Feature `json:"arrays"`
+	RowID    Feature `json:"rowid"`
+}
+
+type Parameters struct {
+	Positional    bool    `json:"positional"`
+	MaxOID        string  `json:"max_oid"`
+	Arrays        Feature `json:"arrays"`
+	BinaryNumeric bool    `json:"binary_numeric"`
+}
+
+type JSON struct {
+	Storage        string  `json:"storage"`
+	Functions      string  `json:"functions"`
+	ArrowOperators Feature `json:"arrow_operators"`
+	InvalidInput   string  `json:"invalid_input"`
+}
+
+type Documentation struct {
+	Compatibility string `json:"compatibility"`
+	Isolation     string `json:"isolation"`
+}
+
+// Current returns the capability document for this build.
+func Current() *Document {
+	return &Document{
+		SchemaVersion: SchemaVersion,
+		Engine:        "pizzasql",
+		EngineVersion: version.String(),
+		Commit:        version.Commit,
+		GeneratedAt:   time.Now().UTC().Format(time.RFC3339),
+
+		Protocol: Protocol{
+			PostgresCompat: "14.0",
+			SQLiteCompat:   "3.35.0",
+			SimpleQuery:    true,
+			ExtendedQuery:  true,
+			TLS:            "terminated by the managed pgproxy, not the engine listener",
+		},
+
+		Constraints: Constraints{
+			NotNull:       Feature{Supported: true, Status: "enforced"},
+			Unique:        Feature{Supported: true, Status: "enforced", SQLState: "23505", Notes: "unique indexes, INLINE UNIQUE, and ON CONFLICT are enforced atomically"},
+			PrimaryKey:    Feature{Supported: true, Status: "enforced", Notes: "single-column only"},
+			CompositePK:   Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "declare a single-column primary key"},
+			ForeignKey:    Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "inline and table-level FOREIGN KEY are rejected; enforce referential integrity in the application, cascades are not implemented"},
+			Check:         Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "inline and table-level CHECK are rejected; enforce the rule in the application"},
+			DefaultValues: Feature{Supported: true, Status: "constant_only", SQLState: "0A000", Notes: "non-constant defaults (CURRENT_TIMESTAMP, function calls) are rejected instead of being frozen at DDL time"},
+		},
+
+		DDL: DDL{
+			AddColumn:              Feature{Supported: true, Status: "metadata_only"},
+			AddColumnBackfill:      Feature{Supported: false, Status: "no_backfill", Notes: "existing rows read NULL; a DEFAULT applies to later INSERTs only"},
+			DropColumn:             Feature{Supported: true, Status: "rewrites_rows"},
+			RenameColumn:           Feature{Supported: true, Status: "rewrites_rows"},
+			RenameColumnIndexes:    Feature{Supported: false, Status: "stale_index_metadata", Notes: "drop and recreate indexes that reference a renamed column"},
+			CreateTableIfNotExists: true,
+			AddColumnIfNotExists:   true,
+		},
+
+		Transactions: Transactions{
+			Model:             "optimistic compare-batch commit; no MVCC or snapshots",
+			Isolation:         "serializable-equivalent for single-row compare-batch commits; write skew is possible across independent rows",
+			SerializationCode: "40001",
+			RowLocks:          Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "SELECT ... FOR UPDATE/SHARE and NOWAIT/SKIP LOCKED are rejected"},
+			Savepoints:        Feature{Supported: true, Status: "enforced"},
+			RetryRecommended:  true,
+		},
+
+		Types: Types{
+			Integer:  true,
+			Bigint:   true,
+			Real:     true,
+			Text:     true,
+			Blob:     true,
+			Boolean:  true,
+			DateTime: true,
+			JSONText: true,
+			JSONB:    Feature{Supported: false, Status: "type_name_alias", Notes: "JSONB is accepted as a column type name with no native semantics; values are stored as text and reported as TEXT over the wire"},
+			Arrays:   Feature{Supported: false, Status: "rejected", SQLState: "0A000"},
+			RowID:    Feature{Supported: true, Status: "auto_assigned", Notes: "rowid/oid/_rowid_ are BIGINT over the wire; monotonic when the engine assigns the rowid, stable across UPDATE, not monotonic for client-assigned descending INTEGER PRIMARY KEY values"},
+		},
+
+		Parameters: Parameters{
+			Positional:    true,
+			MaxOID:        "int8/text/bool/bytea/float; arrays are rejected",
+			Arrays:        Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "id = ANY($1) is rejected; expand to IN (...) with individual parameters"},
+			BinaryNumeric: true,
+		},
+
+		JSON: JSON{
+			Storage:        "TEXT (JSON and JSONB type names are accepted without native semantics; insert JSON as text)",
+			Functions:      "json, json_extract, json_set, json_insert, json_replace, json_remove, json_valid, json_type, json_array, json_object, json_quote, json_group_array",
+			ArrowOperators: Feature{Supported: false, Status: "rejected", SQLState: "42601", Notes: "-> and ->> are not implemented; use json_extract"},
+			InvalidInput:   "json_extract/json_set throw 'malformed JSON'; json_valid returns 0",
+		},
+
+		ErrorCodes: map[string]string{
+			"23505": "unique_violation",
+			"40001": "serialization_failure",
+			"0A000": "feature_not_supported",
+			"42601": "syntax_error",
+			"25P02": "in_failed_sql_transaction",
+			"08P01": "protocol_violation (managed proxy: TLS required)",
+			"53300": "too_many_connections (managed proxy: organization quota)",
+			"XX000": "internal_error",
+		},
+
+		Documentation: Documentation{
+			Compatibility: "https://docs.database.pizza/sql-reference/compatibility/",
+			Isolation:     "https://docs.database.pizza/engine/concurrency/",
+		},
+	}
+}

+ 36 - 0
pkg/capabilities/capabilities_test.go

@@ -0,0 +1,36 @@
+package capabilities
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/version"
+)
+
+func TestCurrentDocumentIsVersionedAndMarshalable(t *testing.T) {
+	doc := Current()
+	if doc.SchemaVersion != SchemaVersion {
+		t.Fatalf("schema version = %d, want %d", doc.SchemaVersion, SchemaVersion)
+	}
+	if doc.Engine != "pizzasql" || doc.EngineVersion != version.String() {
+		t.Fatalf("engine identity = %q/%q", doc.Engine, doc.EngineVersion)
+	}
+
+	body, err := json.Marshal(doc)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	var round Document
+	if err := json.Unmarshal(body, &round); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if round.Constraints.Unique.SQLState != "23505" {
+		t.Fatalf("unique SQLSTATE = %q, want 23505", round.Constraints.Unique.SQLState)
+	}
+	if round.Transactions.SerializationCode != "40001" {
+		t.Fatalf("serialization SQLSTATE = %q, want 40001", round.Transactions.SerializationCode)
+	}
+	if round.ErrorCodes["0A000"] != "feature_not_supported" {
+		t.Fatalf("0A000 mapping = %q", round.ErrorCodes["0A000"])
+	}
+}

+ 49 - 0
pkg/executor/check_fk_test.go

@@ -0,0 +1,49 @@
+package executor
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+func TestCheckConstraintsRejectedAtDDL(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, sql := range []string{
+		"CREATE TABLE t1 (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))",
+		"CREATE TABLE t2 (id INTEGER PRIMARY KEY, age INTEGER, CHECK (age > 0))",
+		"CREATE TABLE t3 (id INTEGER PRIMARY KEY, age INTEGER, CONSTRAINT positive_age CHECK (age > 0))",
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Fatalf("%s: error = %v, want ErrFeatureNotSupported", sql, err)
+		}
+	}
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	if _, err := execSQL(e, "ALTER TABLE t ADD COLUMN age INTEGER CHECK (age > 0)"); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("ALTER CHECK error = %v, want ErrFeatureNotSupported", err)
+	}
+}
+
+func TestForeignKeyConstraintsRejectedAtDDL(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+
+	for _, sql := range []string{
+		"CREATE TABLE child1 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))",
+		"CREATE TABLE child2 (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id))",
+		"CREATE TABLE child3 (id INTEGER PRIMARY KEY, parent_id INTEGER, CONSTRAINT child_parent FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE CASCADE)",
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Fatalf("%s: error = %v, want ErrFeatureNotSupported", sql, err)
+		}
+	}
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	if _, err := execSQL(e, "ALTER TABLE t ADD COLUMN parent_id INTEGER REFERENCES parent(id)"); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("ALTER FK error = %v, want ErrFeatureNotSupported", err)
+	}
+}

+ 72 - 0
pkg/executor/composite_primary_key_test.go

@@ -0,0 +1,72 @@
+package executor
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestCreateTablePrimaryKeyConstraints(t *testing.T) {
+	tests := []struct {
+		name           string
+		sql            string
+		wantErr        string
+		wantPrimaryKey string
+	}{
+		{
+			name:    "composite table-level primary key is rejected",
+			sql:     "CREATE TABLE t (a TEXT, b TEXT, PRIMARY KEY (a, b))",
+			wantErr: "composite primary key",
+		},
+		{
+			name:    "duplicate column composite primary key is rejected",
+			sql:     "CREATE TABLE t (a TEXT, PRIMARY KEY (a, a))",
+			wantErr: "composite primary key",
+		},
+		{
+			name:           "single-column table-level primary key is accepted",
+			sql:            "CREATE TABLE t (a TEXT, PRIMARY KEY (a))",
+			wantPrimaryKey: "a",
+		},
+		{
+			name:           "inline primary key is accepted",
+			sql:            "CREATE TABLE t (a TEXT PRIMARY KEY)",
+			wantPrimaryKey: "a",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, schema, table := newTestDB(t)
+			e := newExec(schema, table)
+
+			_, err := execSQL(e, tt.sql)
+			if tt.wantErr != "" {
+				if err == nil {
+					t.Fatalf("expected %q to fail", tt.sql)
+				}
+				if !strings.Contains(err.Error(), tt.wantErr) {
+					t.Fatalf("error = %q, want it to contain %q", err, tt.wantErr)
+				}
+				if schema.TableExists("t") {
+					t.Fatal("rejected CREATE TABLE left a schema behind")
+				}
+				if _, err := execSQL(e, "SELECT * FROM t"); err == nil {
+					t.Fatal("rejected CREATE TABLE left a usable table behind")
+				}
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("exec %q: %v", tt.sql, err)
+			}
+			got, err := schema.GetSchema("t")
+			if err != nil {
+				t.Fatalf("get schema: %v", err)
+			}
+			if got.PrimaryKey != tt.wantPrimaryKey {
+				t.Fatalf("PrimaryKey = %q, want %q", got.PrimaryKey, tt.wantPrimaryKey)
+			}
+			execMust(t, e, "INSERT INTO t (a) VALUES ('x')")
+		})
+	}
+}

+ 53 - 0
pkg/executor/ddl_defaults.go

@@ -0,0 +1,53 @@
+package executor
+
+import (
+	"fmt"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+// constantDefaultValue evaluates a DEFAULT expression that cannot depend on the
+// row, session clock, or other mutable state.
+//
+// The schema catalog stores the evaluated value, not the expression SQL, so a
+// non-constant default such as CURRENT_TIMESTAMP would be frozen at DDL time and
+// every later INSERT would silently receive the same value. Rejecting the
+// declaration is safer than accepting it with different semantics. Per-INSERT
+// evaluation is not implemented.
+func constantDefaultValue(e *Executor, column string, expr parser.Expr) (interface{}, error) {
+	if !isConstantExpr(expr) {
+		return nil, pgerror.Unsupported(
+			"DEFAULT for column %q is not a constant expression: %s; PizzaSQL stores the evaluated default in the schema catalog, so a non-constant default would be frozen at CREATE/ALTER time. Omit the default and supply the value on INSERT",
+			column, parser.FormatExpr(expr),
+		)
+	}
+	value, err := e.evalExpr(expr, nil)
+	if err != nil {
+		return nil, fmt.Errorf("evaluate DEFAULT for column %q: %w", column, err)
+	}
+	return value, nil
+}
+
+// isConstantExpr reports whether expr is a literal, a unary sign applied to a
+// literal, a parenthesized constant, or a cast of a constant. Everything else
+// (column references including CURRENT_TIMESTAMP, function calls, subqueries,
+// arithmetic) is rejected because its value can change between statements.
+func isConstantExpr(expr parser.Expr) bool {
+	switch ex := expr.(type) {
+	case *parser.LiteralExpr:
+		return true
+	case *parser.UnaryExpr:
+		if ex.Op != lexer.TokenMinus && ex.Op != lexer.TokenPlus {
+			return false
+		}
+		return isConstantExpr(ex.Operand)
+	case *parser.ParenExpr:
+		return isConstantExpr(ex.Expr)
+	case *parser.CastExpr:
+		return isConstantExpr(ex.Expr)
+	default:
+		return false
+	}
+}

+ 83 - 0
pkg/executor/ddl_defaults_test.go

@@ -0,0 +1,83 @@
+package executor
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+// Non-constant defaults cannot be stored in the schema catalog without freezing
+// their value at DDL time, so they must be rejected instead of silently
+// producing a constant.
+func TestCreateTableRejectsNonConstantDefaults(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, sql := range []string{
+		"CREATE TABLE t1 (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
+		"CREATE TABLE t2 (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT datetime('now'))",
+		"CREATE TABLE t3 (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('X'))",
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Fatalf("%s: error = %v, want ErrFeatureNotSupported", sql, err)
+		}
+	}
+}
+
+func TestAlterTableRejectsNonConstantDefault(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+
+	if _, err := execSQL(e, "ALTER TABLE t ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("ALTER error = %v, want ErrFeatureNotSupported", err)
+	}
+}
+
+func TestConstantDefaultsStillApplyOnInsert(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT DEFAULT 'new', score INTEGER DEFAULT -1, flag BOOLEAN DEFAULT false, note TEXT DEFAULT NULL)")
+
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	res := execMust(t, e, "SELECT label, score, flag, note FROM t WHERE id = 1")
+	if res.RowCount != 1 {
+		t.Fatalf("row count = %d, want 1", res.RowCount)
+	}
+	if got := res.Rows[0][0]; got != "new" {
+		t.Fatalf("label = %v, want new", got)
+	}
+	if got := res.Rows[0][1]; got != int64(-1) {
+		t.Fatalf("score = %v (%T), want int64(-1)", got, got)
+	}
+	if got := res.Rows[0][2]; got != false {
+		t.Fatalf("flag = %v, want false", got)
+	}
+	if got := res.Rows[0][3]; got != nil {
+		t.Fatalf("note = %v, want NULL", got)
+	}
+}
+
+func TestAlterTableAddColumnDoesNotBackfill(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+
+	execMust(t, e, "ALTER TABLE t ADD COLUMN status TEXT DEFAULT 'pending'")
+
+	res := execMust(t, e, "SELECT id, status FROM t WHERE id = 1")
+	if res.RowCount != 1 {
+		t.Fatalf("row count = %d, want 1", res.RowCount)
+	}
+	if got := res.Rows[0][1]; got != nil {
+		t.Fatalf("existing row status = %v, want NULL (ADD COLUMN does not backfill)", got)
+	}
+
+	execMust(t, e, "INSERT INTO t (id) VALUES (2)")
+	res = execMust(t, e, "SELECT status FROM t WHERE id = 2")
+	if got := res.Rows[0][0]; got != "pending" {
+		t.Fatalf("new row status = %v, want pending", got)
+	}
+}

+ 57 - 9
pkg/executor/executor.go

@@ -13,10 +13,16 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"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"
 )
 
+// ErrUnsupportedFeature marks DDL that PizzaSQL intentionally rejects instead
+// of silently accepting with different semantics. Protocol layers map it to a
+// feature-not-supported SQLSTATE rather than a generic internal error.
+var ErrUnsupportedFeature = errors.New("unsupported feature")
+
 // Executor executes SQL statements.
 type Executor struct {
 	schema   *storage.SchemaManager
@@ -3551,27 +3557,56 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 				col.Nullable = false
 			case parser.ConstraintDefault:
 				if constraint.Default != nil {
-					val, _ := e.evalExpr(constraint.Default, nil)
+					val, err := constantDefaultValue(e, col.Name, constraint.Default)
+					if err != nil {
+						return nil, err
+					}
 					col.Default = val
 				}
 			case parser.ConstraintAutoIncrement:
 				schema.AutoIncrement = true
+			case parser.ConstraintCheck:
+				return nil, pgerror.Unsupported(
+					"CHECK constraints are not supported: column %q declares CHECK; enforce the rule in the application (the engine parsed but never evaluated CHECK, so accepting it would silently allow invalid rows)",
+					col.Name,
+				)
+			case parser.ConstraintForeignKey:
+				return nil, pgerror.Unsupported(
+					"FOREIGN KEY constraints are not supported: column %q declares REFERENCES %s; enforce referential integrity in the application (the engine never validated foreign keys)",
+					col.Name, constraint.RefTable,
+				)
 			}
 		}
 
 		schema.Columns = append(schema.Columns, col)
 	}
 
-	// Handle table-level constraints
+	// Handle table-level constraints. CHECK and FOREIGN KEY are rejected: they
+	// were previously parsed and discarded, which let applications believe a
+	// constraint existed when nothing enforced it.
 	for _, constraint := range stmt.Constraints {
-		if constraint.Type == parser.ConstraintPrimaryKey && len(constraint.Columns) > 0 {
-			schema.PrimaryKey = constraint.Columns[0]
-			for i := range schema.Columns {
-				if strings.EqualFold(schema.Columns[i].Name, schema.PrimaryKey) {
-					schema.Columns[i].PrimaryKey = true
-					schema.Columns[i].Nullable = false
+		switch constraint.Type {
+		case parser.ConstraintPrimaryKey:
+			if len(constraint.Columns) > 0 {
+				if len(constraint.Columns) > 1 {
+					return nil, fmt.Errorf("%w: composite primary keys are not supported; declare a single-column PRIMARY KEY", ErrUnsupportedFeature)
+				}
+				schema.PrimaryKey = constraint.Columns[0]
+				for i := range schema.Columns {
+					if strings.EqualFold(schema.Columns[i].Name, schema.PrimaryKey) {
+						schema.Columns[i].PrimaryKey = true
+						schema.Columns[i].Nullable = false
+					}
 				}
 			}
+		case parser.ConstraintCheck:
+			return nil, pgerror.Unsupported(
+				"CHECK constraints are not supported: enforce the rule in the application instead (the engine parsed but never evaluated CHECK)",
+			)
+		case parser.ConstraintForeignKey:
+			return nil, pgerror.Unsupported(
+				"FOREIGN KEY constraints are not supported: enforce referential integrity in the application instead (the engine never validated foreign keys)",
+			)
 		}
 	}
 
@@ -3960,9 +3995,22 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 			col.Nullable = false
 		case parser.ConstraintDefault:
 			if constraint.Default != nil {
-				val, _ := e.evalExpr(constraint.Default, nil)
+				val, err := constantDefaultValue(e, col.Name, constraint.Default)
+				if err != nil {
+					return nil, err
+				}
 				col.Default = val
 			}
+		case parser.ConstraintCheck:
+			return nil, pgerror.Unsupported(
+				"CHECK constraints are not supported: column %q declares CHECK; enforce the rule in the application instead",
+				col.Name,
+			)
+		case parser.ConstraintForeignKey:
+			return nil, pgerror.Unsupported(
+				"FOREIGN KEY constraints are not supported: column %q declares REFERENCES %s; enforce referential integrity in the application instead",
+				col.Name, constraint.RefTable,
+			)
 		}
 	}
 

+ 27 - 1
pkg/executor/goatcounter_migration_test.go

@@ -1,7 +1,10 @@
 package executor
 
 import (
+	"errors"
 	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 )
 
 // TestGoatCounterSQLiteMigrations runs the shapes of GoatCounter's SQLite
@@ -82,8 +85,12 @@ func TestGoatCounterMigrationRebuildShape(t *testing.T) {
 		user_id INTEGER PRIMARY KEY AUTOINCREMENT,
 		site_id INTEGER NOT NULL,
 		email VARCHAR NOT NULL,
-		last_report_at TIMESTAMP NOT NULL DEFAULT current_timestamp
+		last_report_at TIMESTAMP NOT NULL DEFAULT ''
 	)`)
+	// GoatCounter's real migration declares DEFAULT current_timestamp. That is
+	// rejected (see TestGoatCounterMigrationRejectsFrozenTimestampDefault), so
+	// this rebuild-shape test uses a constant default to keep exercising the
+	// copy/drop/rename/index steps.
 	execMust(t, e, `INSERT INTO users2 (user_id, site_id, email)
 		SELECT user_id, site_id, email FROM users`)
 	execMust(t, e, "DROP TABLE users")
@@ -101,6 +108,25 @@ func TestGoatCounterMigrationRebuildShape(t *testing.T) {
 	}
 }
 
+// TestGoatCounterMigrationRejectsFrozenTimestampDefault records the deliberate
+// break: the GoatCounter schema uses a non-constant timestamp default, which the
+// engine now rejects instead of freezing at DDL time. A fresh GoatCounter tenant
+// cannot be bootstrapped until that schema is changed or a legacy import mode is
+// designed.
+func TestGoatCounterMigrationRejectsFrozenTimestampDefault(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	_, err := execSQL(e, `CREATE TABLE users2 (
+		user_id INTEGER PRIMARY KEY AUTOINCREMENT,
+		site_id INTEGER NOT NULL,
+		email VARCHAR NOT NULL,
+		last_report_at TIMESTAMP NOT NULL DEFAULT current_timestamp
+	)`)
+	if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("error = %v, want ErrFeatureNotSupported", err)
+	}
+}
+
 func TestGoatCounterInsertWithSelectMigrationShape(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)

+ 1 - 1
pkg/executor/session_state_test.go

@@ -248,7 +248,7 @@ func TestPrimaryKeyMultiRowInsertAtomic(t *testing.T) {
 func TestUpdateWithScalarSubqueryInTransaction(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
-	execMust(t, e, "CREATE TABLE watch (user_id INTEGER, repo_id INTEGER, PRIMARY KEY(user_id, repo_id))")
+	execMust(t, e, "CREATE TABLE watch (user_id INTEGER, repo_id INTEGER, PRIMARY KEY(user_id))")
 	execMust(t, e, "CREATE TABLE repository (id INTEGER PRIMARY KEY, num_watches INTEGER)")
 	execMust(t, e, "INSERT INTO repository VALUES (1, 0)")
 	execMust(t, e, "BEGIN")

+ 46 - 0
pkg/httpserver/capabilities_test.go

@@ -0,0 +1,46 @@
+package httpserver
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestCapabilitiesEndpoint(t *testing.T) {
+	server := setupKVTestServer(t)
+
+	r := httptest.NewRequest(http.MethodGet, "/capabilities", 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 struct {
+		SchemaVersion int               `json:"schema_version"`
+		Engine        string            `json:"engine"`
+		ErrorCodes    map[string]string `json:"error_codes"`
+		Constraints   struct {
+			Unique struct {
+				Supported bool   `json:"supported"`
+				SQLState  string `json:"sqlstate"`
+			} `json:"unique"`
+		} `json:"constraints"`
+	}
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if resp.SchemaVersion < 1 {
+		t.Fatalf("schema_version = %d", resp.SchemaVersion)
+	}
+	if resp.Engine != "pizzasql" {
+		t.Fatalf("engine = %q", resp.Engine)
+	}
+	if !resp.Constraints.Unique.Supported || resp.Constraints.Unique.SQLState != "23505" {
+		t.Fatalf("unique capability = %+v", resp.Constraints.Unique)
+	}
+	if resp.ErrorCodes["40001"] != "serialization_failure" {
+		t.Fatalf("error_codes = %v", resp.ErrorCodes)
+	}
+}

+ 66 - 6
pkg/httpserver/handler.go

@@ -11,6 +11,7 @@ import (
 
 	"github.com/goccy/go-json"
 
+	"github.com/danfragoso/pizzasql-next/pkg/capabilities"
 	"github.com/danfragoso/pizzasql-next/pkg/csvexport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
@@ -140,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
 		}
 
@@ -301,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
 	}
 
@@ -398,6 +396,68 @@ func (s *Server) handleSchemaTable(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, resp, pretty)
 }
 
+// handleCapabilities serves the machine-readable capability document so clients
+// can discover supported SQL surface for this engine build.
+func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) {
+	pretty := r.URL.Query().Get("pretty") == "true"
+	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{}{

+ 2 - 0
pkg/httpserver/server.go

@@ -161,6 +161,8 @@ 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)
 	mux.HandleFunc("/transaction/begin", s.handleTransactionBegin)

+ 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")
+	}
+}

+ 33 - 0
pkg/parser/arrays_test.go

@@ -0,0 +1,33 @@
+package parser
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+func TestArrayParametersAndAnyPredicatesRejected(t *testing.T) {
+	for _, sql := range []string{
+		"SELECT * FROM t WHERE id = ANY('{1,2,3}')",
+		"SELECT * FROM t WHERE id <> ANY('{1,2,3}')",
+		"SELECT * FROM t WHERE id = ALL('{1,2,3}')",
+		"SELECT * FROM t WHERE id = ANY(ARRAY[1,2])",
+	} {
+		t.Run(sql, func(t *testing.T) {
+			_, err := New(lexer.New(sql)).Parse()
+			if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+				t.Fatalf("error = %v, want ErrFeatureNotSupported", err)
+			}
+		})
+	}
+}
+
+func TestAnyAsOrdinaryColumnStillParses(t *testing.T) {
+	// ANY is only a predicate keyword when followed by "(". A column literally
+	// named any must keep working.
+	if _, err := New(lexer.New("SELECT any FROM t")).Parse(); err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+}

+ 39 - 0
pkg/parser/locking_test.go

@@ -0,0 +1,39 @@
+package parser
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+func TestSelectLockingClauseRejected(t *testing.T) {
+	for _, sql := range []string{
+		"SELECT * FROM t FOR UPDATE",
+		"SELECT * FROM t WHERE id = 1 FOR UPDATE NOWAIT",
+		"SELECT * FROM t FOR SHARE",
+		"SELECT * FROM t FOR NO KEY UPDATE SKIP LOCKED",
+		"SELECT id FROM t ORDER BY id LIMIT 10 FOR UPDATE",
+		"SELECT id FROM t UNION SELECT id FROM u FOR UPDATE",
+	} {
+		t.Run(sql, func(t *testing.T) {
+			_, err := New(lexer.New(sql)).Parse()
+			if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+				t.Fatalf("error = %v, want ErrFeatureNotSupported", err)
+			}
+		})
+	}
+}
+
+func TestSelectWithoutLockingClauseStillParses(t *testing.T) {
+	for _, sql := range []string{
+		"SELECT * FROM t",
+		"SELECT * FROM t WHERE id = 1",
+		"SELECT * FROM t ORDER BY id LIMIT 1",
+	} {
+		if _, err := New(lexer.New(sql)).Parse(); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+}

+ 45 - 1
pkg/parser/parser.go

@@ -4,6 +4,7 @@ import (
 	"strings"
 
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 )
 
 // Parser parses SQL statements into an AST.
@@ -286,6 +287,10 @@ func (p *Parser) parseSelect() (*SelectStmt, error) {
 		stmt.Offset = offset
 	}
 
+	if err := p.rejectLockingClause(); err != nil {
+		return nil, err
+	}
+
 	return stmt, nil
 }
 
@@ -421,6 +426,9 @@ func (p *Parser) parseCompoundChain(first *SelectStmt) (*CompoundSelect, error)
 		}
 		compound.Offset = offset
 	}
+	if err := p.rejectLockingClause(); err != nil {
+		return nil, err
+	}
 
 	return compound, nil
 }
@@ -606,7 +614,7 @@ func (p *Parser) parseTableRef() (*TableRef, error) {
 		}
 		ref.Alias = p.curToken.Literal
 		p.nextToken()
-	} else if p.curTokenIs(lexer.TokenIdent) && !p.isClauseKeyword() {
+	} else if p.curTokenIs(lexer.TokenIdent) && !p.isClauseKeyword() && !p.isLockingClauseStart() {
 		ref.Alias = p.curToken.Literal
 		p.nextToken()
 	}
@@ -635,6 +643,33 @@ func (p *Parser) isClauseKeyword() bool {
 	return false
 }
 
+// isLockingClauseStart reports whether the current identifier starts a
+// PostgreSQL row-locking clause. FOR is not a reserved keyword in this lexer,
+// so without this guard "FROM t FOR UPDATE" would treat FOR as a table alias.
+func (p *Parser) isLockingClauseStart() bool {
+	return p.curTokenIs(lexer.TokenIdent) && strings.EqualFold(p.curToken.Literal, "FOR")
+}
+
+// rejectLockingClause rejects SELECT ... FOR UPDATE/SHARE and its NOWAIT / SKIP
+// LOCKED variants. The engine has no row locks; claiming support by parsing the
+// clause would silently provide weaker isolation than PostgreSQL.
+func (p *Parser) rejectLockingClause() error {
+	if !p.isLockingClauseStart() {
+		return nil
+	}
+	locking := p.peekTokenIs(lexer.TokenUPDATE) ||
+		(p.peekTokenIs(lexer.TokenIdent) &&
+			(strings.EqualFold(p.peekToken.Literal, "SHARE") ||
+				strings.EqualFold(p.peekToken.Literal, "NO") ||
+				strings.EqualFold(p.peekToken.Literal, "KEY")))
+	if !locking {
+		return nil
+	}
+	return pgerror.Unsupported(
+		"SELECT ... FOR UPDATE/SHARE row locking (including NOWAIT and SKIP LOCKED) is not supported; use the documented optimistic transaction model with SQLSTATE 40001 retries instead",
+	)
+}
+
 func (p *Parser) parseJoin() (*JoinClause, error) {
 	join := &JoinClause{Type: JoinInner}
 
@@ -2487,6 +2522,12 @@ func (p *Parser) parseUnaryExpr() (Expr, error) {
 
 func (p *Parser) parsePrimaryExpr() (Expr, error) {
 	switch p.curToken.Type {
+	case lexer.TokenALL:
+		if p.peekTokenIs(lexer.TokenLParen) {
+			return nil, pgerror.Unsupported("array parameters and ANY/ALL predicates are not supported (PizzaSQL has no array type); expand the list into IN (...) with individual parameters")
+		}
+		return nil, p.curError("unexpected token in expression: " + p.curToken.Type.String())
+
 	case lexer.TokenNumber:
 		expr := &LiteralExpr{Type: lexer.TokenNumber, Value: p.curToken.Literal}
 		p.nextToken()
@@ -2590,6 +2631,9 @@ func (p *Parser) parseIdentOrFunction() (Expr, error) {
 
 	// Check for function call
 	if p.curTokenIs(lexer.TokenLParen) {
+		if strings.EqualFold(name, "ANY") {
+			return nil, pgerror.Unsupported("array parameters and ANY/ALL predicates are not supported (PizzaSQL has no array type); expand the list into IN (...) with individual parameters")
+		}
 		return p.parseFunctionCall(name)
 	}
 

+ 28 - 0
pkg/pgerror/pgerror.go

@@ -0,0 +1,28 @@
+// Package pgerror holds SQLSTATE metadata and sentinel errors shared by the
+// parser, executor, and protocol servers.
+package pgerror
+
+import (
+	"errors"
+	"fmt"
+)
+
+// SQLSTATE codes used by PizzaSQL's protocol and HTTP surfaces.
+const (
+	CodeFeatureNotSupported  = "0A000"
+	CodeUniqueViolation      = "23505"
+	CodeSerializationFailure = "40001"
+	CodeSyntaxError          = "42601"
+	CodeInternalError        = "XX000"
+)
+
+// ErrFeatureNotSupported marks SQL that PizzaSQL intentionally rejects instead
+// of accepting with different semantics. Protocol layers map it to SQLSTATE
+// 0A000 (feature_not_supported) rather than a generic syntax or internal error.
+var ErrFeatureNotSupported = errors.New("feature not supported")
+
+// Unsupported returns an error that wraps ErrFeatureNotSupported with a
+// client-facing explanation and migration hint.
+func Unsupported(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrFeatureNotSupported, fmt.Sprintf(format, args...))
+}

+ 57 - 0
pkg/pgserver/array_param_test.go

@@ -0,0 +1,57 @@
+package pgserver
+
+import (
+	"bufio"
+	"testing"
+)
+
+// A pg client that binds a text[] parameter for "id = ANY($1)" must receive a
+// clear feature-not-supported error instead of a silently empty result.
+func TestArrayParameterRejectedOverExtendedQuery(t *testing.T) {
+	c, client := newDataConnection(t)
+	reader := bufio.NewReader(client)
+
+	parse := NewMessageBuilder()
+	parse.WriteString("s1")
+	parse.WriteString("SELECT * FROM t WHERE id = ANY($1)")
+	parse.WriteInt16(1)
+	parse.WriteInt32(1009) // text[]
+	if err := c.handleParse(&Message{Type: MsgParse, Data: parse.Bytes()}); err != nil {
+		t.Fatalf("handleParse: %v", err)
+	}
+
+	bind := NewMessageBuilder()
+	bind.WriteString("p1")
+	bind.WriteString("s1")
+	bind.WriteInt16(0) // all-text parameter formats
+	bind.WriteInt16(1) // one parameter
+	bind.WriteInt32(5)
+	bind.WriteBytes([]byte("{1,2}"))
+	bind.WriteInt16(0) // all-text result formats
+	if err := c.handleBind(&Message{Type: MsgBind, Data: bind.Bytes()}); err != nil {
+		t.Fatalf("handleBind: %v", err)
+	}
+	execute := NewMessageBuilder()
+	execute.WriteString("p1")
+	execute.WriteInt32(0)
+	done := make(chan error, 1)
+	// handleBind buffered BindComplete without flushing; handleExecute's error
+	// path flushes both messages, so read them in order.
+	go func() { done <- c.handleExecute(&Message{Type: MsgExecute, Data: execute.Bytes()}) }()
+	var errMsg *Message
+	for errMsg == nil {
+		msg, err := ReadMessage(reader)
+		if err != nil {
+			t.Fatalf("read response: %v", err)
+		}
+		if msg.Type == MsgErrorResponse {
+			errMsg = msg
+		}
+	}
+	if err := <-done; err != nil {
+		t.Fatalf("handleExecute: %v", err)
+	}
+	if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+}

+ 57 - 6
pkg/pgserver/connection.go

@@ -18,7 +18,9 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"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
@@ -141,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 {
@@ -306,7 +316,14 @@ func (c *Connection) handleQuery(msg *Message) error {
 	p := parser.New(l)
 	stmts, err := p.ParseMultiple()
 	if err != nil {
-		c.sendError("ERROR", ErrCodeSyntaxError, fmt.Sprintf("Syntax error: %v", err))
+		code := ErrCodeSyntaxError
+		if errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			code = ErrCodeFeatureNotSupported
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
+		}
+		c.sendError("ERROR", code, fmt.Sprintf("Syntax error: %v", err))
 		return c.sendReadyForQuery()
 	}
 
@@ -320,13 +337,26 @@ func (c *Connection) handleQuery(msg *Message) error {
 		result, err := c.executor.Execute(stmt)
 		if err != nil {
 			code := ErrCodeInternalError
+			message := fmt.Sprintf("Execution error: %v", err)
+			var uniqueErr *storage.UniqueViolationError
 			if errors.Is(err, storage.ErrSerialization) {
 				code = ErrCodeSerializationFailure
 				c.txStatus = TxStatusIdle
+			} else if errors.As(err, &uniqueErr) {
+				code = ErrCodeUniqueViolation
+				message = fmt.Sprintf("Execution error: UNIQUE constraint failed: %s", uniqueErr.Constraint)
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
+			} else if errors.Is(err, pgerror.ErrFeatureNotSupported) || errors.Is(err, executor.ErrUnsupportedFeature) {
+				code = ErrCodeFeatureNotSupported
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if c.txStatus == TxStatusInBlock {
 				c.txStatus = TxStatusFailed
 			}
-			c.sendError("ERROR", code, fmt.Sprintf("Execution error: %v", err))
+			c.sendError("ERROR", code, message)
 			return c.sendReadyForQuery()
 		}
 		if err := c.sendResult(result, stmt); err != nil {
@@ -484,17 +514,38 @@ func (c *Connection) handleExecute(msg *Message) error {
 	l := lexer.New(portal.query)
 	stmt, err := parser.New(l).Parse()
 	if err != nil {
-		return c.failExtended(ErrCodeSyntaxError, fmt.Errorf("syntax error: %w", err))
+		code := ErrCodeSyntaxError
+		if errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			code = ErrCodeFeatureNotSupported
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
+		}
+		return c.failExtended(code, fmt.Errorf("syntax error: %w", err))
 	}
 	result, err := c.executor.Execute(stmt)
 	if err != nil {
 		code := ErrCodeInternalError
+		var uniqueErr *storage.UniqueViolationError
 		if errors.Is(err, storage.ErrSerialization) {
 			code = ErrCodeSerializationFailure
 			c.txStatus = TxStatusIdle
+		} else if errors.As(err, &uniqueErr) {
+			code = ErrCodeUniqueViolation
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
+		} else if errors.Is(err, pgerror.ErrFeatureNotSupported) || errors.Is(err, executor.ErrUnsupportedFeature) {
+			code = ErrCodeFeatureNotSupported
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
 		} else if c.txStatus == TxStatusInBlock {
 			c.txStatus = TxStatusFailed
 		}
+		if uniqueErr != nil {
+			return c.failExtended(code, fmt.Errorf("execution error: UNIQUE constraint failed: %s", uniqueErr.Constraint))
+		}
 		return c.failExtended(code, fmt.Errorf("execution error: %w", err))
 	}
 	return c.sendResult(result, stmt)
@@ -1194,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
 	}
@@ -1239,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"

+ 114 - 0
pkg/pgserver/features_test.go

@@ -5,6 +5,7 @@ import (
 	"bytes"
 	"encoding/binary"
 	"net"
+	"strings"
 	"testing"
 
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
@@ -156,3 +157,116 @@ func TestGetCommandTagSelectUsesRowCount(t *testing.T) {
 		t.Fatalf("tag = %q, want SELECT 2", tag)
 	}
 }
+
+// errorMessage extracts the human-readable message from an ErrorResponse.
+func errorMessage(msg *Message) string {
+	if msg == nil || msg.Type != MsgErrorResponse {
+		return ""
+	}
+	data := msg.Data
+	for i := 0; i < len(data); {
+		if data[i] == 0 {
+			break
+		}
+		field := data[i]
+		i++
+		end := bytes.IndexByte(data[i:], 0)
+		if end < 0 {
+			break
+		}
+		value := string(data[i : i+end])
+		if field == ErrorFieldMessage {
+			return value
+		}
+		i += end + 1
+	}
+	return ""
+}
+
+func TestSimpleQueryUniqueViolationErrorCode(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT UNIQUE)"), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("INSERT INTO t VALUES (1, 'a@x')"), 0)})
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("INSERT INTO t VALUES (2, 'a@x')"), 0)})
+	if len(msgs) != 2 {
+		t.Fatalf("got %d messages, want ErrorResponse+ReadyForQuery", len(msgs))
+	}
+	if code := errorCode(msgs[0]); code != ErrCodeUniqueViolation {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeUniqueViolation)
+	}
+	if message := errorMessage(msgs[0]); !strings.Contains(message, "uniq_t_email") {
+		t.Fatalf("error message = %q, want constraint uniq_t_email", message)
+	}
+}
+
+func TestExtendedQueryUniqueViolationErrorCode(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT UNIQUE)"), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("INSERT INTO t VALUES (1, 'a@x')"), 0)})
+
+	parse := NewMessageBuilder()
+	parse.WriteString("")
+	parse.WriteString("INSERT INTO t VALUES (2, 'a@x')")
+	parse.WriteInt16(0)
+
+	bind := NewMessageBuilder()
+	bind.WriteString("")
+	bind.WriteString("")
+	bind.WriteInt16(0) // parameter format codes
+	bind.WriteInt16(0) // parameter values
+	bind.WriteInt16(0) // result format codes
+
+	execute := NewMessageBuilder()
+	execute.WriteString("")
+	execute.WriteInt32(0)
+
+	done := make(chan error, 1)
+	go func() {
+		if err := c.handleParse(&Message{Type: MsgParse, Data: parse.Bytes()}); err != nil {
+			done <- err
+			return
+		}
+		if err := c.handleBind(&Message{Type: MsgBind, Data: bind.Bytes()}); err != nil {
+			done <- err
+			return
+		}
+		done <- c.handleExecute(&Message{Type: MsgExecute, Data: execute.Bytes()})
+	}()
+
+	reader := bufio.NewReader(client)
+	var errMsg *Message
+	for errMsg == nil {
+		msg, err := ReadMessage(reader)
+		if err != nil {
+			t.Fatalf("read response: %v", err)
+		}
+		if msg.Type == MsgErrorResponse {
+			errMsg = msg
+		}
+	}
+	if err := <-done; err != nil {
+		t.Fatalf("extended query: %v", err)
+	}
+	if code := errorCode(errMsg); code != ErrCodeUniqueViolation {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeUniqueViolation)
+	}
+	if message := errorMessage(errMsg); !strings.Contains(message, "uniq_t_email") {
+		t.Fatalf("error message = %q, want constraint uniq_t_email", message)
+	}
+}
+
+func TestCompositePrimaryKeyDDLErrorCode(t *testing.T) {
+	c, client := newDataConnection(t)
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (a TEXT, b TEXT, PRIMARY KEY (a,b))"), 0)})
+	if len(msgs) != 2 {
+		t.Fatalf("got %d messages, want ErrorResponse+ReadyForQuery", len(msgs))
+	}
+	if code := errorCode(msgs[0]); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+	if message := errorMessage(msgs[0]); !strings.Contains(message, "composite primary key") {
+		t.Fatalf("error message = %q, want a composite primary key error", message)
+	}
+}

+ 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())
+	}
+}

+ 1 - 0
pkg/pgserver/protocol.go

@@ -92,6 +92,7 @@ const (
 	ErrCodeFeatureNotSupported  = "0A000"
 	ErrCodeTransactionAborted   = "25P02"
 	ErrCodeSerializationFailure = "40001"
+	ErrCodeUniqueViolation      = "23505"
 )
 
 // Message represents a PostgreSQL protocol message

+ 21 - 0
pkg/pgserver/unsupported_constraints_test.go

@@ -0,0 +1,21 @@
+package pgserver
+
+import (
+	"testing"
+)
+
+func TestCheckAndForeignKeyRejectedOverSimpleQuery(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE parent (id INTEGER PRIMARY KEY)"), 0)})
+
+	for _, sql := range []string{
+		"CREATE TABLE t1 (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))",
+		"CREATE TABLE t2 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))",
+		"CREATE TABLE t3 (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id))",
+	} {
+		msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(sql), 0)})
+		if code := errorCode(msgs[0]); code != ErrCodeFeatureNotSupported {
+			t.Fatalf("%s: error code = %q, want %q", sql, code, ErrCodeFeatureNotSupported)
+		}
+	}
+}

+ 54 - 0
pkg/pgserver/unsupported_default_test.go

@@ -0,0 +1,54 @@
+package pgserver
+
+import (
+	"bufio"
+	"bytes"
+	"net"
+	"testing"
+)
+
+func runExtendedMessage(t *testing.T, c *Connection, client net.Conn, msg *Message) *Message {
+	t.Helper()
+	done := make(chan error, 1)
+	go func() {
+		done <- c.handleExecute(msg)
+	}()
+	m, err := ReadMessage(bufio.NewReader(client))
+	if err != nil {
+		t.Fatalf("read response: %v", err)
+	}
+	if err := <-done; err != nil {
+		t.Fatalf("handle message: %v", err)
+	}
+	return m
+}
+
+func TestNonConstantDefaultRejectedOverSimpleQuery(t *testing.T) {
+	c, client := newDataConnection(t)
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"), 0)})
+	if len(msgs) != 2 {
+		t.Fatalf("got %d messages, want ErrorResponse+ReadyForQuery", len(msgs))
+	}
+	if code := errorCode(msgs[0]); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+	if message := errorField(t, msgs[0], ErrorFieldMessage); !bytes.Contains([]byte(message), []byte("constant")) {
+		t.Fatalf("error message = %q, want an explanation about constant defaults", message)
+	}
+}
+
+func TestNonConstantDefaultRejectedOverExtendedQuery(t *testing.T) {
+	c, client := newDataConnection(t)
+
+	// Parse does not parse the SQL (it is parsed at Execute), so install the
+	// portal directly; this exercises the extended-protocol execution error path.
+	c.portals["p1"] = &portal{query: "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"}
+
+	execute := NewMessageBuilder()
+	execute.WriteString("p1")
+	execute.WriteInt32(0)
+	errMsg := runExtendedMessage(t, c, client, &Message{Type: MsgExecute, Data: execute.Bytes()})
+	if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+}

+ 45 - 0
pkg/pgserver/unsupported_locking_test.go

@@ -0,0 +1,45 @@
+package pgserver
+
+import (
+	"bytes"
+	"testing"
+)
+
+// errorField extracts a named field from an ErrorResponse.
+func errorField(t *testing.T, msg *Message, want byte) string {
+	t.Helper()
+	if msg == nil || msg.Type != MsgErrorResponse {
+		t.Fatalf("message type = %v, want ErrorResponse", msg)
+	}
+	data := msg.Data
+	for i := 0; i < len(data); {
+		if data[i] == 0 {
+			break
+		}
+		field := data[i]
+		i++
+		end := bytes.IndexByte(data[i:], 0)
+		if end < 0 {
+			break
+		}
+		value := string(data[i : i+end])
+		if field == want {
+			return value
+		}
+		i += end + 1
+	}
+	return ""
+}
+
+func TestLockingClauseRejectedOverSimpleQuery(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY)"), 0)})
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("SELECT * FROM t FOR UPDATE"), 0)})
+	if code := errorCode(msgs[0]); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+	if message := errorField(t, msgs[0], ErrorFieldMessage); !bytes.Contains([]byte(message), []byte("row locking")) {
+		t.Fatalf("error message = %q, want a row-locking explanation", message)
+	}
+}

+ 14 - 3
pkg/storage/table.go

@@ -14,6 +14,17 @@ import (
 // Row represents a database row.
 type Row map[string]interface{}
 
+// UniqueViolationError reports a duplicate value on a UNIQUE index. The
+// constraint name identifies the violated index so protocol layers can surface a
+// machine-detectable error.
+type UniqueViolationError struct {
+	Constraint string
+}
+
+func (e *UniqueViolationError) Error() string {
+	return fmt.Sprintf("UNIQUE constraint failed: %s", e.Constraint)
+}
+
 // TableManager manages table data operations.
 type TableManager struct {
 	pool     *KVPool
@@ -1804,7 +1815,7 @@ func (m *TableManager) validateUniqueRows(table string, pending []Row, excludedK
 				seen[idx.Name] = perIndex
 			}
 			if perIndex[encoded] {
-				return fmt.Errorf("UNIQUE constraint failed: %s", idx.Name)
+				return &UniqueViolationError{Constraint: idx.Name}
 			}
 			perIndex[encoded] = true
 		}
@@ -1842,7 +1853,7 @@ func (m *TableManager) validateUniqueRows(table string, pending []Row, excludedK
 						continue
 					}
 					if perIndex := seen[idx.Name]; perIndex != nil && perIndex[encoded] {
-						return fmt.Errorf("UNIQUE constraint failed: %s", idx.Name)
+						return &UniqueViolationError{Constraint: idx.Name}
 					}
 				}
 			}
@@ -1915,7 +1926,7 @@ func (m *TableManager) ValidateUniqueIndex(index *Index) error {
 			return false, nil
 		}
 		if seen[encoded] {
-			return true, fmt.Errorf("UNIQUE constraint failed: %s", index.Name)
+			return true, &UniqueViolationError{Constraint: index.Name}
 		}
 		seen[encoded] = true
 		return false, nil

+ 25 - 0
pkg/storage/unique_test.go

@@ -1,6 +1,7 @@
 package storage
 
 import (
+	"errors"
 	"fmt"
 	"strings"
 	"sync"
@@ -77,6 +78,30 @@ func TestUniqueIndexEnforcesOnInsert(t *testing.T) {
 	}
 }
 
+func TestUniqueViolationErrorCarriesConstraint(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	err := tables.Insert("t", Row{"id": int64(2), "email": "a@x"})
+	var violation *UniqueViolationError
+	if !errors.As(err, &violation) {
+		t.Fatalf("error %v is not a *UniqueViolationError", err)
+	}
+	if violation.Constraint != "uq_email" {
+		t.Fatalf("constraint = %q, want uq_email", violation.Constraint)
+	}
+	if err.Error() != "UNIQUE constraint failed: uq_email" {
+		t.Fatalf("message = %q, want UNIQUE constraint failed: uq_email", err.Error())
+	}
+}
+
 func TestUniqueIndexAllowsMultipleNulls(t *testing.T) {
 	schemas, tables := uniqueTable(t,
 		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},

+ 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"