Переглянути джерело

Merge remote-tracking branch 'origin/master' into feat/capabilities-endpoint

Bot 21 годин тому
батько
коміт
91a0dc3537

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

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

+ 45 - 3
pkg/pgserver/connection.go

@@ -18,6 +18,7 @@ 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"
 )
 
@@ -306,7 +307,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 +328,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 +505,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)

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

+ 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},