Преглед изворни кода

Merge pull request #2 from database-pizza/fix/ddl-strictness

fix(ddl): reject DEFAULT expressions, CHECK, FOREIGN KEY, and row locking with 0A000
Danilo Fragoso пре 21 часа
родитељ
комит
00805ead8c

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

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

+ 52 - 12
pkg/executor/executor.go

@@ -13,6 +13,7 @@ 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"
 )
@@ -3556,30 +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 {
-			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
+		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)",
+			)
 		}
 	}
 
@@ -3968,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)

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

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

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

+ 19 - 4
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()
 	}
 
@@ -331,7 +339,7 @@ func (c *Connection) handleQuery(msg *Message) error {
 				if c.txStatus == TxStatusInBlock {
 					c.txStatus = TxStatusFailed
 				}
-			} else if errors.Is(err, executor.ErrUnsupportedFeature) {
+			} else if errors.Is(err, pgerror.ErrFeatureNotSupported) || errors.Is(err, executor.ErrUnsupportedFeature) {
 				code = ErrCodeFeatureNotSupported
 				if c.txStatus == TxStatusInBlock {
 					c.txStatus = TxStatusFailed
@@ -497,7 +505,14 @@ 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 {
@@ -511,7 +526,7 @@ func (c *Connection) handleExecute(msg *Message) error {
 			if c.txStatus == TxStatusInBlock {
 				c.txStatus = TxStatusFailed
 			}
-		} else if errors.Is(err, executor.ErrUnsupportedFeature) {
+		} else if errors.Is(err, pgerror.ErrFeatureNotSupported) || errors.Is(err, executor.ErrUnsupportedFeature) {
 			code = ErrCodeFeatureNotSupported
 			if c.txStatus == TxStatusInBlock {
 				c.txStatus = TxStatusFailed

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