Bladeren bron

Merge pull request #43 from database-pizza/engine/c-catalog

broaden pg_catalog and information_schema introspection
Danilo Fragoso 22 uur geleden
bovenliggende
commit
99135e03cb

+ 11 - 3
pkg/analyzer/analyzer.go

@@ -326,15 +326,23 @@ func (a *Analyzer) defineTableRef(ref parser.TableRef) error {
 		return nil
 	}
 
-	table, ok := a.catalog.GetTable(ref.Name)
+	// A non-public schema qualifier selects a schema-qualified catalog entry.
+	// Public tables are registered by bare name, so `public.t` resolves to `t`.
+	catalogName := ref.Name
+	if ref.Schema != "" && !strings.EqualFold(ref.Schema, "public") {
+		// Qualified catalog relations are registered under their lowercase
+		// schema-qualified name; schema names are case-insensitive here.
+		catalogName = strings.ToLower(ref.Schema) + "." + strings.ToLower(ref.Name)
+	}
+	table, ok := a.catalog.GetTable(catalogName)
 	if !ok {
 		return &AnalysisError{
 			Type:    ErrTableNotFound,
-			Message: fmt.Sprintf("table not found: %s", ref.Name),
+			Message: fmt.Sprintf("table not found: %s", catalogName),
 		}
 	}
 	a.scope.DefineTable(&TableInfo{
-		Name:    table.Name,
+		Name:    ref.Name,
 		Columns: table.Columns,
 		Alias:   ref.Alias,
 		IsView:  table.IsView,

+ 27 - 6
pkg/analyzer/types.go

@@ -270,12 +270,33 @@ var builtinFunctions = map[string]FunctionSignature{
 	"TIMEDIFF":  {Name: "TIMEDIFF", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeAny}, ReturnType: TypeText, IsAggregate: false},
 
 	// SQLite specific
-	"SQLITE_VERSION":    {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
-	"PIZZASQL_VERSION":  {Name: "PIZZASQL_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
-	"PG_BACKEND_PID":    {Name: "PG_BACKEND_PID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
-	"LAST_INSERT_ROWID": {Name: "LAST_INSERT_ROWID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
-	"CHANGES":           {Name: "CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
-	"TOTAL_CHANGES":     {Name: "TOTAL_CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
+	"SQLITE_VERSION":   {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"PIZZASQL_VERSION": {Name: "PIZZASQL_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"PG_BACKEND_PID":   {Name: "PG_BACKEND_PID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
+	"VERSION":          {Name: "VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+
+	// PostgreSQL catalog-introspection functions. The engine answers these from
+	// its virtual pg_catalog snapshot; obj_description, col_description, and
+	// pg_get_serial_sequence return NULL because there are no comments or
+	// sequences to describe.
+	"CURRENT_SCHEMA":         {Name: "CURRENT_SCHEMA", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"CURRENT_DATABASE":       {Name: "CURRENT_DATABASE", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"CURRENT_SETTING":        {Name: "CURRENT_SETTING", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeText, TypeBoolean}, ReturnType: TypeText, IsAggregate: false},
+	"FORMAT_TYPE":            {Name: "FORMAT_TYPE", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeInteger}, ReturnType: TypeText, IsAggregate: false},
+	"PG_TABLE_IS_VISIBLE":    {Name: "PG_TABLE_IS_VISIBLE", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeBoolean, IsAggregate: false},
+	"PG_GET_INDEXDEF":        {Name: "PG_GET_INDEXDEF", MinArgs: 1, MaxArgs: 3, ArgTypes: []Type{TypeAny, TypeAny, TypeBoolean}, ReturnType: TypeText, IsAggregate: false},
+	"PG_GET_CONSTRAINTDEF":   {Name: "PG_GET_CONSTRAINTDEF", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeBoolean}, ReturnType: TypeText, IsAggregate: false},
+	"PG_GET_EXPR":            {Name: "PG_GET_EXPR", MinArgs: 1, MaxArgs: 3, ArgTypes: []Type{TypeAny, TypeAny, TypeBoolean}, ReturnType: TypeText, IsAggregate: false},
+	"PG_GET_SERIAL_SEQUENCE": {Name: "PG_GET_SERIAL_SEQUENCE", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeText, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"OBJ_DESCRIPTION":        {Name: "OBJ_DESCRIPTION", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"COL_DESCRIPTION":        {Name: "COL_DESCRIPTION", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeInteger}, ReturnType: TypeText, IsAggregate: false},
+	"HAS_TABLE_PRIVILEGE":    {Name: "HAS_TABLE_PRIVILEGE", MinArgs: 2, MaxArgs: 3, ArgTypes: []Type{TypeAny, TypeText, TypeText}, ReturnType: TypeBoolean, IsAggregate: false},
+	"PG_GET_USERBYID":        {Name: "PG_GET_USERBYID", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"TO_REGCLASS":            {Name: "TO_REGCLASS", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeText}, ReturnType: TypeAny, IsAggregate: false},
+	"TO_REGTYPE":             {Name: "TO_REGTYPE", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeText}, ReturnType: TypeAny, IsAggregate: false},
+	"LAST_INSERT_ROWID":      {Name: "LAST_INSERT_ROWID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
+	"CHANGES":                {Name: "CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
+	"TOTAL_CHANGES":          {Name: "TOTAL_CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
 
 	// Other
 	"HEX":      {Name: "HEX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeBlob}, ReturnType: TypeText, IsAggregate: false},

File diff suppressed because it is too large
+ 13 - 0
pkg/capabilities/capabilities.go


+ 74 - 0
pkg/capabilities/capabilities_test.go

@@ -347,3 +347,77 @@ func TestBoundedExecuteIsAdvertised(t *testing.T) {
 		}
 	}
 }
+
+// The catalog document advertises the read-only introspection surface an ORM
+// uses: the relation and function matrix, the deterministic OID scheme, and the
+// SET/SHOW compatibility surface. pkg/executor covers each relation.
+func TestCatalogIntrospectionIsAdvertised(t *testing.T) {
+	doc := Current()
+	catalog := doc.Catalog
+	if !catalog.PgCatalog.Supported || !catalog.InformationSchema.Supported {
+		t.Fatalf("catalog = %+v, want both schemas supported", catalog)
+	}
+	if catalog.PgCatalog.SQLState != pgerror.CodeFeatureNotSupported {
+		t.Fatalf("pg_catalog SQLSTATE = %q, want 0A000 for writes", catalog.PgCatalog.SQLState)
+	}
+	if catalog.Writable {
+		t.Fatal("catalog relations are read-only")
+	}
+	for _, want := range []string{
+		"pg_catalog.pg_class", "pg_catalog.pg_namespace", "pg_catalog.pg_attribute",
+		"pg_catalog.pg_type", "pg_catalog.pg_index", "pg_catalog.pg_constraint",
+		"pg_catalog.pg_attrdef", "pg_catalog.pg_database", "pg_catalog.pg_tablespace",
+		"pg_catalog.pg_proc", "pg_catalog.pg_am", "pg_catalog.pg_description",
+		"information_schema.tables", "information_schema.columns",
+		"information_schema.table_constraints", "information_schema.key_column_usage",
+		"information_schema.referential_constraints",
+		"information_schema.constraint_column_usage", "information_schema.schemata",
+		"information_schema.sequences", "information_schema.views",
+	} {
+		found := false
+		for _, relation := range catalog.Relations {
+			if relation == want {
+				found = true
+				break
+			}
+		}
+		if !found {
+			t.Errorf("catalog.relations does not list %q", want)
+		}
+	}
+	for _, want := range []string{
+		"current_schema", "current_database", "format_type", "pg_table_is_visible",
+		"pg_get_indexdef", "pg_get_constraintdef", "pg_get_expr", "pg_get_serial_sequence",
+		"obj_description", "col_description", "has_table_privilege", "to_regclass", "to_regtype",
+	} {
+		found := false
+		for _, function := range catalog.Functions {
+			if function == want {
+				found = true
+				break
+			}
+		}
+		if !found {
+			t.Errorf("catalog.functions does not list %q", want)
+		}
+	}
+	if !strings.Contains(catalog.OIDStrategy, "FNV-1a") || !strings.Contains(catalog.OIDStrategy, "deterministic") {
+		t.Fatalf("oid_strategy = %q", catalog.OIDStrategy)
+	}
+	if !catalog.SetStatements.Supported || catalog.SetStatements.Status != "search_path_only" {
+		t.Fatalf("set_statements = %+v", catalog.SetStatements)
+	}
+	for _, want := range []string{"search_path", "SHOW", "current_schema", "SET LOCAL"} {
+		if !strings.Contains(catalog.SetStatements.Notes, want) {
+			t.Errorf("set_statements notes do not mention %q", want)
+		}
+	}
+
+	body, err := json.Marshal(doc)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	if !strings.Contains(string(body), `"writable":false`) {
+		t.Fatalf("document does not advertise the catalog as read-only: %s", body)
+	}
+}

+ 261 - 38
pkg/executor/executor.go

@@ -40,14 +40,32 @@ type Executor struct {
 	// Last SchemaManager version reflected in catalog.
 	catalogVersion uint64
 
+	// pgCatalog caches the virtual PostgreSQL catalog snapshot for the current
+	// statement. It is discarded by Execute so each statement observes DDL
+	// committed (or staged) since the previous one. A statement that evaluates
+	// catalog-backed expressions primes it before scanning; lockedScanDepth then
+	// reports that the session lock is already held.
+	pgCatalog       *catalogSnapshot
+	lockedScanDepth int
+
+	// searchPath is the session search path used by current_schema() and SHOW
+	// search_path. Only public, pg_catalog, and information_schema are
+	// materialized; drivers may name other schemas. txSearchPath holds the session
+	// value captured at BEGIN so ROLLBACK restores it, and localSearchPath holds
+	// SET LOCAL, which expires at the end of the transaction.
+	searchPath      []string
+	txSearchPath    []string
+	localSearchPath []string
+
 	// Multi-database support
 	attachedDatabases map[string]*DatabaseConnection // alias -> connection
 	currentDatabase   string                         // current database alias (default is "main")
 
 	// Transaction state
-	inTransaction   bool
-	savepoints      []string                    // stack of savepoint names
-	savepointStates []*storage.SessionSavepoint // full session state (rows and staged DDL) per savepoint
+	inTransaction        bool
+	savepoints           []string                    // stack of savepoint names
+	savepointStates      []*storage.SessionSavepoint // full session state (rows and staged DDL) per savepoint
+	localSearchPathSaves [][]string                  // SET LOCAL value per savepoint
 
 	// LISTEN/NOTIFY state. The hub and subscriber are per database instance;
 	// listenOps and notifyQueue are staged until COMMIT so LISTEN and NOTIFY
@@ -96,8 +114,11 @@ type Executor struct {
 	// after the join rows are built.
 	joinTypeScopes []*joinTypeScope
 
-	// In-memory view registry: view name (lowercase) → SELECT AST.
-	views map[string]*parser.SelectStmt
+	// In-memory view registry: view name (lowercase) → SELECT AST, plus the
+	// column list each view exposes. The catalog reports both so pg_class,
+	// pg_attribute, and information_schema.columns agree on a view's shape.
+	views       map[string]*parser.SelectStmt
+	viewColumns map[string][]storage.Column
 
 	// Materialized common table expressions for the current query, keyed by
 	// lowercased CTE name. Recursive CTEs populate this during fixpoint
@@ -147,7 +168,9 @@ func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 		attachedDatabases: make(map[string]*DatabaseConnection),
 		currentDatabase:   "main",
 		views:             make(map[string]*parser.SelectStmt),
+		viewColumns:       make(map[string][]storage.Column),
 		listens:           make(map[string]bool),
+		searchPath:        []string{publicSchemaName},
 	}
 
 	// Expression indexes and CHECK predicates persist only SQL text, so the
@@ -222,10 +245,37 @@ func (e *Executor) SyncCatalog() error {
 		}
 	}
 
+	e.registerCatalogRelations()
 	e.catalogVersion = e.session.Version()
 	return nil
 }
 
+// registerCatalogRelations adds the read-only pg_catalog and information_schema
+// relations to the analyzer catalog. Qualified names are always registered;
+// a bare pg_catalog name is registered only when no durable table shadows it,
+// matching PostgreSQL's implicit pg_catalog search path.
+func (e *Executor) registerCatalogRelations() {
+	register := func(def catalogRelationDef, name string) {
+		if e.catalog.TableExists(name) {
+			return
+		}
+		entry := pgCatalogRelationIndex[def.schema+"."+strings.ToLower(def.name)]
+		info := entry.schema.ToAnalyzerTableInfo()
+		info.Name = name
+		info.IsView = true
+		_ = e.catalog.CreateTable(info)
+	}
+	for _, def := range pgCatalogRelationDefs {
+		register(def, def.schema+"."+def.name)
+	}
+	for _, def := range informationSchemaRelationDefs {
+		register(def, def.schema+"."+def.name)
+	}
+	for _, def := range pgCatalogRelationDefs {
+		register(def, def.name)
+	}
+}
+
 // Execute executes a SQL statement.
 func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 	if _, beginning := stmt.(*parser.BeginStmt); !beginning && !e.inTransaction {
@@ -237,14 +287,27 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 	e.arrayCache = make(map[parser.Expr]*arrayParseEntry)
 	e.arrayValueCache = make(map[parser.Expr]interface{})
 	e.arrayCanonicalCache = make(map[parser.Expr]*arrayCanonicalEntry)
+	// The virtual catalog snapshot is statement-scoped: DDL executed by this
+	// statement (or committed since the last one) must be visible to the next
+	// catalog read.
+	e.pgCatalog = nil
 	defer func() {
 		e.subqueryCache = nil
 		e.correlatedAggCache = nil
 		e.arrayCache = nil
 		e.arrayValueCache = nil
 		e.arrayCanonicalCache = nil
+		e.pgCatalog = nil
 	}()
 
+	// Catalog-backed expressions resolve names through a snapshot that reads the
+	// session catalog. That read must happen before a scan takes the session lock,
+	// so a statement using regclass/regtype or a catalog function primes the
+	// snapshot here.
+	if err := e.primeCatalogSnapshot(stmt); err != nil {
+		return nil, err
+	}
+
 	// PRAGMA doesn't need analysis. SQLite catalog-introspection pragmas
 	// (index_list, index_info, table_xinfo) are answered by the catalog agent
 	// first; everything else falls through to the built-in handler.
@@ -260,6 +323,22 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 		return e.executeExplain(explain)
 	}
 
+	// Session settings and SHOW are acknowledged without analysis. SET is
+	// accepted for driver/ORM compatibility; only search_path is retained.
+	switch s := stmt.(type) {
+	case *parser.SetStmt:
+		return e.executeSet(s)
+	case *parser.ShowStmt:
+		return e.executeShow(s)
+	}
+
+	// Catalog relations are read-only virtual relations. Reject writes with a
+	// clear feature-not-supported error instead of letting them fall through to
+	// a confusing table-not-found from storage.
+	if err := e.rejectCatalogWriteTarget(stmt); err != nil {
+		return nil, err
+	}
+
 	// Transaction statements don't need analysis
 	switch s := stmt.(type) {
 	case *parser.BeginStmt:
@@ -433,6 +512,23 @@ func isCountStarSingleTable(stmt *parser.SelectStmt) bool {
 
 // executeSelect executes a SELECT statement (or compound SELECT).
 func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
+	// A subquery evaluated inside a scan that already holds the session lock (a
+	// correlated predicate) cannot read the durable schema: doing so re-enters
+	// the session lock and deadlocks, leaking the table gate to every other
+	// session. Only a SELECT that resolves entirely from virtual catalog
+	// relations, materialized CTEs, or no FROM at all may run in that state.
+	if e.lockedScanDepth > 0 && !e.fromResolvesToCatalogRelations(stmt.From) {
+		return nil, pgerror.Unsupported("a correlated subquery cannot be evaluated inside a locked table scan; its predicate must not read the row being written")
+	}
+	// This SELECT may be a view or CTE body that the statement-level prime never
+	// saw, and its WHERE is pushed into the scan below, so resolve the catalog
+	// snapshot before the scan starts.
+	if e.pgCatalog == nil && catalogSnapshotSelectNeeded(stmt) {
+		if _, err := e.catalogSnapshotForQuery(); err != nil {
+			return nil, err
+		}
+	}
+
 	// The join scope is pushed below the snapshot so a real table's unqualified
 	// column keeps precedence; it answers for right-side derived tables and
 	// CTEs that the snapshot cannot read without materializing them again.
@@ -461,6 +557,13 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		return e.executeSelectFromTableFunction(stmt)
 	}
 
+	// PostgreSQL catalog relations are materialized virtual relations. Answer
+	// any SELECT whose FROM reads one of them before the storage path, which
+	// would otherwise reject the relation as unknown.
+	if e.fromReferencesCatalogRelation(stmt.From) {
+		return e.executeSelectOnCatalogRelations(stmt)
+	}
+
 	// Non-recursive CTEs are desugared to derived tables by the parser. Preserve
 	// every entry in a comma-separated list instead of dispatching solely on the
 	// first derived table.
@@ -525,6 +628,16 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 
 	schema, err := e.session.GetSchema(tableName)
 	if err != nil {
+		if !e.session.TableExists(tableName) {
+			// A qualified name the durable schema does not have, such as
+			// public.pg_class, is an undefined table in PostgreSQL; report it as
+			// 42P01 rather than an internal error.
+			qualified := tableName
+			if stmt.From[0].Schema != "" {
+				qualified = stmt.From[0].Schema + "." + tableName
+			}
+			return nil, pgerror.UndefinedTable("relation %q does not exist", qualified)
+		}
 		return nil, err
 	}
 
@@ -553,13 +666,16 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 
 	// Optimize constant WHERE clauses
 	var constantWhereResult *bool
-	if stmt.Where != nil && !isMultiTable {
-		// Check if WHERE clause is a constant expression (doesn't reference any columns)
-		refs := collectColumnRefs(stmt.Where)
-		if len(refs) == 0 {
-			// Evaluate the constant expression
+	if stmt.Where != nil {
+		// A WHERE clause that references no column is constant: evaluate it once.
+		// A failure is a statement error, not a filter that drops every row;
+		// only the single-table shape short-circuits on the resulting value.
+		if refs := collectColumnRefs(stmt.Where); len(refs) == 0 {
 			val, err := e.evalExpr(stmt.Where, nil)
-			if err == nil {
+			if err != nil {
+				return nil, err
+			}
+			if !isMultiTable {
 				result := toBool(val)
 				constantWhereResult = &result
 			}
@@ -673,6 +789,8 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		if canFilterInScan {
 			appliedInScan = true
 			filter = func(row storage.Row) bool {
+				e.enterLockedScan()
+				defer e.exitLockedScan()
 				val, ferr := e.evalExpr(effectiveWhere, row)
 				if ferr != nil {
 					filterErr = ferr
@@ -779,7 +897,12 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		if effectiveWhere != nil {
 			var filtered []storage.Row
 			for _, row := range rows {
-				val, _ := e.evalExpr(effectiveWhere, row)
+				val, ferr := e.evalExpr(effectiveWhere, row)
+				if ferr != nil {
+					// A failing predicate is a statement error, not a
+					// non-matching row.
+					return nil, ferr
+				}
 				if toBool(val) {
 					filtered = append(filtered, row)
 				}
@@ -927,7 +1050,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		}
 
 		// Helper: inline cross-join two row-sets, applying a predicate.
-		inlineJoin := func(left, right []storage.Row, pred parser.Expr) []storage.Row {
+		inlineJoin := func(left, right []storage.Row, pred parser.Expr) ([]storage.Row, error) {
 			out := make([]storage.Row, 0, len(left))
 			for _, l := range left {
 				for _, r := range right {
@@ -939,7 +1062,12 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 						m[k] = v
 					}
 					if pred != nil {
-						val, _ := e.evalExpr(pred, m)
+						val, perr := e.evalExpr(pred, m)
+						if perr != nil {
+							// A failing predicate is a statement error, not a
+							// non-matching row.
+							return nil, perr
+						}
 						if !toBool(val) {
 							continue
 						}
@@ -947,7 +1075,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					out = append(out, m)
 				}
 			}
-			return out
+			return out, nil
 		}
 
 		// Pre-join connected components of "cross-only" tables (0 single-table filters,
@@ -1096,7 +1224,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if len(appl) > 0 {
 					pred = combineAND(appl)
 				}
-				seedRows = inlineJoin(seedRows, nextRows, pred)
+				seedRows, err = inlineJoin(seedRows, nextRows, pred)
+				if err != nil {
+					return nil, err
+				}
 			}
 
 			virtuals = append(virtuals, virtualUnit{tableIdxs: vSeen, rows: seedRows})
@@ -1178,7 +1309,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				pred := combineAND(tableFilters[first])
 				var filtered []storage.Row
 				for _, row := range rows {
-					val, _ := e.evalExpr(pred, row)
+					val, ferr := e.evalExpr(pred, row)
+					if ferr != nil {
+						return nil, ferr
+					}
 					if toBool(val) {
 						filtered = append(filtered, row)
 					}
@@ -1203,7 +1337,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					pred := combineAND(tableFilters[idx])
 					var filtered []storage.Row
 					for _, row := range rightRows {
-						val, _ := e.evalExpr(pred, row)
+						val, ferr := e.evalExpr(pred, row)
+						if ferr != nil {
+							return nil, ferr
+						}
 						if toBool(val) {
 							filtered = append(filtered, row)
 						}
@@ -1217,7 +1354,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if len(appl) > 0 {
 					pred = combineAND(appl)
 				}
-				rows = inlineJoin(rows, rightRows, pred)
+				rows, err = inlineJoin(rows, rightRows, pred)
+				if err != nil {
+					return nil, err
+				}
 			}
 		} else {
 			// All tables were pre-joined; start with empty placeholder.
@@ -1235,7 +1375,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			if len(appl) > 0 {
 				pred = combineAND(appl)
 			}
-			rows = inlineJoin(rows, vu.rows, pred)
+			rows, err = inlineJoin(rows, vu.rows, pred)
+			if err != nil {
+				return nil, err
+			}
 		}
 
 		// Apply any remaining conditions (shouldn't normally happen).
@@ -1243,7 +1386,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			pred := combineAND(remaining)
 			var filtered []storage.Row
 			for _, row := range rows {
-				val, _ := e.evalExpr(pred, row)
+				val, ferr := e.evalExpr(pred, row)
+				if ferr != nil {
+					return nil, ferr
+				}
 				if toBool(val) {
 					filtered = append(filtered, row)
 				}
@@ -1836,7 +1982,10 @@ func (e *Executor) executeSelectOnMaterializedRows(stmt *parser.SelectStmt, colu
 		for _, row := range derivedRows {
 			val, err := e.evalExpr(stmt.Where, row)
 			if err != nil {
-				continue
+				// A failing predicate is a statement error, not a non-matching
+				// row: a typo'd regclass lookup must fail instead of filtering
+				// everything out and returning 0 rows.
+				return nil, err
 			}
 			if toBool(val) {
 				filteredRows = append(filteredRows, row)
@@ -2331,6 +2480,22 @@ func (e *Executor) executeJoinsWithMode(tableRef parser.TableRef, leftRows []sto
 		rightSchema = schemaFromColumns(cte.columns)
 		e.recordJoinColumns(rightAlias, cte.columns, cte.types)
 	default:
+		if entry, ok := e.catalogRelationFor(*rightTableRef); ok {
+			rightSchema = entry.schema
+			snapshot, snapErr := e.catalogSnapshotForQuery()
+			if snapErr != nil {
+				return nil, snapErr
+			}
+			rightRows = cloneRows(snapshot.relationRows(entry.def.schema + "." + entry.def.name))
+			columns := make([]string, len(rightSchema.Columns))
+			types := make([]string, len(rightSchema.Columns))
+			for i, column := range rightSchema.Columns {
+				columns[i] = column.Name
+				types[i] = column.Type
+			}
+			e.recordJoinColumns(rightAlias, columns, types)
+			break
+		}
 		rightSchema, err = e.session.GetSchema(rightTable)
 		if err != nil {
 			return nil, err
@@ -2473,6 +2638,21 @@ func (e *Executor) executeJoin(tableRef parser.TableRef, leftRows []storage.Row)
 		rightRows = cloneRows(cte.rows)
 		e.recordJoinColumns(rightQualifier, cte.columns, cte.types)
 	default:
+		if entry, ok := e.catalogRelationFor(*join.Table); ok {
+			snapshot, snapErr := e.catalogSnapshotForQuery()
+			if snapErr != nil {
+				return nil, snapErr
+			}
+			rightRows = cloneRows(snapshot.relationRows(entry.def.schema + "." + entry.def.name))
+			columns := make([]string, len(entry.schema.Columns))
+			types := make([]string, len(entry.schema.Columns))
+			for i, column := range entry.schema.Columns {
+				columns[i] = column.Name
+				types[i] = column.Type
+			}
+			e.recordJoinColumns(rightQualifier, columns, types)
+			break
+		}
 		rightRows, err = e.session.Select(rightTable, nil)
 	}
 	if err != nil {
@@ -2659,6 +2839,10 @@ func makeNullRow(rightRows []storage.Row, rightTable string, e *Executor) storag
 			for _, col := range rightSchema.Columns {
 				nullRight[col.Name] = nil
 			}
+		} else if schema, ok := e.catalogSchemaByName(rightTable); ok {
+			for _, col := range schema.Columns {
+				nullRight[col.Name] = nil
+			}
 		}
 	}
 	return nullRight
@@ -3533,6 +3717,8 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 	var filter func(storage.Row) bool
 	if stmt.Where != nil {
 		filter = func(row storage.Row) bool {
+			e.enterLockedScan()
+			defer e.exitLockedScan()
 			val, err := e.evalExpr(stmt.Where, row)
 			if err != nil {
 				filterErr = err
@@ -3544,7 +3730,9 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 
 	// Use UpdateFunc to evaluate expressions per-row (supports self-referencing like balance = balance + 100).
 	// Generated columns are recomputed from the post-assignment row and written
-	// back so STORED values stay consistent.
+	// back so STORED values stay consistent. The storage session releases its
+	// lock around this callback (runUpdateFn), so SET expressions may read the
+	// session (a scalar subquery) while the surrounding scan holds nothing.
 	updateFn := func(row storage.Row) (storage.Row, error) {
 		updates := make(storage.Row)
 		merged := make(storage.Row, len(row))
@@ -3932,6 +4120,8 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 	var filter func(storage.Row) bool
 	if stmt.Where != nil {
 		filter = func(row storage.Row) bool {
+			e.enterLockedScan()
+			defer e.exitLockedScan()
 			val, err := e.evalExpr(stmt.Where, row)
 			if err != nil {
 				filterErr = err
@@ -4453,9 +4643,22 @@ func (e *Executor) executeCreateView(stmt *parser.CreateViewStmt) (*Result, erro
 
 	e.views[name] = stmt.Select
 
-	// Derive view columns from SELECT list for catalog registration.
+	// Derive view columns from SELECT list for catalog registration. The same
+	// pass records the typed column list the catalog reports, so a view's
+	// pg_attribute and information_schema.columns rows match SELECT * FROM view.
 	var viewCols []analyzer.ColumnInfo
+	var catalogColumns []storage.Column
 	hasStar := false
+	typeResolver := e.joinedTypeResolver(stmt.Select.From)
+	addColumn := func(name, typeName string) {
+		viewCols = append(viewCols, analyzer.ColumnInfo{
+			Name:      name,
+			TableName: stmt.View.Name,
+			Type:      analyzer.TypeAny,
+			Nullable:  true,
+		})
+		catalogColumns = append(catalogColumns, storage.Column{Name: name, Type: typeName, Nullable: true})
+	}
 	for _, col := range stmt.Select.Columns {
 		if col.Star {
 			hasStar = true
@@ -4469,24 +4672,14 @@ func (e *Executor) executeCreateView(stmt *parser.CreateViewStmt) (*Result, erro
 				colName = fmt.Sprintf("col_%d", len(viewCols))
 			}
 		}
-		viewCols = append(viewCols, analyzer.ColumnInfo{
-			Name:      colName,
-			TableName: stmt.View.Name,
-			Type:      analyzer.TypeAny,
-			Nullable:  true,
-		})
+		addColumn(colName, inferExprType(col.Expr, typeResolver))
 	}
 	// For SELECT *, pull columns from the underlying table(s).
 	if hasStar && len(stmt.Select.From) > 0 {
 		baseName := stmt.Select.From[0].Name
 		if schema, err := e.session.GetSchema(baseName); err == nil {
-			for _, c := range schema.Columns {
-				viewCols = append(viewCols, analyzer.ColumnInfo{
-					Name:      c.Name,
-					TableName: stmt.View.Name,
-					Type:      analyzer.TypeAny,
-					Nullable:  true,
-				})
+			for _, c := range visibleCatalogColumns(schema) {
+				addColumn(c.Name, c.Type)
 			}
 		}
 	}
@@ -4497,6 +4690,7 @@ func (e *Executor) executeCreateView(stmt *parser.CreateViewStmt) (*Result, erro
 		Columns: viewCols,
 		IsView:  true,
 	})
+	e.viewColumns[name] = catalogColumns
 
 	return NewResult("CREATE VIEW"), nil
 }
@@ -4511,6 +4705,7 @@ func (e *Executor) executeDropView(stmt *parser.DropViewStmt) (*Result, error) {
 			return nil, fmt.Errorf("view not found: %s", ref.Name)
 		}
 		delete(e.views, name)
+		delete(e.viewColumns, name)
 		e.catalog.DropTable(ref.Name) //nolint:errcheck
 	}
 	return NewResult("DROP VIEW"), nil
@@ -4769,6 +4964,8 @@ func (e *Executor) executeBegin(stmt *parser.BeginStmt) (*Result, error) {
 	e.inTransaction = true
 	e.savepoints = nil
 	e.savepointStates = nil
+	e.localSearchPathSaves = nil
+	e.beginSettingScope()
 	e.clearNotificationState()
 
 	result := NewResult("BEGIN")
@@ -4793,6 +4990,7 @@ func (e *Executor) executeCommit(stmt *parser.CommitStmt) (*Result, error) {
 	e.inTransaction = false
 	e.savepoints = nil
 	e.savepointStates = nil
+	e.localSearchPathSaves = nil
 	e.finishCommit(lsn)
 
 	result := NewResult("COMMIT")
@@ -4817,6 +5015,7 @@ func (e *Executor) executeRollback(stmt *parser.RollbackStmt) (*Result, error) {
 	e.inTransaction = false
 	e.savepoints = nil
 	e.savepointStates = nil
+	e.localSearchPathSaves = nil
 	e.finishRollback()
 	// total_changes() is monotonic (SQLite semantics): it is NOT decremented on
 	// rollback. last_insert_rowid() and changes() also intentionally hold.
@@ -4842,6 +5041,7 @@ func (e *Executor) executeSavepoint(stmt *parser.SavepointStmt) (*Result, error)
 	e.savepoints = append(e.savepoints, stmt.Name)
 	e.savepointStates = append(e.savepointStates, e.session.Savepoint())
 	e.notificationSaves = append(e.notificationSaves, e.notificationSnapshot())
+	e.localSearchPathSaves = append(e.localSearchPathSaves, e.localSearchPath)
 
 	result := NewResult("SAVEPOINT")
 	return result, nil
@@ -4862,6 +5062,9 @@ func (e *Executor) executeRelease(stmt *parser.ReleaseStmt) (*Result, error) {
 			if i <= len(e.notificationSaves) {
 				e.notificationSaves = e.notificationSaves[:i]
 			}
+			if i <= len(e.localSearchPathSaves) {
+				e.localSearchPathSaves = e.localSearchPathSaves[:i]
+			}
 			found = true
 			break
 		}
@@ -4962,6 +5165,12 @@ func (e *Executor) rollbackToSavepoint(name string) (*Result, error) {
 		e.rollbackNotificationsTo(e.notificationSaves[savepointIdx])
 		e.notificationSaves = e.notificationSaves[:savepointIdx+1]
 	}
+	// SET LOCAL is undoable down to the savepoint, matching PostgreSQL.
+	if savepointIdx < len(e.localSearchPathSaves) {
+		e.localSearchPath = e.localSearchPathSaves[savepointIdx]
+		e.localSearchPathSaves = e.localSearchPathSaves[:savepointIdx+1]
+		e.pgCatalog = nil
+	}
 
 	// Remove savepoints after the target
 	e.savepoints = e.savepoints[:savepointIdx+1]
@@ -5968,6 +6177,9 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 	if val, handled, err := evalJSONFunction(name, args); handled {
 		return val, err
 	}
+	if val, handled, err := e.evalCatalogFunction(name, fn, args); handled {
+		return val, err
+	}
 
 	switch name {
 	case "UPPER":
@@ -6245,6 +6457,8 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		return e.evalArrayLength(fn.Args[0], args)
 	case "UNNEST":
 		return nil, pgerror.Unsupported("unnest() is only supported as a table function in FROM, as in SELECT * FROM unnest(array)")
+	case "VERSION":
+		return "PostgreSQL " + version.PostgreSQLCompatVersion + " (PizzaSQL)", nil
 	case "PIZZASQL_VERSION":
 		return version.String(), nil
 	case "SQLITE_VERSION":
@@ -6567,6 +6781,9 @@ func (e *Executor) evalCastExpr(expr *parser.CastExpr, row storage.Row) (interfa
 	if expr.Type.Array {
 		return evalArrayCast(expr.Type, val)
 	}
+	if cast, handled, err := e.catalogCast(expr, val); handled {
+		return cast, err
+	}
 
 	typeName := strings.ToUpper(expr.Type.Name)
 	if typeName == "JSONB" {
@@ -7905,7 +8122,7 @@ func (e *Executor) evalIntExpr(expr parser.Expr) int {
 
 func isIntVal(v interface{}) bool {
 	switch v.(type) {
-	case int64, int, bool:
+	case int64, int, bool, RegOID:
 		return true
 	default:
 		return false
@@ -7920,6 +8137,8 @@ func toInt64(v interface{}) int64 {
 		return int64(val)
 	case float64:
 		return int64(val)
+	case RegOID:
+		return val.oid
 	case bool:
 		if val {
 			return 1
@@ -7997,6 +8216,8 @@ func toString(v interface{}) string {
 		return string(t)
 	case jsonText:
 		return string(t)
+	case RegOID:
+		return t.name
 	case bool:
 		if t {
 			return "1"
@@ -8057,6 +8278,8 @@ func toNumeric(v interface{}) (float64, bool) {
 		return float64(val), true
 	case float64:
 		return val, true
+	case RegOID:
+		return float64(val.oid), true
 	case string:
 		f, err := strconv.ParseFloat(val, 64)
 		return f, err == nil

+ 4 - 1
pkg/executor/notify.go

@@ -174,11 +174,14 @@ func (e *Executor) finishCommit(lsn uint64) {
 		e.publish(n)
 	}
 	e.clearNotificationState()
+	e.commitSettingScope()
 }
 
-// finishRollback discards staged subscription changes and notifications.
+// finishRollback discards staged subscription changes and notifications, and
+// restores the transaction-scoped session settings.
 func (e *Executor) finishRollback() {
 	e.clearNotificationState()
+	e.rollbackSettingScope()
 }
 
 func (e *Executor) clearNotificationState() {

+ 2851 - 0
pkg/executor/pg_catalog.go

@@ -0,0 +1,2851 @@
+package executor
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// PostgreSQL catalog emulation.
+//
+// pg_catalog and information_schema are synthesized from the durable schema the
+// connection's session observes (including DDL staged by an open transaction).
+// Nothing is persisted and no virtual relation accepts writes. OIDs are
+// deterministic: a 32-bit FNV-1a hash of the namespace-qualified name, lifted
+// above PostgreSQL's FirstNormalObjectId and collision-probed in sorted order.
+// The same allocator feeds pg_class, pg_attribute, pg_type, pg_index, and
+// pg_constraint, so catalog joins agree.
+//
+// The emulated schemas are pg_catalog (OID 11), public (OID 2200), and
+// information_schema (OID 13207). User relations always live in public.
+
+const (
+	pgCatalogSchemaName    = "pg_catalog"
+	informationSchemaName  = "information_schema"
+	publicSchemaName       = "public"
+	pgCatalogNamespaceOID  = int64(11)
+	publicNamespaceOID     = int64(2200)
+	infoSchemaNamespaceOID = int64(13207)
+
+	firstNormalObjectID = int64(16384)
+
+	// Built-in OIDs the emulation refers to.
+	pgDefaultTablespaceOID = int64(1663)
+	pgGlobalTablespaceOID  = int64(1664)
+	pgBtreeAMOID           = int64(403)
+	pgHeapAMOID            = int64(2)
+	catalogOwnerOID        = int64(10)
+)
+
+// catalogRelationDef declares a virtual relation's column list.
+type catalogRelationDef struct {
+	schema  string
+	name    string
+	columns []catalogColumnDef
+}
+
+type catalogColumnDef struct {
+	name string
+	typ  string
+}
+
+// pgCatalogRelationDefs lists the read-only pg_catalog relations.
+var pgCatalogRelationDefs = []catalogRelationDef{
+	{schema: pgCatalogSchemaName, name: "pg_namespace", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"nspname", "NAME"}, {"nspowner", "OID"}, {"nspacl", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_class", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"relname", "NAME"}, {"relnamespace", "OID"}, {"reltype", "OID"},
+		{"reloftype", "OID"}, {"relowner", "OID"}, {"relam", "OID"}, {"relfilenode", "OID"},
+		{"reltablespace", "OID"}, {"relpages", "INT4"}, {"reltuples", "FLOAT4"},
+		{"relallvisible", "INT4"}, {"reltoastrelid", "OID"}, {"relhasindex", "BOOL"},
+		{"relisshared", "BOOL"}, {"relpersistence", "CHAR"}, {"relkind", "CHAR"},
+		{"relnatts", "INT2"}, {"relchecks", "INT2"}, {"relhasrules", "BOOL"},
+		{"relhastriggers", "BOOL"}, {"relhassubclass", "BOOL"}, {"relrowsecurity", "BOOL"},
+		{"relforcerowsecurity", "BOOL"}, {"relispopulated", "BOOL"}, {"relreplident", "CHAR"},
+		{"relispartition", "BOOL"}, {"relrewrite", "OID"}, {"relfrozenxid", "OID"},
+		{"relminmxid", "OID"}, {"relacl", "TEXT"}, {"reloptions", "TEXT"},
+		{"relpartbound", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_attribute", columns: []catalogColumnDef{
+		{"attrelid", "OID"}, {"attname", "NAME"}, {"atttypid", "OID"},
+		{"attstattarget", "INT4"}, {"attlen", "INT2"}, {"attnum", "INT2"},
+		{"attndims", "INT4"}, {"attcacheoff", "INT4"}, {"atttypmod", "INT4"},
+		{"attbyval", "BOOL"}, {"attalign", "CHAR"}, {"attstorage", "CHAR"},
+		{"attcompression", "CHAR"}, {"attnotnull", "BOOL"}, {"atthasdef", "BOOL"},
+		{"atthasmissing", "BOOL"}, {"attidentity", "CHAR"}, {"attgenerated", "CHAR"},
+		{"attisdropped", "BOOL"}, {"attislocal", "BOOL"}, {"attinhcount", "INT2"},
+		{"attcollation", "OID"}, {"attacl", "TEXT"}, {"attoptions", "TEXT"},
+		{"attfdwoptions", "TEXT"}, {"attmissingval", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_type", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"typname", "NAME"}, {"typnamespace", "OID"},
+		{"typowner", "OID"}, {"typlen", "INT2"}, {"typbyval", "BOOL"},
+		{"typtype", "CHAR"}, {"typcategory", "CHAR"}, {"typispreferred", "BOOL"},
+		{"typisdefined", "BOOL"}, {"typdelim", "CHAR"}, {"typrelid", "OID"},
+		{"typsubscript", "REGPROC"}, {"typelem", "OID"}, {"typarray", "OID"},
+		{"typinput", "REGPROC"}, {"typoutput", "REGPROC"}, {"typreceive", "REGPROC"},
+		{"typsend", "REGPROC"}, {"typmodin", "REGPROC"}, {"typmodout", "REGPROC"},
+		{"typanalyze", "REGPROC"}, {"typalign", "CHAR"}, {"typstorage", "CHAR"},
+		{"typnotnull", "BOOL"}, {"typbasetype", "OID"}, {"typtypmod", "INT4"},
+		{"typndims", "INT4"}, {"typcollation", "OID"}, {"typdefaultbin", "TEXT"},
+		{"typdefault", "TEXT"}, {"typacl", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_index", columns: []catalogColumnDef{
+		{"indexrelid", "OID"}, {"indrelid", "OID"}, {"indnatts", "INT2"},
+		{"indnkeyatts", "INT2"}, {"indisunique", "BOOL"}, {"indisprimary", "BOOL"},
+		{"indisexclusion", "BOOL"}, {"indimmediate", "BOOL"}, {"indisclustered", "BOOL"},
+		{"indisvalid", "BOOL"}, {"indcheckxmin", "BOOL"}, {"indisready", "BOOL"},
+		{"indislive", "BOOL"}, {"indisreplident", "BOOL"}, {"indkey", "INT2[]"},
+		{"indcollation", "TEXT"}, {"indclass", "TEXT"}, {"indoption", "INT2[]"},
+		{"indexprs", "TEXT"}, {"indpred", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_constraint", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"conname", "NAME"}, {"connamespace", "OID"},
+		{"contype", "CHAR"}, {"condeferrable", "BOOL"}, {"condeferred", "BOOL"},
+		{"convalidated", "BOOL"}, {"conrelid", "OID"}, {"contypid", "OID"},
+		{"conindid", "OID"}, {"conparentid", "OID"}, {"confrelid", "OID"},
+		{"confupdtype", "CHAR"}, {"confdeltype", "CHAR"}, {"confmatchtype", "CHAR"},
+		{"conislocal", "BOOL"}, {"coninhcount", "INT2"}, {"connoinherit", "BOOL"},
+		{"conkey", "INT2[]"}, {"confkey", "INT2[]"}, {"conpfeqop", "TEXT"},
+		{"conppeqop", "TEXT"}, {"conffeqop", "TEXT"}, {"conexclop", "TEXT"},
+		{"conbin", "TEXT"}, {"consrc", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_attrdef", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"adrelid", "OID"}, {"adnum", "INT2"}, {"adbin", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_database", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"datname", "NAME"}, {"datdba", "OID"}, {"encoding", "INT4"},
+		{"datlocprovider", "CHAR"}, {"datistemplate", "BOOL"}, {"datallowconn", "BOOL"},
+		{"datconnlimit", "INT4"}, {"datlastsysoid", "OID"}, {"datfrozenxid", "OID"},
+		{"datminmxid", "OID"}, {"dattablespace", "OID"}, {"datcollate", "TEXT"},
+		{"datctype", "TEXT"}, {"datlocale", "TEXT"}, {"daticurules", "TEXT"},
+		{"datcollversion", "TEXT"}, {"datacl", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_tablespace", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"spcname", "NAME"}, {"spcowner", "OID"}, {"spcacl", "TEXT"},
+		{"spcoptions", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_proc", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"proname", "NAME"}, {"pronamespace", "OID"},
+		{"proowner", "OID"}, {"prolang", "OID"}, {"procost", "FLOAT4"},
+		{"prorows", "FLOAT4"}, {"provariadic", "OID"}, {"prosupport", "REGPROC"},
+		{"prokind", "CHAR"}, {"prosecdef", "BOOL"}, {"proleakproof", "BOOL"},
+		{"proisstrict", "BOOL"}, {"proretset", "BOOL"}, {"provolatile", "CHAR"},
+		{"proparallel", "CHAR"}, {"pronargs", "INT2"}, {"pronargdefaults", "INT2"},
+		{"prorettype", "OID"}, {"proargtypes", "TEXT"}, {"proallargtypes", "TEXT"},
+		{"proargmodes", "TEXT"}, {"proargnames", "TEXT"}, {"proargdefaults", "TEXT"},
+		{"protrftypes", "TEXT"}, {"prosrc", "TEXT"}, {"probin", "TEXT"},
+		{"prosqlbody", "TEXT"}, {"proconfig", "TEXT"}, {"proacl", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_am", columns: []catalogColumnDef{
+		{"oid", "OID"}, {"amname", "NAME"}, {"amhandler", "OID"}, {"amtype", "CHAR"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_description", columns: []catalogColumnDef{
+		{"objoid", "OID"}, {"classoid", "OID"}, {"objsubid", "INT4"}, {"description", "TEXT"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_tables", columns: []catalogColumnDef{
+		{"schemaname", "NAME"}, {"tablename", "NAME"}, {"tableowner", "NAME"},
+		{"tablespace", "NAME"}, {"hasindexes", "BOOL"}, {"hasrules", "BOOL"},
+		{"hastriggers", "BOOL"}, {"rowsecurity", "BOOL"},
+	}},
+	{schema: pgCatalogSchemaName, name: "pg_indexes", columns: []catalogColumnDef{
+		{"schemaname", "NAME"}, {"tablename", "NAME"}, {"indexname", "NAME"},
+		{"tablespace", "NAME"}, {"indexdef", "TEXT"},
+	}},
+}
+
+// informationSchemaRelationDefs lists the read-only information_schema views.
+var informationSchemaRelationDefs = []catalogRelationDef{
+	{schema: informationSchemaName, name: "tables", columns: []catalogColumnDef{
+		{"table_catalog", "TEXT"}, {"table_schema", "TEXT"}, {"table_name", "TEXT"},
+		{"table_type", "TEXT"}, {"self_referencing_column_name", "TEXT"},
+		{"reference_generation", "TEXT"}, {"user_defined_type_catalog", "TEXT"},
+		{"user_defined_type_schema", "TEXT"}, {"user_defined_type_name", "TEXT"},
+		{"is_insertable_into", "TEXT"}, {"is_typed", "TEXT"}, {"commit_action", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "columns", columns: []catalogColumnDef{
+		{"table_catalog", "TEXT"}, {"table_schema", "TEXT"}, {"table_name", "TEXT"},
+		{"column_name", "TEXT"}, {"ordinal_position", "INT4"}, {"column_default", "TEXT"},
+		{"is_nullable", "TEXT"}, {"data_type", "TEXT"}, {"character_maximum_length", "INT4"},
+		{"character_octet_length", "INT4"}, {"numeric_precision", "INT4"},
+		{"numeric_precision_radix", "INT4"}, {"numeric_scale", "INT4"},
+		{"datetime_precision", "INT4"}, {"interval_type", "TEXT"},
+		{"interval_precision", "INT4"}, {"character_set_catalog", "TEXT"},
+		{"character_set_schema", "TEXT"}, {"character_set_name", "TEXT"},
+		{"collation_catalog", "TEXT"}, {"collation_schema", "TEXT"},
+		{"collation_name", "TEXT"}, {"domain_catalog", "TEXT"}, {"domain_schema", "TEXT"},
+		{"domain_name", "TEXT"}, {"udt_catalog", "TEXT"}, {"udt_schema", "TEXT"},
+		{"udt_name", "TEXT"}, {"scope_catalog", "TEXT"}, {"scope_schema", "TEXT"},
+		{"scope_name", "TEXT"}, {"maximum_cardinality", "INT4"}, {"dtd_identifier", "TEXT"},
+		{"is_self_referencing", "TEXT"}, {"is_identity", "TEXT"},
+		{"identity_generation", "TEXT"}, {"identity_start", "TEXT"},
+		{"identity_increment", "TEXT"}, {"identity_maximum", "TEXT"},
+		{"identity_minimum", "TEXT"}, {"identity_cycle", "TEXT"},
+		{"is_generated", "TEXT"}, {"generation_expression", "TEXT"},
+		{"is_updatable", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "table_constraints", columns: []catalogColumnDef{
+		{"constraint_catalog", "TEXT"}, {"constraint_schema", "TEXT"},
+		{"constraint_name", "TEXT"}, {"table_catalog", "TEXT"}, {"table_schema", "TEXT"},
+		{"table_name", "TEXT"}, {"constraint_type", "TEXT"}, {"is_deferrable", "TEXT"},
+		{"initially_deferred", "TEXT"}, {"enforced", "TEXT"}, {"nulls_distinct", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "key_column_usage", columns: []catalogColumnDef{
+		{"constraint_catalog", "TEXT"}, {"constraint_schema", "TEXT"},
+		{"constraint_name", "TEXT"}, {"table_catalog", "TEXT"}, {"table_schema", "TEXT"},
+		{"table_name", "TEXT"}, {"column_name", "TEXT"}, {"ordinal_position", "INT4"},
+		{"position_in_unique_constraint", "INT4"},
+	}},
+	{schema: informationSchemaName, name: "referential_constraints", columns: []catalogColumnDef{
+		{"constraint_catalog", "TEXT"}, {"constraint_schema", "TEXT"},
+		{"constraint_name", "TEXT"}, {"unique_constraint_catalog", "TEXT"},
+		{"unique_constraint_schema", "TEXT"}, {"unique_constraint_name", "TEXT"},
+		{"match_option", "TEXT"}, {"update_rule", "TEXT"}, {"delete_rule", "TEXT"},
+		{"is_deferrable", "TEXT"}, {"initially_deferred", "TEXT"}, {"enforced", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "constraint_column_usage", columns: []catalogColumnDef{
+		{"table_catalog", "TEXT"}, {"table_schema", "TEXT"}, {"table_name", "TEXT"},
+		{"constraint_catalog", "TEXT"}, {"constraint_schema", "TEXT"},
+		{"constraint_name", "TEXT"}, {"column_name", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "schemata", columns: []catalogColumnDef{
+		{"catalog_name", "TEXT"}, {"schema_name", "TEXT"}, {"schema_owner", "TEXT"},
+		{"default_character_set_catalog", "TEXT"},
+		{"default_character_set_schema", "TEXT"},
+		{"default_character_set_name", "TEXT"}, {"sql_path", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "sequences", columns: []catalogColumnDef{
+		{"sequence_catalog", "TEXT"}, {"sequence_schema", "TEXT"},
+		{"sequence_name", "TEXT"}, {"data_type", "TEXT"},
+		{"numeric_precision", "INT4"}, {"numeric_precision_radix", "INT4"},
+		{"numeric_scale", "INT4"}, {"start_value", "TEXT"}, {"minimum_value", "TEXT"},
+		{"maximum_value", "TEXT"}, {"increment", "TEXT"}, {"cycle_option", "TEXT"},
+	}},
+	{schema: informationSchemaName, name: "views", columns: []catalogColumnDef{
+		{"table_catalog", "TEXT"}, {"table_schema", "TEXT"}, {"table_name", "TEXT"},
+		{"view_definition", "TEXT"}, {"check_option", "TEXT"}, {"is_updatable", "TEXT"},
+		{"is_insertable_into", "TEXT"}, {"is_trigger_updatable", "TEXT"},
+		{"is_trigger_deletable", "TEXT"}, {"is_trigger_insertable_into", "TEXT"},
+	}},
+}
+
+// catalogRelationSchema builds the storage schema that the analyzer and the
+// materialized SELECT path use for a virtual relation.
+func catalogRelationSchema(def catalogRelationDef) *storage.Schema {
+	schema := &storage.Schema{Name: def.name, Columns: make([]storage.Column, len(def.columns))}
+	for i, column := range def.columns {
+		schema.Columns[i] = storage.Column{Name: column.name, Type: column.typ, Nullable: true}
+	}
+	return schema
+}
+
+var pgCatalogRelationIndex = buildCatalogRelationIndex()
+
+type catalogRelationEntry struct {
+	def    catalogRelationDef
+	schema *storage.Schema
+}
+
+func buildCatalogRelationIndex() map[string]catalogRelationEntry {
+	index := make(map[string]catalogRelationEntry, len(pgCatalogRelationDefs)+len(informationSchemaRelationDefs))
+	for _, def := range append(append([]catalogRelationDef(nil), pgCatalogRelationDefs...), informationSchemaRelationDefs...) {
+		index[def.schema+"."+strings.ToLower(def.name)] = catalogRelationEntry{def: def, schema: catalogRelationSchema(def)}
+	}
+	return index
+}
+
+// catalogRelationFor resolves a possibly schema-qualified table reference to a
+// virtual catalog relation. A CTE in scope shadows every relation name, and an
+// unqualified name resolves to pg_catalog only when no durable table shadows it,
+// matching PostgreSQL's implicit search_path.
+func (e *Executor) catalogRelationFor(ref parser.TableRef) (catalogRelationEntry, bool) {
+	if ref.Subquery != nil || ref.Function != nil {
+		return catalogRelationEntry{}, false
+	}
+	if e.cteTableExists(ref.Name) {
+		return catalogRelationEntry{}, false
+	}
+	name := strings.ToLower(ref.Name)
+	switch strings.ToLower(ref.Schema) {
+	case pgCatalogSchemaName:
+		entry, ok := pgCatalogRelationIndex[pgCatalogSchemaName+"."+name]
+		return entry, ok
+	case informationSchemaName:
+		entry, ok := pgCatalogRelationIndex[informationSchemaName+"."+name]
+		return entry, ok
+	case "":
+		// Resolve the catalog name first: an ordinary table name then needs no
+		// session lookup, and a locked scan only asks the session whether a
+		// catalog-named relation is shadowed by a durable table.
+		entry, ok := pgCatalogRelationIndex[pgCatalogSchemaName+"."+name]
+		if !ok || e.catalogTableExists(ref.Name) {
+			return catalogRelationEntry{}, false
+		}
+		return entry, true
+	default:
+		// The emulated schemas are distinct: public.<name> names a durable
+		// relation, never pg_catalog.<name>, so a qualified miss falls through to
+		// the durable-schema path and is reported as an undefined table.
+		return catalogRelationEntry{}, false
+	}
+}
+
+// isCatalogRelationRef reports whether a FROM entry names a virtual relation.
+func (e *Executor) isCatalogRelationRef(ref parser.TableRef) bool {
+	_, ok := e.catalogRelationFor(ref)
+	return ok
+}
+
+// fromReferencesCatalogRelation walks a FROM clause, including JOIN chains.
+func (e *Executor) fromReferencesCatalogRelation(from []parser.TableRef) bool {
+	for _, ref := range from {
+		cur := ref
+		for {
+			if e.isCatalogRelationRef(cur) {
+				return true
+			}
+			if cur.Join == nil || cur.Join.Table == nil {
+				break
+			}
+			cur = *cur.Join.Table
+		}
+	}
+	return false
+}
+
+// ---------------------------------------------------------------------------
+// Deterministic OIDs
+
+func fnv1a32(text string) uint32 {
+	hash := uint32(2166136261)
+	for i := 0; i < len(text); i++ {
+		hash ^= uint32(text[i])
+		hash *= 16777619
+	}
+	return hash
+}
+
+// catalogRelationKey identifies a relation row. relkind is part of the identity:
+// the engine lets an index share its table's name, and both rows must keep their
+// own OID so pg_class, pg_attribute, and pg_index agree, exactly as relname plus
+// relkind identifies a pg_class row in PostgreSQL.
+func catalogRelationKey(relation *catalogRelation) string {
+	return strings.ToLower(relation.schemaName+"."+relation.name) + "#" + string(relation.kind)
+}
+
+// allocateOID maps a kind and fully qualified name to a stable, positive OID.
+// The first probe is the FNV-1a hash lifted into [firstNormalObjectID,
+// MaxInt32]; collisions are resolved by linear probing in the caller's sorted
+// order, so the assignment is deterministic for a given schema.
+func (s *catalogSnapshot) allocateOID(kind, key string) int64 {
+	owner := kind + ":" + key
+	oid := int64(fnv1a32("pizzasql:" + owner))
+	oid &= 0x7fffffff
+	if oid < firstNormalObjectID {
+		oid += firstNormalObjectID
+	}
+	for {
+		if existing, used := s.oidOwners[oid]; !used || existing == owner {
+			s.oidOwners[oid] = owner
+			return oid
+		}
+		oid++
+		if oid > 0x7fffffff {
+			oid = firstNormalObjectID
+		}
+	}
+}
+
+// ---------------------------------------------------------------------------
+// Built-in type table
+
+type pgBuiltinType struct {
+	oid      int64
+	name     string // pg_type.typname
+	sqlName  string // format_type / information_schema spelling
+	length   int64
+	byval    bool
+	category string
+	delim    string
+	elem     int64
+	array    int64
+	align    string
+	storage  string
+}
+
+func (t pgBuiltinType) isArray() bool { return t.category == "A" }
+
+var pgBuiltinTypes = []pgBuiltinType{
+	{oid: 16, name: "bool", sqlName: "boolean", length: 1, byval: true, category: "B", delim: ",", array: 1000, align: "c", storage: "p"},
+	{oid: 17, name: "bytea", sqlName: "bytea", length: -1, category: "U", delim: ",", array: 1001, align: "i", storage: "x"},
+	{oid: 18, name: "char", sqlName: "\"char\"", length: 1, byval: true, category: "S", delim: ",", array: 1002, align: "c", storage: "p"},
+	{oid: 19, name: "name", sqlName: "name", length: 64, category: "S", delim: ",", array: 1003, align: "c", storage: "p"},
+	{oid: 20, name: "int8", sqlName: "bigint", length: 8, byval: true, category: "N", delim: ",", array: 1016, align: "d", storage: "p"},
+	{oid: 21, name: "int2", sqlName: "smallint", length: 2, byval: true, category: "N", delim: ",", array: 1005, align: "s", storage: "p"},
+	{oid: 23, name: "int4", sqlName: "integer", length: 4, byval: true, category: "N", delim: ",", array: 1007, align: "i", storage: "p"},
+	{oid: 25, name: "text", sqlName: "text", length: -1, category: "S", delim: ",", array: 1009, align: "i", storage: "x"},
+	{oid: 26, name: "oid", sqlName: "oid", length: 4, byval: true, category: "N", delim: ",", array: 1028, align: "i", storage: "p"},
+	{oid: 114, name: "json", sqlName: "json", length: -1, category: "U", delim: ",", array: 199, align: "i", storage: "x"},
+	{oid: 700, name: "float4", sqlName: "real", length: 4, byval: true, category: "N", delim: ",", array: 1021, align: "i", storage: "p"},
+	{oid: 701, name: "float8", sqlName: "double precision", length: 8, byval: true, category: "N", delim: ",", array: 1022, align: "d", storage: "p"},
+	{oid: 1042, name: "bpchar", sqlName: "character", length: -1, category: "S", delim: ",", array: 1014, align: "i", storage: "x"},
+	{oid: 1043, name: "varchar", sqlName: "character varying", length: -1, category: "S", delim: ",", array: 1015, align: "i", storage: "x"},
+	{oid: 1082, name: "date", sqlName: "date", length: 4, byval: true, category: "D", delim: ",", array: 1182, align: "i", storage: "p"},
+	{oid: 1083, name: "time", sqlName: "time without time zone", length: 8, byval: true, category: "D", delim: ",", array: 1183, align: "d", storage: "p"},
+	{oid: 1184, name: "timestamptz", sqlName: "timestamp with time zone", length: 8, byval: true, category: "D", delim: ",", array: 1185, align: "d", storage: "p"},
+	{oid: 1700, name: "numeric", sqlName: "numeric", length: -1, category: "N", delim: ",", array: 1231, align: "i", storage: "x"},
+	{oid: 2205, name: "regclass", sqlName: "regclass", length: 4, byval: true, category: "N", delim: ",", array: 2210, align: "i", storage: "p"},
+	{oid: 2206, name: "regtype", sqlName: "regtype", length: 4, byval: true, category: "N", delim: ",", array: 2211, align: "i", storage: "p"},
+	{oid: 2950, name: "uuid", sqlName: "uuid", length: 16, category: "U", delim: ",", array: 2951, align: "c", storage: "p"},
+	{oid: 3802, name: "jsonb", sqlName: "jsonb", length: -1, category: "U", delim: ",", array: 3807, align: "i", storage: "x"},
+
+	// Array types for the supported element types.
+	{oid: 199, name: "_json", sqlName: "json[]", length: -1, category: "A", delim: ",", elem: 114, align: "i", storage: "x"},
+	{oid: 1000, name: "_bool", sqlName: "boolean[]", length: -1, category: "A", delim: ",", elem: 16, align: "i", storage: "x"},
+	{oid: 1001, name: "_bytea", sqlName: "bytea[]", length: -1, category: "A", delim: ",", elem: 17, align: "i", storage: "x"},
+	{oid: 1002, name: "_char", sqlName: "\"char\"[]", length: -1, category: "A", delim: ",", elem: 18, align: "i", storage: "x"},
+	{oid: 1003, name: "_name", sqlName: "name[]", length: -1, category: "A", delim: ",", elem: 19, align: "i", storage: "x"},
+	{oid: 1005, name: "_int2", sqlName: "smallint[]", length: -1, category: "A", delim: ",", elem: 21, align: "i", storage: "x"},
+	{oid: 1007, name: "_int4", sqlName: "integer[]", length: -1, category: "A", delim: ",", elem: 23, align: "i", storage: "x"},
+	{oid: 1009, name: "_text", sqlName: "text[]", length: -1, category: "A", delim: ",", elem: 25, align: "i", storage: "x"},
+	{oid: 1014, name: "_bpchar", sqlName: "character[]", length: -1, category: "A", delim: ",", elem: 1042, align: "i", storage: "x"},
+	{oid: 1015, name: "_varchar", sqlName: "character varying[]", length: -1, category: "A", delim: ",", elem: 1043, align: "i", storage: "x"},
+	{oid: 1016, name: "_int8", sqlName: "bigint[]", length: -1, category: "A", delim: ",", elem: 20, align: "i", storage: "x"},
+	{oid: 1021, name: "_float4", sqlName: "real[]", length: -1, category: "A", delim: ",", elem: 700, align: "i", storage: "x"},
+	{oid: 1022, name: "_float8", sqlName: "double precision[]", length: -1, category: "A", delim: ",", elem: 701, align: "i", storage: "x"},
+	{oid: 1028, name: "_oid", sqlName: "oid[]", length: -1, category: "A", delim: ",", elem: 26, align: "i", storage: "x"},
+	{oid: 1182, name: "_date", sqlName: "date[]", length: -1, category: "A", delim: ",", elem: 1082, align: "i", storage: "x"},
+	{oid: 1183, name: "_time", sqlName: "time without time zone[]", length: -1, category: "A", delim: ",", elem: 1083, align: "i", storage: "x"},
+	{oid: 1185, name: "_timestamptz", sqlName: "timestamp with time zone[]", length: -1, category: "A", delim: ",", elem: 1184, align: "i", storage: "x"},
+	{oid: 1231, name: "_numeric", sqlName: "numeric[]", length: -1, category: "A", delim: ",", elem: 1700, align: "i", storage: "x"},
+	{oid: 2210, name: "_regclass", sqlName: "regclass[]", length: -1, category: "A", delim: ",", elem: 2205, align: "i", storage: "x"},
+	{oid: 2211, name: "_regtype", sqlName: "regtype[]", length: -1, category: "A", delim: ",", elem: 2206, align: "i", storage: "x"},
+	{oid: 2951, name: "_uuid", sqlName: "uuid[]", length: -1, category: "A", delim: ",", elem: 2950, align: "i", storage: "x"},
+	{oid: 3807, name: "_jsonb", sqlName: "jsonb[]", length: -1, category: "A", delim: ",", elem: 3802, align: "i", storage: "x"},
+}
+
+var pgBuiltinTypeByOID = func() map[int64]pgBuiltinType {
+	index := make(map[int64]pgBuiltinType, len(pgBuiltinTypes))
+	for _, t := range pgBuiltinTypes {
+		index[t.oid] = t
+	}
+	return index
+}()
+
+var pgBuiltinTypeByName = func() map[string]pgBuiltinType {
+	index := make(map[string]pgBuiltinType, len(pgBuiltinTypes))
+	for _, t := range pgBuiltinTypes {
+		index[t.name] = t
+	}
+	return index
+}()
+
+// pgTypeForDeclaredName maps an engine column type ("INTEGER", "TEXT[]", ...)
+// to its PostgreSQL type row. Unknown names report TEXT rather than claiming a
+// type the engine does not have.
+func pgTypeForDeclaredName(name string) pgBuiltinType {
+	upper := strings.ToUpper(strings.TrimSpace(name))
+	if idx := strings.IndexByte(upper, '('); idx >= 0 {
+		upper = strings.TrimSpace(upper[:idx])
+	}
+	if strings.HasSuffix(upper, "[]") {
+		element := strings.TrimSpace(strings.TrimSuffix(upper, "[]"))
+		base := pgTypeForDeclaredName(element)
+		if array, ok := pgBuiltinTypeByOID[base.array]; ok && base.array != 0 {
+			return array
+		}
+		if fallback, ok := pgBuiltinTypeByOID[1009]; ok {
+			return fallback
+		}
+	}
+	switch upper {
+	case "INTEGER", "INT", "INT4":
+		return pgBuiltinTypeByOID[23]
+	case "BIGINT", "INT8":
+		return pgBuiltinTypeByOID[20]
+	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
+		return pgBuiltinTypeByOID[21]
+	case "TEXT", "CLOB", "NCHAR", "NVARCHAR":
+		return pgBuiltinTypeByOID[25]
+	case "VARCHAR", "CHARACTER VARYING":
+		return pgBuiltinTypeByOID[1043]
+	case "CHAR", "CHARACTER":
+		return pgBuiltinTypeByOID[1042]
+	case "BOOLEAN", "BOOL":
+		return pgBuiltinTypeByOID[16]
+	case "REAL", "FLOAT", "FLOAT4":
+		return pgBuiltinTypeByOID[700]
+	case "DOUBLE", "DOUBLE PRECISION", "FLOAT8":
+		return pgBuiltinTypeByOID[701]
+	case "NUMERIC", "DECIMAL":
+		return pgBuiltinTypeByOID[1700]
+	case "JSONB":
+		return pgBuiltinTypeByOID[3802]
+	case "JSON":
+		return pgBuiltinTypeByOID[114]
+	case "BLOB", "BYTEA":
+		return pgBuiltinTypeByOID[17]
+	case "DATETIME", "TIMESTAMP", "TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITHOUT TIME ZONE":
+		return pgBuiltinTypeByOID[1184]
+	case "DATE":
+		return pgBuiltinTypeByOID[1082]
+	case "TIME", "TIME WITHOUT TIME ZONE", "TIME WITH TIME ZONE":
+		return pgBuiltinTypeByOID[1083]
+	case "UUID":
+		return pgBuiltinTypeByOID[2950]
+	case "OID":
+		return pgBuiltinTypeByOID[26]
+	case "NAME":
+		return pgBuiltinTypeByOID[19]
+	case "REGCLASS":
+		return pgBuiltinTypeByOID[2205]
+	case "REGTYPE":
+		return pgBuiltinTypeByOID[2206]
+	default:
+		return pgBuiltinTypeByOID[25]
+	}
+}
+
+// ---------------------------------------------------------------------------
+// Snapshot
+
+type catalogRelation struct {
+	schemaName string
+	name       string
+	oid        int64
+	kind       byte // 'r' table, 'i' index, 'v' view / virtual relation
+	columns    []storage.Column
+	table      *storage.Schema
+	index      *storage.Index
+	// virtual marks a synthesized pg_catalog/information_schema relation. User
+	// tables, indexes, and session views are the durable/session schema.
+	virtual bool
+	// primaryIndex marks the synthetic index that backs a PRIMARY KEY. The
+	// engine does not persist an index object for the key, but PostgreSQL tools
+	// expect pg_index/pg_constraint to point at one.
+	primaryIndex bool
+}
+
+type catalogConstraint struct {
+	oid      int64
+	name     string
+	table    *catalogRelation
+	contype  byte
+	indexOID int64
+	columns  []string // child columns (PK/UNIQUE/FK) or empty for CHECK
+	fk       *storage.ForeignKeyConstraint
+	check    *storage.CheckConstraint
+}
+
+type catalogSnapshot struct {
+	database string
+
+	relations     []*catalogRelation
+	relationByKey map[string]*catalogRelation
+	relationByOID map[int64]*catalogRelation
+	tableByName   map[string]*catalogRelation
+	tableIndexes  map[string][]*catalogRelation
+
+	constraints     []*catalogConstraint
+	constraintByOID map[int64]*catalogConstraint
+
+	rows map[string][]storage.Row
+
+	// Preallocated OIDs keep every catalog relation deterministic regardless of
+	// the order in which row sets are materialized.
+	attrdefOIDs map[string]int64
+	databaseOID int64
+	procOIDs    map[string]int64
+
+	oidOwners map[int64]string
+}
+
+// catalogSnapshotForQuery builds (once per statement) and returns the catalog
+// snapshot the statement observes.
+func (e *Executor) catalogSnapshotForQuery() (*catalogSnapshot, error) {
+	if e.schema == nil || e.session == nil {
+		return nil, pgerror.Unsupported("catalog introspection is not available in a stored expression")
+	}
+	if e.pgCatalog != nil {
+		return e.pgCatalog, nil
+	}
+	if e.lockedScanDepth > 0 {
+		// Building a snapshot reads the session catalog, which takes the session
+		// lock; that lock is already held by the scan this predicate runs inside,
+		// so continuing would deadlock the connection and leak the table lock.
+		// Statements that can reach this point prime the snapshot before scanning
+		// (see primeCatalogSnapshot), so this is a guard, not a policy.
+		return nil, pgerror.Unsupported("catalog relations cannot be resolved inside a locked table scan")
+	}
+	snapshot, err := buildCatalogSnapshot(e)
+	if err != nil {
+		return nil, err
+	}
+	e.pgCatalog = snapshot
+	return snapshot, nil
+}
+
+// catalogSnapshotFunctionNames lists the catalog functions whose value is
+// resolved through the statement's virtual catalog snapshot.
+var catalogSnapshotFunctionNames = map[string]bool{
+	"FORMAT_TYPE":          true,
+	"PG_TABLE_IS_VISIBLE":  true,
+	"PG_GET_INDEXDEF":      true,
+	"PG_GET_CONSTRAINTDEF": true,
+	"HAS_TABLE_PRIVILEGE":  true,
+	"TO_REGCLASS":          true,
+	"TO_REGTYPE":           true,
+}
+
+// catalogSnapshotExprNeeded reports whether evaluating expr reads the virtual
+// catalog at value time: a regclass/regtype cast, a catalog function that
+// resolves names through the snapshot, or a subquery containing either.
+// AST fields are often typed nil pointers inside an interface, so every pointer
+// case checks for nil before reading it.
+func catalogSnapshotExprNeeded(expr parser.Expr) bool {
+	switch n := expr.(type) {
+	case nil, *parser.ColumnRef, *parser.LiteralExpr:
+		return false
+	case *parser.CastExpr:
+		if n == nil {
+			return false
+		}
+		switch strings.ToUpper(strings.TrimSpace(n.Type.SQLTypeName())) {
+		case "REGCLASS", "REGTYPE":
+			return true
+		}
+		return catalogSnapshotExprNeeded(n.Expr)
+	case *parser.FunctionCall:
+		if n == nil {
+			return false
+		}
+		if catalogSnapshotFunctionNames[strings.ToUpper(n.Name)] {
+			return true
+		}
+		for _, arg := range n.Args {
+			if catalogSnapshotExprNeeded(arg) {
+				return true
+			}
+		}
+		return false
+	case *parser.BinaryExpr:
+		return n != nil && (catalogSnapshotExprNeeded(n.Left) || catalogSnapshotExprNeeded(n.Right))
+	case *parser.UnaryExpr:
+		return n != nil && catalogSnapshotExprNeeded(n.Operand)
+	case *parser.BetweenExpr:
+		return n != nil && (catalogSnapshotExprNeeded(n.Left) || catalogSnapshotExprNeeded(n.Low) || catalogSnapshotExprNeeded(n.High))
+	case *parser.LikeExpr:
+		return n != nil && (catalogSnapshotExprNeeded(n.Left) || catalogSnapshotExprNeeded(n.Pattern) || catalogSnapshotExprNeeded(n.Escape))
+	case *parser.IsNullExpr:
+		return n != nil && catalogSnapshotExprNeeded(n.Left)
+	case *parser.IsDistinctExpr:
+		return n != nil && (catalogSnapshotExprNeeded(n.Left) || catalogSnapshotExprNeeded(n.Right))
+	case *parser.CaseExpr:
+		if n == nil {
+			return false
+		}
+		if catalogSnapshotExprNeeded(n.Operand) || catalogSnapshotExprNeeded(n.Else) {
+			return true
+		}
+		for _, when := range n.Whens {
+			if catalogSnapshotExprNeeded(when.Condition) || catalogSnapshotExprNeeded(when.Result) {
+				return true
+			}
+		}
+		return false
+	case *parser.ArrayExpr:
+		if n == nil {
+			return false
+		}
+		for _, element := range n.Elements {
+			if catalogSnapshotExprNeeded(element) {
+				return true
+			}
+		}
+		return false
+	case *parser.SubscriptExpr:
+		return n != nil && (catalogSnapshotExprNeeded(n.Expr) || catalogSnapshotExprNeeded(n.Index))
+	case *parser.AnyAllExpr:
+		return n != nil && (catalogSnapshotExprNeeded(n.Left) || catalogSnapshotExprNeeded(n.Array))
+	case *parser.InExpr:
+		if n == nil {
+			return false
+		}
+		if catalogSnapshotExprNeeded(n.Left) || catalogSnapshotSelectNeeded(n.Subquery) {
+			return true
+		}
+		for _, value := range n.Values {
+			if catalogSnapshotExprNeeded(value) {
+				return true
+			}
+		}
+		return false
+	case *parser.SubqueryExpr:
+		return n != nil && catalogSnapshotSelectNeeded(n.Query)
+	case *parser.ExistsExpr:
+		return n != nil && catalogSnapshotSelectNeeded(n.Subquery)
+	case *parser.ParenExpr:
+		return n != nil && catalogSnapshotExprNeeded(n.Expr)
+	default:
+		return false
+	}
+}
+
+// catalogSnapshotSelectNeeded reports whether a SELECT evaluates a catalog-backed
+// expression in any of its clauses or nested relations.
+func catalogSnapshotSelectNeeded(sel *parser.SelectStmt) bool {
+	if sel == nil {
+		return false
+	}
+	for _, cte := range sel.With {
+		if catalogSnapshotSelectNeeded(cte.Query) {
+			return true
+		}
+	}
+	if sel.Compound != nil && (catalogSnapshotSelectNeeded(sel.Compound.Left) || catalogSnapshotSelectNeeded(sel.Compound.Right)) {
+		return true
+	}
+	for _, column := range sel.Columns {
+		if catalogSnapshotExprNeeded(column.Expr) {
+			return true
+		}
+	}
+	for i := range sel.From {
+		if catalogSnapshotTableRefNeeded(&sel.From[i]) {
+			return true
+		}
+	}
+	for _, expr := range sel.GroupBy {
+		if catalogSnapshotExprNeeded(expr) {
+			return true
+		}
+	}
+	for _, item := range sel.OrderBy {
+		if catalogSnapshotExprNeeded(item.Expr) {
+			return true
+		}
+	}
+	return catalogSnapshotExprNeeded(sel.Where) ||
+		catalogSnapshotExprNeeded(sel.Having) ||
+		catalogSnapshotExprNeeded(sel.Limit) ||
+		catalogSnapshotExprNeeded(sel.Offset)
+}
+
+// catalogSnapshotTableRefNeeded reports whether a FROM entry evaluates a
+// catalog-backed expression in a derived table, table function, or join.
+func catalogSnapshotTableRefNeeded(ref *parser.TableRef) bool {
+	if ref == nil {
+		return false
+	}
+	if catalogSnapshotSelectNeeded(ref.Subquery) || catalogSnapshotExprNeeded(ref.Function) {
+		return true
+	}
+	if ref.Join != nil {
+		return catalogSnapshotTableRefNeeded(ref.Join.Table) || catalogSnapshotExprNeeded(ref.Join.Condition)
+	}
+	return false
+}
+
+// catalogSnapshotStatementNeeded reports whether the statement evaluates a
+// catalog-backed expression anywhere it can run during the statement.
+func catalogSnapshotStatementNeeded(stmt parser.Statement) bool {
+	switch s := stmt.(type) {
+	case *parser.SelectStmt:
+		return catalogSnapshotSelectNeeded(s)
+	case *parser.UpdateStmt:
+		for i := range s.From {
+			if catalogSnapshotTableRefNeeded(&s.From[i]) {
+				return true
+			}
+		}
+		for _, assignment := range s.Set {
+			if catalogSnapshotExprNeeded(assignment.Value) {
+				return true
+			}
+		}
+		return catalogSnapshotExprNeeded(s.Where) || catalogSnapshotReturningNeeded(s.Returning)
+	case *parser.DeleteStmt:
+		return catalogSnapshotExprNeeded(s.Where) || catalogSnapshotReturningNeeded(s.Returning)
+	case *parser.InsertStmt:
+		for _, row := range s.Values {
+			for _, value := range row {
+				if catalogSnapshotExprNeeded(value) {
+					return true
+				}
+			}
+		}
+		for _, assignment := range s.ConflictUpdate {
+			if catalogSnapshotExprNeeded(assignment.Value) {
+				return true
+			}
+		}
+		return catalogSnapshotSelectNeeded(s.Select) || catalogSnapshotReturningNeeded(s.Returning)
+	default:
+		return false
+	}
+}
+
+func catalogSnapshotReturningNeeded(columns []parser.SelectColumn) bool {
+	for _, column := range columns {
+		if catalogSnapshotExprNeeded(column.Expr) {
+			return true
+		}
+	}
+	return false
+}
+
+// primeCatalogSnapshot builds the statement's virtual catalog snapshot before a
+// scan can take the session lock. A regclass/regtype cast or catalog function
+// evaluated inside a locked scan would otherwise call back into the session while
+// the scan holds its lock, which deadlocks the connection and leaks the table
+// lock to every other session. Statements whose value-time expressions cannot
+// reach the catalog skip the build entirely.
+func (e *Executor) primeCatalogSnapshot(stmt parser.Statement) error {
+	if e.pgCatalog != nil || !catalogSnapshotStatementNeeded(stmt) {
+		return nil
+	}
+	_, err := e.catalogSnapshotForQuery()
+	return err
+}
+
+// enterLockedScan marks the start of row-filter or update evaluation that runs
+// inside a session scan, where the session lock is already held.
+func (e *Executor) enterLockedScan() { e.lockedScanDepth++ }
+
+// exitLockedScan marks the end of one such evaluation.
+func (e *Executor) exitLockedScan() { e.lockedScanDepth-- }
+
+// visibleCatalogColumns drops the engine's hidden synthetic rowid column, which
+// is not a user-visible PostgreSQL attribute.
+func visibleCatalogColumns(schema *storage.Schema) []storage.Column {
+	columns := make([]storage.Column, 0, len(schema.Columns))
+	for _, column := range schema.Columns {
+		if schema.UsesSyntheticRowID() && column.Name == "_rowid_" {
+			continue
+		}
+		columns = append(columns, column)
+	}
+	return columns
+}
+
+func indexCatalogColumns(index *storage.Index) []storage.Column {
+	columns := make([]storage.Column, len(index.Columns))
+	for i, column := range index.Columns {
+		name := column.Name
+		if name == "" {
+			name = fmt.Sprintf("expr%d", i+1)
+		}
+		columns[i] = storage.Column{Name: name, Type: "TEXT", Nullable: true}
+	}
+	return columns
+}
+
+func relationNamespaceOID(schemaName string) int64 {
+	switch schemaName {
+	case pgCatalogSchemaName:
+		return pgCatalogNamespaceOID
+	case informationSchemaName:
+		return infoSchemaNamespaceOID
+	default:
+		return publicNamespaceOID
+	}
+}
+
+// uniqueCatalogName returns base, or base with a numeric suffix, so a synthetic
+// relation name never collides with a durable table or index name.
+func uniqueCatalogName(base string, used map[string]bool) string {
+	name := base
+	for suffix := 2; used[strings.ToLower(name)]; suffix++ {
+		name = fmt.Sprintf("%s%d", base, suffix)
+	}
+	used[strings.ToLower(name)] = true
+	return name
+}
+
+func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
+	snapshot := &catalogSnapshot{
+		database:        e.schema.GetDatabaseName(),
+		relationByKey:   make(map[string]*catalogRelation),
+		relationByOID:   make(map[int64]*catalogRelation),
+		tableByName:     make(map[string]*catalogRelation),
+		tableIndexes:    make(map[string][]*catalogRelation),
+		constraintByOID: make(map[int64]*catalogConstraint),
+		rows:            make(map[string][]storage.Row),
+		attrdefOIDs:     make(map[string]int64),
+		procOIDs:        make(map[string]int64),
+		oidOwners:       make(map[int64]string),
+	}
+	if snapshot.database == "" {
+		snapshot.database = "pizzasql"
+	}
+
+	tables, err := e.session.ListTables()
+	if err != nil {
+		return nil, err
+	}
+	sort.Strings(tables)
+
+	indexNames, err := e.session.ListIndexes()
+	if err != nil {
+		return nil, err
+	}
+	sort.Strings(indexNames)
+
+	var descriptors []*catalogRelation
+	// usedNames guards every synthetic name against the durable names it could
+	// collide with. A table named <x>_pkey shadows the synthetic index name a
+	// PRIMARY KEY on x would otherwise take.
+	usedNames := make(map[string]bool, len(tables)+len(indexNames)+len(e.views))
+	tableSchemas := make(map[string]*storage.Schema, len(tables))
+	for _, tableName := range tables {
+		schema, err := e.session.GetSchema(tableName)
+		if err != nil {
+			return nil, fmt.Errorf("pg_catalog: resolve table %q: %w", tableName, err)
+		}
+		tableSchemas[tableName] = schema
+		usedNames[strings.ToLower(schema.Name)] = true
+		descriptors = append(descriptors, &catalogRelation{
+			schemaName: publicSchemaName,
+			name:       schema.Name,
+			kind:       'r',
+			columns:    visibleCatalogColumns(schema),
+			table:      schema,
+		})
+	}
+
+	for _, indexName := range indexNames {
+		index, err := e.session.GetIndex(indexName)
+		if err != nil {
+			return nil, fmt.Errorf("pg_catalog: resolve index %q: %w", indexName, err)
+		}
+		usedNames[strings.ToLower(index.Name)] = true
+		descriptors = append(descriptors, &catalogRelation{
+			schemaName: publicSchemaName,
+			name:       index.Name,
+			kind:       'i',
+			columns:    indexCatalogColumns(index),
+			index:      index,
+		})
+	}
+
+	// Session views are part of the schema the connection observes, so they are
+	// reported the same way PostgreSQL reports a view: a pg_class row with
+	// relkind 'v' plus matching pg_attribute and information_schema.columns rows.
+	viewNames := sortedViewNames(e.views)
+	for _, viewName := range viewNames {
+		columns := e.viewColumns[viewName]
+		usedNames[viewName] = true
+		descriptors = append(descriptors, &catalogRelation{
+			schemaName: publicSchemaName,
+			name:       viewName,
+			kind:       'v',
+			columns:    columns,
+		})
+	}
+
+	// One synthetic index per primary key so pg_index/pg_constraint have a
+	// stable conindid. Composite keys keep their declared order.
+	for _, tableName := range tables {
+		schema := tableSchemas[tableName]
+		// A table without a declared PRIMARY KEY is addressed by the engine's
+		// synthetic _rowid_ column, which is not a PostgreSQL attribute and has
+		// no index.
+		if schema.UsesSyntheticRowID() {
+			continue
+		}
+		keyColumns := schema.PrimaryKeyList()
+		if len(keyColumns) == 0 {
+			continue
+		}
+		index := &storage.Index{Name: uniqueCatalogName(schema.Name+"_pkey", usedNames), Table: schema.Name, Unique: true}
+		for _, column := range keyColumns {
+			index.Columns = append(index.Columns, storage.IndexColumn{Name: column})
+		}
+		descriptors = append(descriptors, &catalogRelation{
+			schemaName:   publicSchemaName,
+			name:         index.Name,
+			kind:         'i',
+			columns:      indexCatalogColumns(index),
+			index:        index,
+			primaryIndex: true,
+		})
+	}
+
+	for _, def := range pgCatalogRelationDefs {
+		descriptors = append(descriptors, &catalogRelation{
+			schemaName: def.schema,
+			name:       def.name,
+			kind:       'v',
+			columns:    pgCatalogRelationIndex[def.schema+"."+def.name].schema.Columns,
+			virtual:    true,
+		})
+	}
+	for _, def := range informationSchemaRelationDefs {
+		descriptors = append(descriptors, &catalogRelation{
+			schemaName: def.schema,
+			name:       def.name,
+			kind:       'v',
+			columns:    pgCatalogRelationIndex[def.schema+"."+def.name].schema.Columns,
+			virtual:    true,
+		})
+	}
+
+	sort.SliceStable(descriptors, func(i, j int) bool {
+		if descriptors[i].schemaName != descriptors[j].schemaName {
+			return descriptors[i].schemaName < descriptors[j].schemaName
+		}
+		if descriptors[i].name != descriptors[j].name {
+			return descriptors[i].name < descriptors[j].name
+		}
+		return descriptors[i].kind < descriptors[j].kind
+	})
+
+	for _, relation := range descriptors {
+		relation.oid = snapshot.allocateOID("relation", catalogRelationKey(relation))
+		snapshot.relations = append(snapshot.relations, relation)
+		snapshot.relationByKey[catalogRelationKey(relation)] = relation
+		snapshot.relationByOID[relation.oid] = relation
+		if relation.kind == 'r' {
+			snapshot.tableByName[strings.ToLower(relation.name)] = relation
+		}
+		if relation.kind == 'i' {
+			tableKey := strings.ToLower(relation.index.Table)
+			snapshot.tableIndexes[tableKey] = append(snapshot.tableIndexes[tableKey], relation)
+		}
+	}
+
+	// Constraints are allocated after relations so conindid can point at the
+	// durable or synthetic index OID.
+	for _, relation := range snapshot.relations {
+		if relation.kind != 'r' {
+			continue
+		}
+		usedConstraintNames := make(map[string]bool)
+		addConstraint := func(constraint *catalogConstraint) {
+			constraint.table = relation
+			constraint.oid = snapshot.allocateOID("constraint", relation.name+"."+constraint.name)
+			snapshot.constraints = append(snapshot.constraints, constraint)
+			snapshot.constraintByOID[constraint.oid] = constraint
+		}
+
+		for _, indexRelation := range snapshot.tableIndexes[strings.ToLower(relation.name)] {
+			if !indexRelation.primaryIndex {
+				continue
+			}
+			usedConstraintNames[strings.ToLower(indexRelation.name)] = true
+			addConstraint(&catalogConstraint{
+				name:     indexRelation.name,
+				contype:  'p',
+				indexOID: indexRelation.oid,
+				columns:  indexColumnNames(indexRelation.index),
+			})
+		}
+		for _, indexRelation := range snapshot.tableIndexes[strings.ToLower(relation.name)] {
+			if indexRelation.primaryIndex || !indexRelation.index.Unique {
+				continue
+			}
+			if usedConstraintNames[strings.ToLower(indexRelation.name)] {
+				continue
+			}
+			usedConstraintNames[strings.ToLower(indexRelation.name)] = true
+			addConstraint(&catalogConstraint{
+				name:     indexRelation.name,
+				contype:  'u',
+				indexOID: indexRelation.oid,
+				columns:  indexColumnNames(indexRelation.index),
+			})
+		}
+		for i := range relation.table.ForeignKeys {
+			fk := &relation.table.ForeignKeys[i]
+			name := fk.Name
+			if name == "" {
+				name = relation.name + "_" + strings.Join(fk.Columns, "_") + "_fkey"
+			}
+			name = uniqueCatalogName(name, usedConstraintNames)
+			addConstraint(&catalogConstraint{name: name, contype: 'f', columns: fk.Columns, fk: fk})
+		}
+		for i := range relation.table.Checks {
+			check := &relation.table.Checks[i]
+			name := check.Name
+			if name == "" {
+				name = relation.name + "_check"
+			}
+			name = uniqueCatalogName(name, usedConstraintNames)
+			addConstraint(&catalogConstraint{name: name, contype: 'c', check: check})
+		}
+	}
+
+	// Default expressions, the database row, and function rows are allocated in
+	// sorted/declared order so their OIDs do not depend on which relation a
+	// query reads first.
+	for _, relation := range snapshot.relations {
+		if relation.kind != 'r' {
+			continue
+		}
+		for _, column := range relation.columns {
+			if column.DefaultLiteral() == nil {
+				continue
+			}
+			snapshot.attrdefOIDs[attrdefKey(relation.name, column.Name)] = snapshot.allocateOID("attrdef", relation.name+"."+column.Name)
+		}
+	}
+	snapshot.databaseOID = snapshot.allocateOID("database", snapshot.database)
+	for _, function := range pgCatalogFunctions {
+		snapshot.procOIDs[function.name] = snapshot.allocateOID("proc", function.name)
+	}
+
+	return snapshot, nil
+}
+
+// attrdefKey identifies a column default in the snapshot's OID table.
+func attrdefKey(table, column string) string {
+	return strings.ToLower(table) + "." + strings.ToLower(column)
+}
+
+// sortedViewNames returns the session's view names in deterministic order.
+func sortedViewNames(views map[string]*parser.SelectStmt) []string {
+	names := make([]string, 0, len(views))
+	for name := range views {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	return names
+}
+
+func indexColumnNames(index *storage.Index) []string {
+	names := make([]string, len(index.Columns))
+	for i, column := range index.Columns {
+		names[i] = column.Name
+	}
+	return names
+}
+
+// regclassOID resolves a relation name the way a regclass literal does:
+// an explicit schema or the public search path, with pg_catalog implicit.
+func (s *catalogSnapshot) regclassOID(name string) (int64, bool) {
+	name = strings.TrimSpace(name)
+	name = strings.Trim(name, `"`)
+	if name == "" {
+		return 0, false
+	}
+	if dot := strings.IndexByte(name, '.'); dot >= 0 {
+		schemaName := strings.Trim(strings.TrimSpace(name[:dot]), `"`)
+		tableName := strings.Trim(strings.TrimSpace(name[dot+1:]), `"`)
+		if relation := s.regclassRelation(schemaName, tableName); relation != nil {
+			return relation.oid, true
+		}
+		return 0, false
+	}
+	if relation := s.regclassRelation(publicSchemaName, name); relation != nil {
+		return relation.oid, true
+	}
+	if relation := s.regclassRelation(pgCatalogSchemaName, name); relation != nil {
+		return relation.oid, true
+	}
+	return 0, false
+}
+
+// regclassRelation resolves a schema-qualified relation name to the row a
+// regclass literal names. PostgreSQL keeps relation names unique inside a
+// schema, but the engine lets an index share its table's name, so the lookup
+// prefers a table, then a session view, then an index.
+func (s *catalogSnapshot) regclassRelation(schemaName, name string) *catalogRelation {
+	key := strings.ToLower(schemaName + "." + name)
+	for _, kind := range []byte{'r', 'v', 'i'} {
+		if relation, ok := s.relationByKey[key+"#"+string(kind)]; ok {
+			return relation
+		}
+	}
+	return nil
+}
+
+// regclassName renders a relation OID the way regclass output does.
+func (s *catalogSnapshot) regclassName(oid int64) (string, bool) {
+	relation, ok := s.relationByOID[oid]
+	if !ok {
+		return "", false
+	}
+	return relation.name, true
+}
+
+// tableRelationFor resolves a table name (unqualified or schema-qualified) to
+// its public relation row.
+func (s *catalogSnapshot) tableRelationFor(name string) *catalogRelation {
+	name = strings.TrimSpace(name)
+	if dot := strings.IndexByte(name, '.'); dot >= 0 {
+		name = name[dot+1:]
+	}
+	return s.tableByName[strings.ToLower(name)]
+}
+
+func catalogAttributeNumbers(schema *storage.Schema) map[string]int64 {
+	numbers := make(map[string]int64, len(schema.Columns))
+	position := int64(0)
+	for _, column := range visibleCatalogColumns(schema) {
+		position++
+		numbers[strings.ToLower(column.Name)] = position
+	}
+	return numbers
+}
+
+func oidListText(values []int64) string {
+	parts := make([]string, len(values))
+	for i, value := range values {
+		parts[i] = strconv.FormatInt(value, 10)
+	}
+	return "{" + strings.Join(parts, ",") + "}"
+}
+
+func defaultExpressionText(column storage.Column) interface{} {
+	value := column.DefaultLiteral()
+	if value == nil {
+		return nil
+	}
+	return sqlLiteral(value)
+}
+
+// ---------------------------------------------------------------------------
+// Row builders
+
+func (s *catalogSnapshot) relationRows(key string) []storage.Row {
+	key = strings.ToLower(key)
+	if rows, ok := s.rows[key]; ok {
+		return rows
+	}
+	var rows []storage.Row
+	switch key {
+	case "pg_catalog.pg_namespace":
+		rows = s.pgNamespaceRows()
+	case "pg_catalog.pg_class":
+		rows = s.pgClassRows()
+	case "pg_catalog.pg_attribute":
+		rows = s.pgAttributeRows()
+	case "pg_catalog.pg_type":
+		rows = s.pgTypeRows()
+	case "pg_catalog.pg_index":
+		rows = s.pgIndexRows()
+	case "pg_catalog.pg_constraint":
+		rows = s.pgConstraintRows()
+	case "pg_catalog.pg_attrdef":
+		rows = s.pgAttrdefRows()
+	case "pg_catalog.pg_database":
+		rows = s.pgDatabaseRows()
+	case "pg_catalog.pg_tablespace":
+		rows = s.pgTablespaceRows()
+	case "pg_catalog.pg_proc":
+		rows = s.pgProcRows()
+	case "pg_catalog.pg_am":
+		rows = s.pgAmRows()
+	case "pg_catalog.pg_description":
+		rows = []storage.Row{}
+	case "pg_catalog.pg_tables":
+		rows = s.pgTablesRows()
+	case "pg_catalog.pg_indexes":
+		rows = s.pgIndexesRows()
+	case "information_schema.tables":
+		rows = s.informationSchemaTablesRows()
+	case "information_schema.columns":
+		rows = s.informationSchemaColumnsRows()
+	case "information_schema.table_constraints":
+		rows = s.informationSchemaTableConstraintsRows()
+	case "information_schema.key_column_usage":
+		rows = s.informationSchemaKeyColumnUsageRows()
+	case "information_schema.referential_constraints":
+		rows = s.informationSchemaReferentialConstraintsRows()
+	case "information_schema.constraint_column_usage":
+		rows = s.informationSchemaConstraintColumnUsageRows()
+	case "information_schema.schemata":
+		rows = s.informationSchemaSchemataRows()
+	case "information_schema.sequences":
+		rows = []storage.Row{}
+	case "information_schema.views":
+		rows = s.informationSchemaViewsRows()
+	default:
+		rows = []storage.Row{}
+	}
+	s.rows[key] = rows
+	return rows
+}
+
+func (s *catalogSnapshot) pgNamespaceRows() []storage.Row {
+	return []storage.Row{
+		{"oid": pgCatalogNamespaceOID, "nspname": pgCatalogSchemaName, "nspowner": catalogOwnerOID, "nspacl": nil},
+		{"oid": publicNamespaceOID, "nspname": publicSchemaName, "nspowner": catalogOwnerOID, "nspacl": nil},
+		{"oid": infoSchemaNamespaceOID, "nspname": informationSchemaName, "nspowner": catalogOwnerOID, "nspacl": nil},
+	}
+}
+
+func (s *catalogSnapshot) pgClassRows() []storage.Row {
+	rows := make([]storage.Row, 0, len(s.relations))
+	for _, relation := range s.relations {
+		hasIndex := false
+		checks := int64(0)
+		relfilenode := int64(0)
+		relam := int64(0)
+		switch relation.kind {
+		case 'r':
+			hasIndex = len(s.tableIndexes[strings.ToLower(relation.name)]) > 0
+			checks = int64(len(relation.table.Checks))
+			relfilenode = relation.oid
+		case 'i':
+			relfilenode = relation.oid
+			relam = pgBtreeAMOID
+		}
+		rows = append(rows, storage.Row{
+			"oid":                 relation.oid,
+			"relname":             relation.name,
+			"relnamespace":        relationNamespaceOID(relation.schemaName),
+			"reltype":             int64(0),
+			"reloftype":           int64(0),
+			"relowner":            catalogOwnerOID,
+			"relam":               relam,
+			"relfilenode":         relfilenode,
+			"reltablespace":       int64(0),
+			"relpages":            int64(0),
+			"reltuples":           float64(0),
+			"relallvisible":       int64(0),
+			"reltoastrelid":       int64(0),
+			"relhasindex":         hasIndex,
+			"relisshared":         false,
+			"relpersistence":      "p",
+			"relkind":             string(relation.kind),
+			"relnatts":            int64(len(relation.columns)),
+			"relchecks":           checks,
+			"relhasrules":         false,
+			"relhastriggers":      false,
+			"relhassubclass":      false,
+			"relrowsecurity":      false,
+			"relforcerowsecurity": false,
+			"relispopulated":      true,
+			"relreplident":        "d",
+			"relispartition":      false,
+			"relrewrite":          int64(0),
+			"relfrozenxid":        int64(0),
+			"relminmxid":          int64(0),
+			"relacl":              nil,
+			"reloptions":          nil,
+			"relpartbound":        nil,
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) relationAttributeType(relation *catalogRelation, column storage.Column) pgBuiltinType {
+	if relation.kind == 'i' && relation.index != nil {
+		if table := s.tableRelationFor(relation.index.Table); table != nil {
+			for _, tableColumn := range table.columns {
+				if strings.EqualFold(tableColumn.Name, column.Name) {
+					return pgTypeForDeclaredName(tableColumn.Type)
+				}
+			}
+		}
+	}
+	return pgTypeForDeclaredName(column.Type)
+}
+
+func (s *catalogSnapshot) pgAttributeRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		for i, column := range relation.columns {
+			builtin := s.relationAttributeType(relation, column)
+			notNull := false
+			hasDefault := false
+			generated := ""
+			if relation.kind == 'r' {
+				notNull = !column.Nullable
+				hasDefault = column.DefaultLiteral() != nil
+				if column.GeneratedExpr != "" {
+					generated = "s"
+				}
+			}
+			arrayDimensions := int64(0)
+			if builtin.isArray() {
+				arrayDimensions = 1
+			}
+			rows = append(rows, storage.Row{
+				"attrelid":       relation.oid,
+				"attname":        column.Name,
+				"atttypid":       builtin.oid,
+				"attstattarget":  int64(-1),
+				"attlen":         builtin.length,
+				"attnum":         int64(i + 1),
+				"attndims":       arrayDimensions,
+				"attcacheoff":    int64(-1),
+				"atttypmod":      int64(-1),
+				"attbyval":       builtin.byval,
+				"attalign":       builtin.align,
+				"attstorage":     builtin.storage,
+				"attcompression": "",
+				"attnotnull":     notNull,
+				"atthasdef":      hasDefault,
+				"atthasmissing":  false,
+				"attidentity":    "",
+				"attgenerated":   generated,
+				"attisdropped":   false,
+				"attislocal":     true,
+				"attinhcount":    int64(0),
+				"attcollation":   int64(0),
+				"attacl":         nil,
+				"attoptions":     nil,
+				"attfdwoptions":  nil,
+				"attmissingval":  nil,
+			})
+		}
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) pgTypeRows() []storage.Row {
+	rows := make([]storage.Row, 0, len(pgBuiltinTypes))
+	for _, t := range pgBuiltinTypes {
+		rows = append(rows, storage.Row{
+			"oid":            t.oid,
+			"typname":        t.name,
+			"typnamespace":   pgCatalogNamespaceOID,
+			"typowner":       catalogOwnerOID,
+			"typlen":         t.length,
+			"typbyval":       t.byval,
+			"typtype":        "b",
+			"typcategory":    t.category,
+			"typispreferred": false,
+			"typisdefined":   true,
+			"typdelim":       t.delim,
+			"typrelid":       int64(0),
+			"typsubscript":   nil,
+			"typelem":        t.elem,
+			"typarray":       t.array,
+			"typinput":       int64(0),
+			"typoutput":      int64(0),
+			"typreceive":     int64(0),
+			"typsend":        int64(0),
+			"typmodin":       int64(0),
+			"typmodout":      int64(0),
+			"typanalyze":     int64(0),
+			"typalign":       t.align,
+			"typstorage":     t.storage,
+			"typnotnull":     false,
+			"typbasetype":    int64(0),
+			"typtypmod":      int64(-1),
+			"typndims":       int64(0),
+			"typcollation":   int64(0),
+			"typdefaultbin":  nil,
+			"typdefault":     nil,
+			"typacl":         nil,
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) indexAttributeNumbers(indexRelation *catalogRelation) []int64 {
+	numbers := make([]int64, len(indexRelation.index.Columns))
+	table := s.tableRelationFor(indexRelation.index.Table)
+	if table == nil {
+		return numbers
+	}
+	attnums := catalogAttributeNumbers(table.table)
+	for i, column := range indexRelation.index.Columns {
+		if column.Name == "" {
+			continue
+		}
+		numbers[i] = attnums[strings.ToLower(column.Name)]
+	}
+	return numbers
+}
+
+func (s *catalogSnapshot) pgIndexRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind != 'i' || relation.index == nil {
+			continue
+		}
+		table := s.tableRelationFor(relation.index.Table)
+		tableOID := int64(0)
+		if table != nil {
+			tableOID = table.oid
+		}
+		attnums := s.indexAttributeNumbers(relation)
+		options := make([]int64, len(attnums))
+		var expressions []string
+		hasExpression := false
+		for _, column := range relation.index.Columns {
+			if column.Expression != "" {
+				expressions = append(expressions, column.Expression)
+				hasExpression = true
+			}
+		}
+		var indexprs interface{}
+		if hasExpression {
+			indexprs = strings.Join(expressions, ", ")
+		}
+		rows = append(rows, storage.Row{
+			"indexrelid":     relation.oid,
+			"indrelid":       tableOID,
+			"indnatts":       int64(len(attnums)),
+			"indnkeyatts":    int64(len(attnums)),
+			"indisunique":    relation.index.Unique,
+			"indisprimary":   relation.primaryIndex,
+			"indisexclusion": false,
+			"indimmediate":   true,
+			"indisclustered": false,
+			"indisvalid":     true,
+			"indcheckxmin":   false,
+			"indisready":     true,
+			"indislive":      true,
+			"indisreplident": false,
+			"indkey":         oidListText(attnums),
+			"indcollation":   oidListText(make([]int64, len(attnums))),
+			"indclass":       oidListText(make([]int64, len(attnums))),
+			"indoption":      oidListText(options),
+			"indexprs":       indexprs,
+			"indpred":        nil,
+		})
+	}
+	return rows
+}
+
+func constraintActionCode(action string) string {
+	switch strings.ToUpper(strings.TrimSpace(action)) {
+	case "CASCADE":
+		return "c"
+	case "RESTRICT":
+		return "r"
+	case "SET NULL":
+		return "n"
+	case "SET DEFAULT":
+		return "d"
+	default:
+		return "a"
+	}
+}
+
+func (s *catalogSnapshot) pgConstraintRows() []storage.Row {
+	rows := make([]storage.Row, 0, len(s.constraints))
+	for _, constraint := range s.constraints {
+		var conkey interface{}
+		if len(constraint.columns) > 0 {
+			attnums := catalogAttributeNumbers(constraint.table.table)
+			key := make([]int64, 0, len(constraint.columns))
+			for _, name := range constraint.columns {
+				key = append(key, attnums[strings.ToLower(name)])
+			}
+			conkey = oidListText(key)
+		}
+
+		var confrelid, confupdtype, confdeltype, confmatchtype, confkey interface{}
+		if constraint.fk != nil {
+			if parent := s.tableRelationFor(constraint.fk.RefTable); parent != nil {
+				confrelid = parent.oid
+				parentAttnums := catalogAttributeNumbers(parent.table)
+				key := make([]int64, 0, len(constraint.fk.RefColumns))
+				for _, name := range constraint.fk.RefColumns {
+					key = append(key, parentAttnums[strings.ToLower(name)])
+				}
+				confkey = oidListText(key)
+			} else {
+				confrelid = int64(0)
+			}
+			confupdtype = constraintActionCode(constraint.fk.OnUpdate)
+			confdeltype = constraintActionCode(constraint.fk.OnDelete)
+			confmatchtype = "s"
+		}
+
+		var conbin interface{}
+		if constraint.check != nil {
+			conbin = constraint.check.Expression
+		}
+
+		rows = append(rows, storage.Row{
+			"oid":           constraint.oid,
+			"conname":       constraint.name,
+			"connamespace":  publicNamespaceOID,
+			"contype":       string(constraint.contype),
+			"condeferrable": false,
+			"condeferred":   false,
+			"convalidated":  true,
+			"conrelid":      constraint.table.oid,
+			"contypid":      int64(0),
+			"conindid":      constraint.indexOID,
+			"conparentid":   int64(0),
+			"confrelid":     confrelid,
+			"confupdtype":   confupdtype,
+			"confdeltype":   confdeltype,
+			"confmatchtype": confmatchtype,
+			"conislocal":    true,
+			"coninhcount":   int64(0),
+			"connoinherit":  false,
+			"conkey":        conkey,
+			"confkey":       confkey,
+			"conpfeqop":     nil,
+			"conppeqop":     nil,
+			"conffeqop":     nil,
+			"conexclop":     nil,
+			"conbin":        conbin,
+			"consrc":        conbin,
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) pgAttrdefRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind != 'r' {
+			continue
+		}
+		attnums := catalogAttributeNumbers(relation.table)
+		for _, column := range relation.columns {
+			text := defaultExpressionText(column)
+			if text == nil {
+				continue
+			}
+			adnum := attnums[strings.ToLower(column.Name)]
+			rows = append(rows, storage.Row{
+				"oid":     s.attrdefOIDs[attrdefKey(relation.name, column.Name)],
+				"adrelid": relation.oid,
+				"adnum":   adnum,
+				"adbin":   text,
+			})
+		}
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) pgDatabaseRows() []storage.Row {
+	return []storage.Row{{
+		"oid":            s.databaseOID,
+		"datname":        s.database,
+		"datdba":         catalogOwnerOID,
+		"encoding":       int64(6), // UTF8
+		"datlocprovider": "c",
+		"datistemplate":  false,
+		"datallowconn":   true,
+		"datconnlimit":   int64(-1),
+		"datlastsysoid":  int64(0),
+		"datfrozenxid":   int64(0),
+		"datminmxid":     int64(0),
+		"dattablespace":  pgDefaultTablespaceOID,
+		"datcollate":     "C",
+		"datctype":       "C",
+		"datlocale":      nil,
+		"daticurules":    nil,
+		"datcollversion": nil,
+		"datacl":         nil,
+	}}
+}
+
+func (s *catalogSnapshot) pgTablespaceRows() []storage.Row {
+	return []storage.Row{
+		{"oid": pgDefaultTablespaceOID, "spcname": "pg_default", "spcowner": catalogOwnerOID, "spcacl": nil, "spcoptions": nil},
+		{"oid": pgGlobalTablespaceOID, "spcname": "pg_global", "spcowner": catalogOwnerOID, "spcacl": nil, "spcoptions": nil},
+	}
+}
+
+// pgCatalogFunctions is the pg_proc surface the executor answers. It mirrors
+// evalCatalogFunction; names not listed here never appear in pg_proc.
+var pgCatalogFunctions = []struct {
+	name    string
+	nargs   int64
+	rettype int64
+}{
+	{"current_database", 0, 19},
+	{"current_schema", 0, 19},
+	{"format_type", 2, 25},
+	{"has_table_privilege", 3, 16},
+	{"obj_description", 2, 25},
+	{"col_description", 2, 25},
+	{"pg_get_constraintdef", 2, 25},
+	{"pg_get_expr", 3, 25},
+	{"pg_get_indexdef", 3, 25},
+	{"pg_get_serial_sequence", 2, 25},
+	{"pg_get_userbyid", 1, 19},
+	{"pg_table_is_visible", 1, 16},
+	{"to_regclass", 1, 2205},
+	{"to_regtype", 1, 2206},
+}
+
+func (s *catalogSnapshot) pgProcRows() []storage.Row {
+	rows := make([]storage.Row, 0, len(pgCatalogFunctions))
+	for _, function := range pgCatalogFunctions {
+		rows = append(rows, storage.Row{
+			"oid":             s.procOIDs[function.name],
+			"proname":         function.name,
+			"pronamespace":    pgCatalogNamespaceOID,
+			"proowner":        catalogOwnerOID,
+			"prolang":         int64(12), // internal
+			"procost":         float64(1),
+			"prorows":         float64(0),
+			"provariadic":     int64(0),
+			"prosupport":      nil,
+			"prokind":         "f",
+			"prosecdef":       false,
+			"proleakproof":    false,
+			"proisstrict":     true,
+			"proretset":       false,
+			"provolatile":     "s",
+			"proparallel":     "s",
+			"pronargs":        function.nargs,
+			"pronargdefaults": int64(0),
+			"prorettype":      function.rettype,
+			"proargtypes":     "",
+			"proallargtypes":  nil,
+			"proargmodes":     nil,
+			"proargnames":     nil,
+			"proargdefaults":  nil,
+			"protrftypes":     nil,
+			"prosrc":          function.name,
+			"probin":          nil,
+			"prosqlbody":      nil,
+			"proconfig":       nil,
+			"proacl":          nil,
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) pgAmRows() []storage.Row {
+	return []storage.Row{
+		{"oid": pgHeapAMOID, "amname": "heap", "amhandler": int64(0), "amtype": "t"},
+		{"oid": pgBtreeAMOID, "amname": "btree", "amhandler": int64(0), "amtype": "i"},
+	}
+}
+
+func (s *catalogSnapshot) pgTablesRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind != 'r' {
+			continue
+		}
+		hasIndex := len(s.tableIndexes[strings.ToLower(relation.name)]) > 0
+		rows = append(rows, storage.Row{
+			"schemaname":  relation.schemaName,
+			"tablename":   relation.name,
+			"tableowner":  "pizzasql",
+			"tablespace":  nil,
+			"hasindexes":  hasIndex,
+			"hasrules":    false,
+			"hastriggers": false,
+			"rowsecurity": false,
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) pgIndexesRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind != 'i' || relation.index == nil {
+			continue
+		}
+		rows = append(rows, storage.Row{
+			"schemaname": relation.schemaName,
+			"tablename":  relation.index.Table,
+			"indexname":  relation.name,
+			"tablespace": nil,
+			"indexdef":   s.indexDefinition(relation.index),
+		})
+	}
+	return rows
+}
+
+// ---------------------------------------------------------------------------
+// information_schema row builders
+
+func (s *catalogSnapshot) informationSchemaTablesRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind == 'i' {
+			continue
+		}
+		tableType := "BASE TABLE"
+		if relation.kind != 'r' {
+			tableType = "VIEW"
+		}
+		insertable := "NO"
+		if relation.kind == 'r' {
+			insertable = "YES"
+		}
+		rows = append(rows, storage.Row{
+			"table_catalog":                s.database,
+			"table_schema":                 relation.schemaName,
+			"table_name":                   relation.name,
+			"table_type":                   tableType,
+			"self_referencing_column_name": nil,
+			"reference_generation":         nil,
+			"user_defined_type_catalog":    nil,
+			"user_defined_type_schema":     nil,
+			"user_defined_type_name":       nil,
+			"is_insertable_into":           insertable,
+			"is_typed":                     "NO",
+			"commit_action":                nil,
+		})
+	}
+	return rows
+}
+
+func informationSchemaColumn(relation *catalogRelation, column storage.Column, position int) storage.Row {
+	builtin := pgTypeForDeclaredName(column.Type)
+	dataType := builtin.sqlName
+	if builtin.isArray() {
+		dataType = "ARRAY"
+	}
+	generated := "NEVER"
+	generationExpression := interface{}(nil)
+	if column.GeneratedExpr != "" {
+		generated = "ALWAYS"
+		generationExpression = column.GeneratedExpr
+	}
+	return storage.Row{
+		"table_catalog":            "", // filled by the caller
+		"table_schema":             relation.schemaName,
+		"table_name":               relation.name,
+		"column_name":              column.Name,
+		"ordinal_position":         int64(position),
+		"column_default":           defaultExpressionText(column),
+		"is_nullable":              map[bool]string{true: "YES", false: "NO"}[column.Nullable],
+		"data_type":                dataType,
+		"character_maximum_length": nil,
+		"character_octet_length":   nil,
+		"numeric_precision":        nil,
+		"numeric_precision_radix":  nil,
+		"numeric_scale":            nil,
+		"datetime_precision":       nil,
+		"interval_type":            nil,
+		"interval_precision":       nil,
+		"character_set_catalog":    nil,
+		"character_set_schema":     nil,
+		"character_set_name":       nil,
+		"collation_catalog":        nil,
+		"collation_schema":         nil,
+		"collation_name":           nil,
+		"domain_catalog":           nil,
+		"domain_schema":            nil,
+		"domain_name":              nil,
+		"udt_catalog":              "", // filled by the caller
+		"udt_schema":               pgCatalogSchemaName,
+		"udt_name":                 builtin.name,
+		"scope_catalog":            nil,
+		"scope_schema":             nil,
+		"scope_name":               nil,
+		"maximum_cardinality":      nil,
+		"dtd_identifier":           strconv.Itoa(position),
+		"is_self_referencing":      "NO",
+		"is_identity":              "NO",
+		"identity_generation":      nil,
+		"identity_start":           nil,
+		"identity_increment":       nil,
+		"identity_maximum":         nil,
+		"identity_minimum":         nil,
+		"identity_cycle":           "NO",
+		"is_generated":             generated,
+		"generation_expression":    generationExpression,
+		"is_updatable":             "YES",
+	}
+}
+
+func (s *catalogSnapshot) informationSchemaColumnsRows() []storage.Row {
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind == 'i' || relation.virtual {
+			continue
+		}
+		for i, column := range relation.columns {
+			row := informationSchemaColumn(relation, column, i+1)
+			row["table_catalog"] = s.database
+			row["udt_catalog"] = s.database
+			rows = append(rows, row)
+		}
+	}
+	return rows
+}
+
+func constraintTypeName(contype byte) string {
+	switch contype {
+	case 'p':
+		return "PRIMARY KEY"
+	case 'u':
+		return "UNIQUE"
+	case 'f':
+		return "FOREIGN KEY"
+	case 'c':
+		return "CHECK"
+	default:
+		return "UNKNOWN"
+	}
+}
+
+func (s *catalogSnapshot) informationSchemaTableConstraintsRows() []storage.Row {
+	rows := make([]storage.Row, 0, len(s.constraints))
+	for _, constraint := range s.constraints {
+		rows = append(rows, storage.Row{
+			"constraint_catalog": s.database,
+			"constraint_schema":  publicSchemaName,
+			"constraint_name":    constraint.name,
+			"table_catalog":      s.database,
+			"table_schema":       publicSchemaName,
+			"table_name":         constraint.table.name,
+			"constraint_type":    constraintTypeName(constraint.contype),
+			"is_deferrable":      "NO",
+			"initially_deferred": "NO",
+			"enforced":           "YES",
+			"nulls_distinct":     nil,
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) informationSchemaKeyColumnUsageRows() []storage.Row {
+	var rows []storage.Row
+	for _, constraint := range s.constraints {
+		var columns []string
+		switch constraint.contype {
+		case 'p', 'u':
+			columns = constraint.columns
+		case 'f':
+			columns = constraint.fk.Columns
+		default:
+			continue
+		}
+		for i, column := range columns {
+			var position interface{}
+			if constraint.contype == 'f' {
+				position = int64(i + 1)
+			}
+			rows = append(rows, storage.Row{
+				"constraint_catalog":            s.database,
+				"constraint_schema":             publicSchemaName,
+				"constraint_name":               constraint.name,
+				"table_catalog":                 s.database,
+				"table_schema":                  publicSchemaName,
+				"table_name":                    constraint.table.name,
+				"column_name":                   column,
+				"ordinal_position":              int64(i + 1),
+				"position_in_unique_constraint": position,
+			})
+		}
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) uniqueConstraintFor(parent *catalogRelation, columns []string) string {
+	for _, constraint := range s.constraints {
+		if constraint.table != parent || (constraint.contype != 'p' && constraint.contype != 'u') {
+			continue
+		}
+		keyColumns := constraint.columns
+		if constraint.contype == 'u' && constraint.indexOID != 0 {
+			for _, indexRelation := range s.relations {
+				if indexRelation.oid == constraint.indexOID && indexRelation.index != nil {
+					keyColumns = indexColumnNames(indexRelation.index)
+					break
+				}
+			}
+		}
+		if len(keyColumns) != len(columns) {
+			continue
+		}
+		matches := true
+		for i := range columns {
+			if !strings.EqualFold(keyColumns[i], columns[i]) {
+				matches = false
+				break
+			}
+		}
+		if matches {
+			return constraint.name
+		}
+	}
+	return ""
+}
+
+func referentialRule(action string) string {
+	if strings.TrimSpace(action) == "" {
+		return "NO ACTION"
+	}
+	return strings.ToUpper(strings.TrimSpace(action))
+}
+
+func (s *catalogSnapshot) informationSchemaReferentialConstraintsRows() []storage.Row {
+	var rows []storage.Row
+	for _, constraint := range s.constraints {
+		if constraint.contype != 'f' || constraint.fk == nil {
+			continue
+		}
+		parent := s.tableRelationFor(constraint.fk.RefTable)
+		var uniqueName interface{}
+		if parent != nil {
+			if name := s.uniqueConstraintFor(parent, constraint.fk.RefColumns); name != "" {
+				uniqueName = name
+			}
+		}
+		rows = append(rows, storage.Row{
+			"constraint_catalog":        s.database,
+			"constraint_schema":         publicSchemaName,
+			"constraint_name":           constraint.name,
+			"unique_constraint_catalog": s.database,
+			"unique_constraint_schema":  publicSchemaName,
+			"unique_constraint_name":    uniqueName,
+			"match_option":              "NONE",
+			"update_rule":               referentialRule(constraint.fk.OnUpdate),
+			"delete_rule":               referentialRule(constraint.fk.OnDelete),
+			"is_deferrable":             "NO",
+			"initially_deferred":        "NO",
+			"enforced":                  "YES",
+		})
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) informationSchemaConstraintColumnUsageRows() []storage.Row {
+	var rows []storage.Row
+	for _, constraint := range s.constraints {
+		switch constraint.contype {
+		case 'f':
+			if constraint.fk == nil {
+				continue
+			}
+			for _, column := range constraint.fk.RefColumns {
+				rows = append(rows, storage.Row{
+					"table_catalog":      s.database,
+					"table_schema":       publicSchemaName,
+					"table_name":         constraint.fk.RefTable,
+					"constraint_catalog": s.database,
+					"constraint_schema":  publicSchemaName,
+					"constraint_name":    constraint.name,
+					"column_name":        column,
+				})
+			}
+		case 'c':
+			if constraint.check == nil {
+				continue
+			}
+			expr, err := parser.ParseExpr(constraint.check.Expression)
+			if err != nil {
+				continue
+			}
+			for _, column := range collectColumnRefs(expr) {
+				rows = append(rows, storage.Row{
+					"table_catalog":      s.database,
+					"table_schema":       publicSchemaName,
+					"table_name":         constraint.table.name,
+					"constraint_catalog": s.database,
+					"constraint_schema":  publicSchemaName,
+					"constraint_name":    constraint.name,
+					"column_name":        column,
+				})
+			}
+		}
+	}
+	return rows
+}
+
+func (s *catalogSnapshot) informationSchemaSchemataRows() []storage.Row {
+	return []storage.Row{
+		{"catalog_name": s.database, "schema_name": pgCatalogSchemaName, "schema_owner": "pizzasql", "default_character_set_catalog": nil, "default_character_set_schema": nil, "default_character_set_name": nil, "sql_path": nil},
+		{"catalog_name": s.database, "schema_name": publicSchemaName, "schema_owner": "pizzasql", "default_character_set_catalog": nil, "default_character_set_schema": nil, "default_character_set_name": nil, "sql_path": nil},
+		{"catalog_name": s.database, "schema_name": informationSchemaName, "schema_owner": "pizzasql", "default_character_set_catalog": nil, "default_character_set_schema": nil, "default_character_set_name": nil, "sql_path": nil},
+	}
+}
+
+// catalogSchemaByName resolves an unqualified pg_catalog relation name that is
+// not shadowed by a durable table or a CTE.
+func (e *Executor) catalogSchemaByName(name string) (*storage.Schema, bool) {
+	entry, ok := pgCatalogRelationIndex[pgCatalogSchemaName+"."+strings.ToLower(name)]
+	if !ok || e.catalogTableExists(name) || e.cteTableExists(name) {
+		return nil, false
+	}
+	return entry.schema, true
+}
+
+// fromResolvesToCatalogRelations reports whether every FROM entry is a virtual
+// catalog relation or a materialized CTE, so the statement can be evaluated
+// without reading the durable schema. A statement with no FROM needs no schema
+// either.
+func (e *Executor) fromResolvesToCatalogRelations(from []parser.TableRef) bool {
+	for i := range from {
+		ref := &from[i]
+		if e.cteTableExists(ref.Name) {
+			continue
+		}
+		if _, ok := e.catalogRelationFor(*ref); !ok {
+			return false
+		}
+		for join := ref.Join; join != nil && join.Table != nil; join = join.Table.Join {
+			if e.cteTableExists(join.Table.Name) {
+				continue
+			}
+			if _, ok := e.catalogRelationFor(*join.Table); !ok {
+				return false
+			}
+		}
+	}
+	return true
+}
+
+// catalogTableExists reports whether a durable table shadows an unqualified
+// catalog relation name. A scan that already holds the session lock cannot ask
+// the session, so the statement's pre-built snapshot answers instead; without
+// one the virtual catalog relation is assumed, which makes the catalog path
+// report the locked-scan guard error instead of deadlocking.
+func (e *Executor) catalogTableExists(name string) bool {
+	if e.lockedScanDepth > 0 {
+		if e.pgCatalog == nil {
+			return false
+		}
+		_, ok := e.pgCatalog.tableByName[strings.ToLower(name)]
+		return ok
+	}
+	return e.session.TableExists(name)
+}
+
+func (s *catalogSnapshot) informationSchemaViewsRows() []storage.Row {
+	// A session view is listed here exactly when it is listed in pg_class with
+	// relkind 'v'. The engine does not retain the view's SQL text, so the
+	// definition is NULL rather than invented.
+	var rows []storage.Row
+	for _, relation := range s.relations {
+		if relation.kind != 'v' || relation.virtual {
+			continue
+		}
+		rows = append(rows, storage.Row{
+			"table_catalog":              s.database,
+			"table_schema":               relation.schemaName,
+			"table_name":                 relation.name,
+			"view_definition":            nil,
+			"check_option":               "NONE",
+			"is_updatable":               "NO",
+			"is_insertable_into":         "NO",
+			"is_trigger_updatable":       "NO",
+			"is_trigger_deletable":       "NO",
+			"is_trigger_insertable_into": "NO",
+		})
+	}
+	return rows
+}
+
+// ---------------------------------------------------------------------------
+// Function/relation helpers used by the executor's expression evaluator
+
+// indexDefinition renders CREATE INDEX statement text, used by pg_indexes and
+// pg_get_indexdef.
+func (s *catalogSnapshot) indexDefinition(index *storage.Index) string {
+	var builder strings.Builder
+	builder.WriteString("CREATE ")
+	if index.Unique {
+		builder.WriteString("UNIQUE ")
+	}
+	builder.WriteString("INDEX ")
+	builder.WriteString(index.Name)
+	builder.WriteString(" ON ")
+	builder.WriteString(publicSchemaName)
+	builder.WriteByte('.')
+	builder.WriteString(index.Table)
+	builder.WriteString(" USING btree (")
+	for i, column := range index.Columns {
+		if i > 0 {
+			builder.WriteString(", ")
+		}
+		if column.Expression != "" {
+			builder.WriteString("(")
+			builder.WriteString(column.Expression)
+			builder.WriteString(")")
+			continue
+		}
+		builder.WriteString(column.Name)
+		if column.Desc {
+			builder.WriteString(" DESC")
+		}
+	}
+	builder.WriteString(")")
+	return builder.String()
+}
+
+func (s *catalogSnapshot) indexDefinitionForOID(oid int64) (string, bool) {
+	relation, ok := s.relationByOID[oid]
+	if !ok || relation.kind != 'i' || relation.index == nil {
+		return "", false
+	}
+	return s.indexDefinition(relation.index), true
+}
+
+func (s *catalogSnapshot) constraintDefinition(constraint *catalogConstraint) string {
+	switch constraint.contype {
+	case 'p':
+		return "PRIMARY KEY (" + strings.Join(constraint.columns, ", ") + ")"
+	case 'u':
+		return "UNIQUE (" + strings.Join(constraint.columns, ", ") + ")"
+	case 'f':
+		if constraint.fk == nil {
+			return ""
+		}
+		clause := "FOREIGN KEY (" + strings.Join(constraint.fk.Columns, ", ") + ") REFERENCES " + constraint.fk.RefTable + "(" + strings.Join(constraint.fk.RefColumns, ", ") + ")"
+		if constraint.fk.OnDelete != "" {
+			clause += " ON DELETE " + strings.ToUpper(constraint.fk.OnDelete)
+		}
+		if constraint.fk.OnUpdate != "" {
+			clause += " ON UPDATE " + strings.ToUpper(constraint.fk.OnUpdate)
+		}
+		return clause
+	case 'c':
+		if constraint.check == nil {
+			return ""
+		}
+		return "CHECK ((" + constraint.check.Expression + "))"
+	default:
+		return ""
+	}
+}
+
+func (s *catalogSnapshot) constraintDefinitionForOID(oid int64) (string, bool) {
+	constraint, ok := s.constraintByOID[oid]
+	if !ok {
+		return "", false
+	}
+	return s.constraintDefinition(constraint), true
+}
+
+// formatType renders a type OID the way format_type() does. Precision modifiers
+// are not persisted by the engine, so callers pass -1.
+func (s *catalogSnapshot) formatType(oid int64, typmod int64) (string, bool) {
+	builtin, ok := pgBuiltinTypeByOID[oid]
+	if !ok {
+		return "???", false
+	}
+	name := builtin.sqlName
+	if typmod < 0 {
+		return name, true
+	}
+	switch oid {
+	case 1043, 1042: // varchar, bpchar
+		return fmt.Sprintf("%s(%d)", name, typmod-4), true
+	case 1700: // numeric
+		modifier := typmod - 4
+		precision := (modifier >> 16) & 0xffff
+		scale := modifier & 0xffff
+		return fmt.Sprintf("numeric(%d,%d)", precision, scale), true
+	default:
+		return name, true
+	}
+}
+
+// typeOIDForName resolves a regtype literal.
+func (s *catalogSnapshot) typeOIDForName(name string) (int64, bool) {
+	name = strings.TrimSpace(name)
+	name = strings.Trim(name, `"`)
+	upper := strings.ToUpper(name)
+	if strings.HasSuffix(upper, "[]") || strings.HasSuffix(upper, " ARRAY") {
+		elementName := strings.TrimSuffix(strings.TrimSuffix(upper, " ARRAY"), "[]")
+		element := pgTypeForDeclaredName(elementName)
+		if array, ok := pgBuiltinTypeByOID[element.array]; ok && element.array != 0 {
+			return array.oid, true
+		}
+		return 0, false
+	}
+	if !knownDeclaredTypeName(upper) {
+		if builtin, ok := pgBuiltinTypeByName[strings.ToLower(name)]; ok {
+			return builtin.oid, true
+		}
+		return 0, false
+	}
+	return pgTypeForDeclaredName(upper).oid, true
+}
+
+// knownDeclaredTypeName reports whether a type spelling maps to a built-in.
+func knownDeclaredTypeName(upper string) bool {
+	switch upper {
+	case "INTEGER", "INT", "INT4", "BIGINT", "INT8", "SMALLINT", "INT2", "TINYINT", "MEDIUMINT",
+		"TEXT", "CLOB", "NCHAR", "NVARCHAR", "VARCHAR", "CHARACTER VARYING", "CHAR", "CHARACTER",
+		"BOOLEAN", "BOOL", "REAL", "FLOAT", "FLOAT4", "DOUBLE", "DOUBLE PRECISION", "FLOAT8",
+		"NUMERIC", "DECIMAL", "JSONB", "JSON", "BLOB", "BYTEA", "DATETIME", "TIMESTAMP",
+		"TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITHOUT TIME ZONE", "DATE",
+		"TIME", "TIME WITHOUT TIME ZONE", "TIME WITH TIME ZONE", "UUID", "OID", "NAME",
+		"REGCLASS", "REGTYPE":
+		return true
+	default:
+		return false
+	}
+}
+
+func (s *catalogSnapshot) typeNameForOID(oid int64) (string, bool) {
+	builtin, ok := pgBuiltinTypeByOID[oid]
+	if !ok {
+		return "", false
+	}
+	return builtin.sqlName, true
+}
+
+func (s *catalogSnapshot) relationVisible(oid int64) bool {
+	relation, ok := s.relationByOID[oid]
+	if !ok {
+		return false
+	}
+	switch relation.schemaName {
+	case publicSchemaName, pgCatalogSchemaName, informationSchemaName:
+		return true
+	default:
+		return false
+	}
+}
+
+// numericArg converts a function argument that denotes an OID.
+func numericArg(value interface{}) (int64, bool) {
+	switch v := value.(type) {
+	case RegOID:
+		return v.oid, true
+	case int64:
+		return v, true
+	case int:
+		return int64(v), true
+	case float64:
+		return int64(v), true
+	case string:
+		parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
+		if err != nil {
+			return 0, false
+		}
+		return parsed, true
+	default:
+		return 0, false
+	}
+}
+
+// RegOID is the value of a regclass or regtype cast. Numeric contexts use the
+// OID; text output renders the resolved name, matching PostgreSQL's symbolic
+// type rendering.
+type RegOID struct {
+	oid  int64
+	name string
+}
+
+// OID returns the numeric OID the value carries.
+func (r RegOID) OID() int64 { return r.oid }
+
+// String renders the symbolic name.
+func (r RegOID) String() string { return r.name }
+
+// evalCatalogFunction answers the PostgreSQL catalog-introspection builtins.
+// handled is false for every other function so evalFunctionCall keeps them.
+func (e *Executor) evalCatalogFunction(name string, fn *parser.FunctionCall, args []interface{}) (interface{}, bool, error) {
+	switch name {
+	case "CURRENT_SCHEMA":
+		return e.currentSchemaName(), true, nil
+	case "CURRENT_DATABASE":
+		if e.schema == nil {
+			return "pizzasql", true, nil
+		}
+		database := e.schema.GetDatabaseName()
+		if database == "" {
+			database = "pizzasql"
+		}
+		return database, true, nil
+	case "PG_GET_SERIAL_SEQUENCE", "OBJ_DESCRIPTION", "COL_DESCRIPTION":
+		// The engine has no sequences and stores no catalog comments.
+		return nil, true, nil
+	case "PG_GET_USERBYID":
+		return "pizzasql", true, nil
+	case "CURRENT_SETTING":
+		if len(args) == 0 || args[0] == nil {
+			return nil, true, nil
+		}
+		value, err := e.settingValue(toString(args[0]))
+		if err != nil {
+			return nil, true, err
+		}
+		return value, true, nil
+	case "PG_GET_EXPR":
+		if len(args) == 0 || args[0] == nil {
+			return nil, true, nil
+		}
+		if text, ok := args[0].(string); ok {
+			return text, true, nil
+		}
+		return toString(args[0]), true, nil
+	}
+
+	switch name {
+	case "FORMAT_TYPE", "PG_TABLE_IS_VISIBLE", "PG_GET_INDEXDEF", "PG_GET_CONSTRAINTDEF", "HAS_TABLE_PRIVILEGE", "TO_REGCLASS", "TO_REGTYPE":
+	default:
+		return nil, false, nil
+	}
+
+	snapshot, err := e.catalogSnapshotForQuery()
+	if err != nil {
+		return nil, true, err
+	}
+
+	switch name {
+	case "TO_REGCLASS", "TO_REGTYPE":
+		// PostgreSQL's to_regclass/to_regtype return NULL for a name that does
+		// not exist instead of failing the statement.
+		if len(args) == 0 || args[0] == nil {
+			return nil, true, nil
+		}
+		if reg, ok := args[0].(RegOID); ok {
+			return reg, true, nil
+		}
+		text, ok := args[0].(string)
+		if !ok {
+			return nil, true, pgerror.InvalidText("%s requires a text argument", strings.ToLower(name))
+		}
+		var oid int64
+		var found bool
+		if name == "TO_REGTYPE" {
+			oid, found = snapshot.typeOIDForName(text)
+		} else {
+			oid, found = snapshot.regclassOID(text)
+		}
+		if !found {
+			return nil, true, nil
+		}
+		var resolved string
+		if name == "TO_REGTYPE" {
+			resolved, _ = snapshot.typeNameForOID(oid)
+		} else {
+			resolved, _ = snapshot.regclassName(oid)
+		}
+		return RegOID{oid: oid, name: resolved}, true, nil
+	case "FORMAT_TYPE":
+		if len(args) == 0 {
+			return nil, true, pgerror.Unsupported("format_type requires a type OID")
+		}
+		oid, ok := numericArg(args[0])
+		if !ok {
+			return nil, true, pgerror.InvalidText("format_type requires a numeric type OID")
+		}
+		typmod := int64(-1)
+		if len(args) > 1 && args[1] != nil {
+			if value, ok := numericArg(args[1]); ok {
+				typmod = value
+			}
+		}
+		rendered, _ := snapshot.formatType(oid, typmod)
+		return rendered, true, nil
+	case "PG_TABLE_IS_VISIBLE":
+		if len(args) == 0 || args[0] == nil {
+			return nil, true, nil
+		}
+		oid, ok := numericArg(args[0])
+		if !ok {
+			return false, true, nil
+		}
+		return snapshot.relationVisible(oid), true, nil
+	case "PG_GET_INDEXDEF":
+		if len(args) == 0 || args[0] == nil {
+			return nil, true, nil
+		}
+		oid, ok := numericArg(args[0])
+		if !ok {
+			return nil, true, pgerror.InvalidText("pg_get_indexdef requires an index OID")
+		}
+		definition, found := snapshot.indexDefinitionForOID(oid)
+		if !found {
+			return nil, true, nil
+		}
+		return definition, true, nil
+	case "PG_GET_CONSTRAINTDEF":
+		if len(args) == 0 || args[0] == nil {
+			return nil, true, nil
+		}
+		oid, ok := numericArg(args[0])
+		if !ok {
+			return nil, true, pgerror.InvalidText("pg_get_constraintdef requires a constraint OID")
+		}
+		definition, found := snapshot.constraintDefinitionForOID(oid)
+		if !found {
+			return nil, true, nil
+		}
+		return definition, true, nil
+	case "HAS_TABLE_PRIVILEGE":
+		tableArg := interface{}(nil)
+		switch len(args) {
+		case 2:
+			tableArg = args[0]
+		case 3:
+			tableArg = args[1]
+		default:
+			return false, true, nil
+		}
+		if tableArg == nil {
+			return nil, true, nil
+		}
+		if oid, ok := numericArg(tableArg); ok {
+			return snapshot.relationVisible(oid), true, nil
+		}
+		_, ok := snapshot.regclassOID(toString(tableArg))
+		return ok, true, nil
+	default:
+		return nil, false, nil
+	}
+}
+
+// currentSchemaName reports the first schema in the effective search path. The
+// engine only materializes public, pg_catalog, and information_schema, so a
+// search path naming anything else falls back to public.
+func (e *Executor) currentSchemaName() string {
+	path := e.effectiveSearchPath()
+	if len(path) > 0 && path[0] != "" {
+		return path[0]
+	}
+	return publicSchemaName
+}
+
+// catalogCast handles regclass/regtype casts and text casts of their values.
+func (e *Executor) catalogCast(expr *parser.CastExpr, value interface{}) (interface{}, bool, error) {
+	typeName := strings.ToUpper(strings.TrimSpace(expr.Type.Name))
+	switch typeName {
+	case "REGCLASS", "REGTYPE":
+		snapshot, err := e.catalogSnapshotForQuery()
+		if err != nil {
+			return nil, true, err
+		}
+		if reg, ok := value.(RegOID); ok {
+			return reg, true, nil
+		}
+		if text, ok := value.(string); ok {
+			if typeName == "REGTYPE" {
+				oid, found := snapshot.typeOIDForName(text)
+				if !found {
+					return nil, true, pgerror.UndefinedTable("type %q does not exist", text)
+				}
+				name, _ := snapshot.typeNameForOID(oid)
+				return RegOID{oid: oid, name: name}, true, nil
+			}
+			oid, found := snapshot.regclassOID(text)
+			if !found {
+				return nil, true, pgerror.UndefinedTable("relation %q does not exist", text)
+			}
+			name, _ := snapshot.regclassName(oid)
+			return RegOID{oid: oid, name: name}, true, nil
+		}
+		oid, ok := numericArg(value)
+		if !ok {
+			return nil, true, pgerror.InvalidText("cannot cast %T to %s", value, typeName)
+		}
+		var name string
+		if typeName == "REGTYPE" {
+			name, _ = snapshot.typeNameForOID(oid)
+		} else {
+			name, _ = snapshot.regclassName(oid)
+		}
+		if name == "" {
+			name = strconv.FormatInt(oid, 10)
+		}
+		return RegOID{oid: oid, name: name}, true, nil
+	case "TEXT", "VARCHAR", "CHAR", "CHARACTER", "CLOB", "NCHAR", "NVARCHAR":
+		if reg, ok := value.(RegOID); ok {
+			return reg.name, true, nil
+		}
+	case "OID":
+		if reg, ok := value.(RegOID); ok {
+			return reg.oid, true, nil
+		}
+	}
+	return nil, false, nil
+}
+
+// isCatalogWriteTarget reports a DML/DDL statement aimed at a virtual relation.
+func (e *Executor) rejectCatalogWriteTarget(stmt parser.Statement) error {
+	var refs []parser.TableRef
+	switch s := stmt.(type) {
+	case *parser.InsertStmt:
+		if s.Table != nil {
+			refs = append(refs, *s.Table)
+		}
+	case *parser.UpdateStmt:
+		if s.Table != nil {
+			refs = append(refs, *s.Table)
+		}
+	case *parser.DeleteStmt:
+		if s.Table != nil {
+			refs = append(refs, *s.Table)
+		}
+	case *parser.CreateTableStmt:
+		if s.Table != nil {
+			refs = append(refs, *s.Table)
+		}
+	case *parser.CreateIndexStmt:
+		refs = append(refs, parser.TableRef{Name: s.Table})
+	case *parser.AlterTableStmt:
+		refs = append(refs, parser.TableRef{Name: s.Table})
+	case *parser.CreateViewStmt:
+		if s.View != nil {
+			refs = append(refs, *s.View)
+		}
+	case *parser.DropTableStmt:
+		for _, table := range s.Tables {
+			if table != nil {
+				refs = append(refs, *table)
+			}
+		}
+	}
+	for _, ref := range refs {
+		if _, ok := e.catalogRelationFor(ref); !ok {
+			continue
+		}
+		schemaName := strings.ToLower(ref.Schema)
+		if schemaName == "" || schemaName == publicSchemaName {
+			schemaName = pgCatalogSchemaName
+		}
+		return pgerror.Unsupported("%s.%s is a read-only virtual relation; catalog relations are synthesized from the durable schema and cannot be written", schemaName, strings.ToLower(ref.Name))
+	}
+	return nil
+}
+
+// ---------------------------------------------------------------------------
+// SELECT over virtual catalog relations
+
+// catalogSelectRelation is the column metadata of one FROM entry.
+type catalogSelectRelation struct {
+	qualifier string
+	columns   []storage.Column
+}
+
+// catalogFlatRefs flattens a FROM clause, including JOIN chains, preserving the
+// schema qualifier of every entry.
+func catalogFlatRefs(from []parser.TableRef) []parser.TableRef {
+	var refs []parser.TableRef
+	for _, ref := range from {
+		cur := ref
+		for {
+			flat := cur
+			flat.Join = nil
+			flat.Subquery = cur.Subquery
+			flat.Function = cur.Function
+			refs = append(refs, flat)
+			if cur.Join == nil || cur.Join.Table == nil {
+				break
+			}
+			cur = *cur.Join.Table
+		}
+	}
+	return refs
+}
+
+func catalogRefQualifier(ref parser.TableRef) string {
+	if ref.Alias != "" {
+		return ref.Alias
+	}
+	return ref.Name
+}
+
+// catalogSelectMetadata resolves the column list of every FROM relation so
+// SELECT * and table.* expand, and so projection types stay accurate.
+func (e *Executor) catalogSelectMetadata(from []parser.TableRef) ([]catalogSelectRelation, error) {
+	var relations []catalogSelectRelation
+	for _, ref := range catalogFlatRefs(from) {
+		if ref.Subquery != nil || ref.Function != nil {
+			continue
+		}
+		var columns []storage.Column
+		if entry, ok := e.catalogRelationFor(ref); ok {
+			columns = entry.schema.Columns
+		} else if cte, ok := e.cteTableFor(ref.Name); ok {
+			columns = schemaFromColumns(cte.columns).Columns
+		} else {
+			schema, err := e.session.GetSchema(ref.Name)
+			if err != nil {
+				return nil, err
+			}
+			columns = visibleCatalogColumns(schema)
+		}
+		relations = append(relations, catalogSelectRelation{qualifier: catalogRefQualifier(ref), columns: columns})
+	}
+	return relations, nil
+}
+
+// materializeCatalogTableRef reads one FROM entry. Catalog relations come from
+// the statement's virtual snapshot; everything else goes through the ordinary
+// materializer so a catalog query can still join a real table or read a
+// derived table.
+func (e *Executor) materializeCatalogTableRef(ref parser.TableRef) ([]storage.Row, error) {
+	if entry, ok := e.catalogRelationFor(ref); ok {
+		snapshot, err := e.catalogSnapshotForQuery()
+		if err != nil {
+			return nil, err
+		}
+		rows := cloneRows(snapshot.relationRows(entry.def.schema + "." + entry.def.name))
+		alias := catalogRefQualifier(ref)
+		for i := range rows {
+			rows[i] = e.addTableAlias(rows[i], alias)
+		}
+		return rows, nil
+	}
+	if cte, ok := e.cteTableFor(ref.Name); ok {
+		rows := cloneRows(cte.rows)
+		alias := catalogRefQualifier(ref)
+		for i := range rows {
+			rows[i] = e.addTableAlias(rows[i], alias)
+		}
+		return rows, nil
+	}
+	return e.materializeTableRef(ref)
+}
+
+// materializeCatalogFrom materializes the whole FROM list, applying each
+// entry's JOIN chain and cross-joining comma-separated entries.
+func (e *Executor) materializeCatalogFrom(from []parser.TableRef) ([]storage.Row, error) {
+	rows, err := e.materializeCatalogTableRef(from[0])
+	if err != nil {
+		return nil, err
+	}
+	if from[0].Join != nil {
+		rows, err = e.executeJoins(from[0], rows)
+		if err != nil {
+			return nil, err
+		}
+	}
+	for _, ref := range from[1:] {
+		right, err := e.materializeCatalogTableRef(ref)
+		if err != nil {
+			return nil, err
+		}
+		if ref.Join != nil {
+			right, err = e.executeJoins(ref, right)
+			if err != nil {
+				return nil, err
+			}
+		}
+		joined := make([]storage.Row, 0, len(rows)*len(right))
+		for _, leftRow := range rows {
+			for _, rightRow := range right {
+				combined := make(storage.Row, len(leftRow)+len(rightRow))
+				for key, value := range leftRow {
+					combined[key] = value
+				}
+				for key, value := range rightRow {
+					combined[key] = value
+				}
+				joined = append(joined, combined)
+			}
+		}
+		rows = joined
+	}
+	return rows, nil
+}
+
+// expandCatalogTableStars rewrites table.* projections into explicit column
+// references so the shared materialized projection can read qualified keys.
+func expandCatalogTableStars(stmt *parser.SelectStmt, relations []catalogSelectRelation) (*parser.SelectStmt, error) {
+	expanded := *stmt
+	if len(stmt.Columns) == 0 {
+		return &expanded, nil
+	}
+	columns := make([]parser.SelectColumn, 0, len(stmt.Columns))
+	for _, projection := range stmt.Columns {
+		if projection.TableStar == "" {
+			columns = append(columns, projection)
+			continue
+		}
+		var matched *catalogSelectRelation
+		for i := range relations {
+			if strings.EqualFold(relations[i].qualifier, projection.TableStar) {
+				matched = &relations[i]
+				break
+			}
+		}
+		if matched == nil {
+			return nil, fmt.Errorf("cannot expand %s.*: relation columns are not available", projection.TableStar)
+		}
+		for _, column := range matched.columns {
+			columns = append(columns, parser.SelectColumn{
+				Expr:  &parser.ColumnRef{Table: matched.qualifier, Column: column.Name},
+				Alias: column.Name,
+			})
+		}
+	}
+	expanded.Columns = columns
+	return &expanded, nil
+}
+
+func catalogJoinKeyParts(key string) (string, string) {
+	if dot := strings.LastIndexByte(key, '.'); dot >= 0 {
+		return key[:dot], key[dot+1:]
+	}
+	return "", key
+}
+
+// catalogProjectionTypes computes result types parallel to the projection. The
+// resolver follows starKeys, so both qualified and unqualified references keep
+// the declared catalog type (OID, NAME, BOOL, ...) instead of falling back to
+// TEXT over the wire.
+func catalogProjectionTypes(stmt *parser.SelectStmt, starKeys, types []string) []string {
+	resolve := func(table, column string) (string, bool) {
+		for i, key := range starKeys {
+			qualifier, name := catalogJoinKeyParts(key)
+			if !strings.EqualFold(name, column) {
+				continue
+			}
+			if table != "" && !strings.EqualFold(table, qualifier) {
+				continue
+			}
+			if i < len(types) && types[i] != "" {
+				return types[i], true
+			}
+			return "", true
+		}
+		return "", false
+	}
+	result := make([]string, 0, len(stmt.Columns))
+	for _, projection := range stmt.Columns {
+		if projection.Star {
+			result = append(result, types...)
+			continue
+		}
+		result = append(result, inferExprType(projection.Expr, resolve))
+	}
+	return result
+}
+
+// executeSelectOnCatalogRelations answers a SELECT whose FROM clause reads at
+// least one virtual relation. Rows are materialized from the session's schema
+// view, so staged transactional DDL is visible to the issuing connection only.
+func (e *Executor) executeSelectOnCatalogRelations(stmt *parser.SelectStmt) (*Result, error) {
+	relations, err := e.catalogSelectMetadata(stmt.From)
+	if err != nil {
+		return nil, err
+	}
+	expanded, err := expandCatalogTableStars(stmt, relations)
+	if err != nil {
+		return nil, err
+	}
+
+	rows, err := e.materializeCatalogFrom(stmt.From)
+	if err != nil {
+		return nil, err
+	}
+
+	var columns, starKeys, types, qualifiers []string
+	for _, relation := range relations {
+		for _, column := range relation.columns {
+			columns = append(columns, column.Name)
+			starKeys = append(starKeys, relation.qualifier+"."+column.Name)
+			types = append(types, column.Type)
+			qualifiers = append(qualifiers, relation.qualifier)
+		}
+	}
+
+	// The JOIN chain is already applied by materializeCatalogFrom; hand the
+	// shared materialized path rows without a pending join.
+	tail := *expanded
+	tailFrom := make([]parser.TableRef, len(expanded.From))
+	copy(tailFrom, expanded.From)
+	tailFrom[0].Join = nil
+	tail.From = tailFrom
+
+	e.pushTypeScope(materializedTypeResolver(columns, types, qualifiers))
+	result, err := e.executeSelectOnMaterializedRows(&tail, columns, starKeys, rows)
+	e.popTypeScope()
+	if err != nil {
+		return nil, err
+	}
+	result.ColumnTypes = catalogProjectionTypes(&tail, starKeys, types)
+	if tail.Distinct {
+		result.Rows = e.applyDistinct(result.Rows)
+	}
+	return result, nil
+}

+ 280 - 0
pkg/executor/pg_catalog_lock_test.go

@@ -0,0 +1,280 @@
+package executor
+
+import (
+	"errors"
+	"fmt"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// A catalog-backed predicate must be resolved before a statement takes the
+// session lock. Evaluating it inside the locked scan re-enters the session lock
+// (which is not re-entrant), so the statement hangs forever and every other
+// session blocks on the table the stuck statement never releases.
+//
+// These regressions therefore run every statement under a bounded timeout and
+// probe a second session afterwards.
+const catalogLockTimeout = 10 * time.Second
+
+type catalogLockOutcome struct {
+	result *Result
+	err    error
+}
+
+// runCatalogLockedStatement executes one statement and fails the test when it
+// does not finish within catalogLockTimeout.
+func runCatalogLockedStatement(t *testing.T, e *Executor, sql string) (*Result, error) {
+	t.Helper()
+	done := make(chan catalogLockOutcome, 1)
+	go func() {
+		result, err := execSQL(e, sql)
+		done <- catalogLockOutcome{result: result, err: err}
+	}()
+	select {
+	case out := <-done:
+		return out.result, out.err
+	case <-time.After(catalogLockTimeout):
+		buf := make([]byte, 1<<20)
+		n := runtime.Stack(buf, true)
+		t.Fatalf("%s did not finish within %s (deadlock)\n%s", sql, catalogLockTimeout, buf[:n])
+		return nil, nil
+	}
+}
+
+// probeSessionLiveness proves a second connection can still read and write: a
+// statement that deadlocks while holding the session lock leaks the table gate,
+// so a later session's SELECT or INSERT blocks forever.
+func probeSessionLiveness(t *testing.T, schema *storage.SchemaManager, table *storage.TableManager, tableName string) {
+	t.Helper()
+	observer := newExec(schema, table)
+	if _, err := runCatalogLockedStatement(t, observer, "SELECT count(*) FROM "+tableName); err != nil {
+		t.Fatalf("observer SELECT on %s failed: %v", tableName, err)
+	}
+	if _, err := runCatalogLockedStatement(t, observer, "INSERT INTO probe (id) VALUES (1)"); err != nil {
+		t.Fatalf("observer INSERT failed: %v", err)
+	}
+}
+
+// setupCatalogLockFixture builds the target table plus an unrelated table the
+// observer session writes to.
+func setupCatalogLockFixture(t *testing.T) (*Executor, *storage.SchemaManager, *storage.TableManager) {
+	t.Helper()
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a'), (2, 'b')")
+	execMust(t, e, "CREATE TABLE other (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO other VALUES (1)")
+	execMust(t, e, "CREATE TABLE probe (id INTEGER PRIMARY KEY)")
+	return e, schema, table
+}
+
+func TestPGCatalogPredicateInLockedScanDoesNotDeadlock(t *testing.T) {
+	cases := []struct {
+		name      string
+		sql       string
+		wantError bool
+		wantRows  int
+		wantValue string
+	}{
+		{
+			name:      "update unknown regclass",
+			sql:       `UPDATE t SET v = 'z' WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`,
+			wantError: true,
+		},
+		{
+			name:     "update known regclass",
+			sql:      `UPDATE t SET v = 'z' WHERE v = 'a' AND 't'::regclass IS NOT NULL`,
+			wantRows: 1,
+		},
+		{
+			name:      "delete unknown regclass",
+			sql:       `DELETE FROM t WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`,
+			wantError: true,
+		},
+		{
+			name:     "update catalog function predicate",
+			sql:      `UPDATE t SET v = 'z' WHERE v = 'a' AND format_type(23, -1) = 'integer'`,
+			wantRows: 1,
+		},
+		{
+			name:     "update in catalog subquery",
+			sql:      `UPDATE t SET v = 'z' WHERE id IN (SELECT oid FROM pg_class)`,
+			wantRows: 0,
+		},
+		{
+			name:     "update in constant subquery",
+			sql:      `UPDATE t SET v = 'z' WHERE id IN (SELECT 1)`,
+			wantRows: 1,
+		},
+		{
+			name:     "update in base-table subquery",
+			sql:      `UPDATE t SET v = 'z' WHERE id IN (SELECT id FROM other)`,
+			wantRows: 1,
+		},
+		{
+			name:     "update returning regclass",
+			sql:      `UPDATE t SET v = 'z' WHERE id = 1 RETURNING id, 't'::regclass`,
+			wantRows: 1,
+		},
+		{
+			name:      "update set catalog function",
+			sql:       `UPDATE t SET v = 'fmt:' || format_type(23, -1) WHERE id = 1`,
+			wantValue: "fmt:integer",
+		},
+		{
+			name:     "update correlated catalog subquery",
+			sql:      `UPDATE t SET v = 'z' WHERE EXISTS (SELECT 1 FROM pg_class c WHERE c.relname = t.v)`,
+			wantRows: 0,
+		},
+		{
+			name:     "update catalog relation predicate",
+			sql:      `UPDATE t SET v = 'z' WHERE id IN (SELECT c.oid FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid)`,
+			wantRows: 0,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			e, schema, table := setupCatalogLockFixture(t)
+			_, err := runCatalogLockedStatement(t, e, tc.sql)
+			if tc.wantError {
+				if !errors.Is(err, pgerror.ErrUndefinedTable) {
+					t.Fatalf("%s: err = %v, want an undefined_table error", tc.sql, err)
+				}
+			} else if err != nil {
+				t.Fatalf("%s: %v", tc.sql, err)
+			} else if tc.wantRows > 0 {
+				res, countErr := runCatalogLockedStatement(t, e, `SELECT count(*) FROM t WHERE v = 'z'`)
+				if countErr != nil {
+					t.Fatal(countErr)
+				}
+				if res.Rows[0][0] != int64(tc.wantRows) {
+					t.Fatalf("%s: updated rows = %v, want %d", tc.sql, res.Rows[0][0], tc.wantRows)
+				}
+			} else if tc.wantValue != "" {
+				res, valueErr := runCatalogLockedStatement(t, e, `SELECT v FROM t WHERE id = 1`)
+				if valueErr != nil {
+					t.Fatal(valueErr)
+				}
+				if fmt.Sprintf("%v", res.Rows[0][0]) != tc.wantValue {
+					t.Fatalf("%s: v = %v, want %q", tc.sql, res.Rows[0][0], tc.wantValue)
+				}
+			}
+			probeSessionLiveness(t, schema, table, "t")
+		})
+	}
+}
+
+// A predicate that fails part-way through the scan must not leave rows written
+// by the rows the scan already visited.
+func TestPGCatalogPredicateErrorLeavesNoPartialWrite(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	// No UNIQUE index: nothing else makes the statement atomic for it.
+	execMust(t, e, "CREATE TABLE plain (id INTEGER, v TEXT)")
+	execMust(t, e, "INSERT INTO plain VALUES (1, 'one'), (2, 'two'), (3, 'three')")
+	execMust(t, e, "CREATE TABLE probe (id INTEGER PRIMARY KEY)")
+
+	// The predicate succeeds for rows 1 and 3 and fails on row 2, so a
+	// non-atomic update would leave rows 1 and 3 changed.
+	_, err := runCatalogLockedStatement(t, e, `UPDATE plain SET v = 'changed' WHERE id <> 2 OR 'missing_relation'::regclass IS NOT NULL`)
+	if !errors.Is(err, pgerror.ErrUndefinedTable) {
+		t.Fatalf("update err = %v, want an undefined_table error", err)
+	}
+	res := execMust(t, e, `SELECT count(*) FROM plain WHERE v = 'changed'`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("partial write: %v rows changed", res.Rows[0][0])
+	}
+
+	_, err = runCatalogLockedStatement(t, e, `DELETE FROM plain WHERE id <> 2 OR 'missing_relation'::regclass IS NOT NULL`)
+	if !errors.Is(err, pgerror.ErrUndefinedTable) {
+		t.Fatalf("delete err = %v, want an undefined_table error", err)
+	}
+	res = execMust(t, e, `SELECT count(*) FROM plain`)
+	if res.Rows[0][0] != int64(3) {
+		t.Fatalf("partial delete: %v rows left, want 3", res.Rows[0][0])
+	}
+	probeSessionLiveness(t, schema, table, "plain")
+}
+
+// The same deadlock class applies to any scan that runs inside an open
+// transaction, including a SELECT.
+func TestPGCatalogPredicateInExplicitTransactionDoesNotDeadlock(t *testing.T) {
+	e, schema, table := setupCatalogLockFixture(t)
+
+	if _, err := runCatalogLockedStatement(t, e, "BEGIN"); err != nil {
+		t.Fatalf("BEGIN: %v", err)
+	}
+	_, err := runCatalogLockedStatement(t, e, `SELECT * FROM t WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`)
+	if !errors.Is(err, pgerror.ErrUndefinedTable) {
+		t.Fatalf("select err = %v, want an undefined_table error", err)
+	}
+	_, err = runCatalogLockedStatement(t, e, `UPDATE t SET v = 'z' WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`)
+	if !errors.Is(err, pgerror.ErrUndefinedTable) {
+		t.Fatalf("update err = %v, want an undefined_table error", err)
+	}
+	// A catalog relation in a predicate still works inside the transaction.
+	res, err := runCatalogLockedStatement(t, e, `SELECT count(*) FROM t WHERE id IN (SELECT 1)`)
+	if err != nil {
+		t.Fatalf("catalog-free predicate: %v", err)
+	}
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("count = %v, want 1", res.Rows[0][0])
+	}
+	if _, err := runCatalogLockedStatement(t, e, "ROLLBACK"); err != nil {
+		t.Fatalf("ROLLBACK: %v", err)
+	}
+
+	// The transaction held no lock after the rollback.
+	probeSessionLiveness(t, schema, table, "t")
+
+	// A catalog predicate that matches still updates inside a transaction.
+	if _, err := runCatalogLockedStatement(t, e, "BEGIN"); err != nil {
+		t.Fatalf("BEGIN: %v", err)
+	}
+	if _, err := runCatalogLockedStatement(t, e, `UPDATE t SET v = 'z' WHERE id = 2 AND 't'::regclass IS NOT NULL`); err != nil {
+		t.Fatalf("update in transaction: %v", err)
+	}
+	if _, err := runCatalogLockedStatement(t, e, "COMMIT"); err != nil {
+		t.Fatalf("COMMIT: %v", err)
+	}
+	res = execMust(t, e, `SELECT v FROM t WHERE id = 2`)
+	if fmt.Sprintf("%v", res.Rows[0][0]) != "z" {
+		t.Fatalf("committed value = %v, want z", res.Rows[0][0])
+	}
+}
+
+// A correlated subquery in a write predicate cannot read the durable schema while
+// the scan holds the session lock. It must report that (0A000) instead of
+// deadlocking, and the failed statement must leave no partial write and no lock.
+func TestPGCatalogCorrelatedPredicateErrorsInsteadOfDeadlocking(t *testing.T) {
+	e, schema, table := setupCatalogLockFixture(t)
+	_, err := runCatalogLockedStatement(t, e, `UPDATE t SET v = 'z' WHERE EXISTS (SELECT 1 FROM other o WHERE o.id = t.id)`)
+	if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("err = %v, want a feature_not_supported error", err)
+	}
+	res := execMust(t, e, `SELECT count(*) FROM t WHERE v = 'z'`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("partial write: %v rows changed", res.Rows[0][0])
+	}
+	probeSessionLiveness(t, schema, table, "t")
+}
+
+// A catalog-only correlated subquery resolves from the statement snapshot, so it
+// still works inside the locked scan.
+func TestPGCatalogCorrelatedSubqueryResolvesFromSnapshot(t *testing.T) {
+	e, schema, table := setupCatalogLockFixture(t)
+	if _, err := runCatalogLockedStatement(t, e, `UPDATE t SET v = 'pg_class' WHERE EXISTS (SELECT 1 FROM pg_class c WHERE c.relname = 'pg_class')`); err != nil {
+		t.Fatalf("catalog-only correlated subquery: %v", err)
+	}
+	res := execMust(t, e, `SELECT count(*) FROM t WHERE v = 'pg_class'`)
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("updated rows = %v, want 2", res.Rows[0][0])
+	}
+	probeSessionLiveness(t, schema, table, "t")
+}

+ 776 - 0
pkg/executor/pg_catalog_test.go

@@ -0,0 +1,776 @@
+package executor
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+// setupPGCatalogFixture creates a schema that exercises every catalog-backed
+// object shape: single and composite primary keys, UNIQUE, FOREIGN KEY, CHECK,
+// explicit indexes, arrays, JSONB, defaults, and a view.
+func setupPGCatalogFixture(t *testing.T) *Executor {
+	t.Helper()
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	for _, sql := range []string{
+		"CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)",
+		"CREATE TABLE orders (id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL, note TEXT DEFAULT 'new', tags TEXT[], meta JSONB, CHECK (id > 0), CONSTRAINT fk_orders_account FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE)",
+		"CREATE INDEX idx_orders_note ON orders (note)",
+		"CREATE TABLE pairs (a TEXT, b TEXT, v TEXT, PRIMARY KEY (a, b))",
+		"CREATE VIEW active_orders AS SELECT id, note FROM orders",
+	} {
+		execMust(t, e, sql)
+	}
+	return e
+}
+
+func catalogCell(t *testing.T, res *Result, row, col int) string {
+	t.Helper()
+	if row >= len(res.Rows) || col >= len(res.Rows[row]) {
+		t.Fatalf("row %d column %d out of range: %v", row, col, res.Rows)
+	}
+	value := res.Rows[row][col]
+	if value == nil {
+		return "<nil>"
+	}
+	return fmt.Sprintf("%v", value)
+}
+
+func catalogRows(t *testing.T, e *Executor, query string) [][]string {
+	t.Helper()
+	res, err := execSQL(e, query)
+	if err != nil {
+		t.Fatalf("%s: %v", query, err)
+	}
+	rows := make([][]string, len(res.Rows))
+	for i := range res.Rows {
+		rows[i] = make([]string, len(res.Rows[i]))
+		for j, value := range res.Rows[i] {
+			if value == nil {
+				rows[i][j] = "<nil>"
+				continue
+			}
+			rows[i][j] = fmt.Sprintf("%v", value)
+		}
+	}
+	return rows
+}
+
+// A PostgreSQL-style column listing joins pg_class, pg_namespace, and
+// pg_attribute; the synthetic OIDs must line up across all three.
+func TestPGCatalogJoinListsColumns(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT n.nspname, c.relname, a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), a.attnotnull
+		FROM pg_catalog.pg_class c
+		JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+		JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
+		WHERE c.relname = 'orders' AND n.nspname = 'public' AND a.attnum > 0 AND NOT a.attisdropped
+		ORDER BY a.attnum`)
+	want := [][]string{
+		{"public", "orders", "id", "integer", "true"},
+		{"public", "orders", "account_id", "integer", "true"},
+		{"public", "orders", "note", "text", "false"},
+		{"public", "orders", "tags", "text[]", "false"},
+		{"public", "orders", "meta", "jsonb", "false"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("columns = %v, want %v", rows, want)
+	}
+}
+
+func TestPGCatalogInformationSchemaColumns(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT table_name, column_name, data_type, is_nullable, udt_name, ordinal_position
+		FROM information_schema.columns
+		WHERE table_name = 'orders'
+		ORDER BY ordinal_position`)
+	want := [][]string{
+		{"orders", "id", "integer", "NO", "int4", "1"},
+		{"orders", "account_id", "integer", "NO", "int4", "2"},
+		{"orders", "note", "text", "YES", "text", "3"},
+		{"orders", "tags", "ARRAY", "YES", "_text", "4"},
+		{"orders", "meta", "jsonb", "YES", "jsonb", "5"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("information_schema.columns = %v, want %v", rows, want)
+	}
+
+	// Schema names are case-insensitive, as in PostgreSQL.
+	res := execMust(t, e, `SELECT count(*) FROM INFORMATION_SCHEMA.TABLES WHERE table_name = 'orders'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("uppercase information schema lookup = %v, want 1", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, `
+		SELECT count(*) FROM information_schema.tables
+		WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`)
+	if res.Rows[0][0] != int64(3) {
+		t.Fatalf("base table count = %v, want 3", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT count(*) FROM information_schema.tables WHERE table_name = 'active_orders' AND table_type = 'VIEW'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("view count = %v, want 1", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT count(*) FROM information_schema.sequences`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("sequence count = %v, want 0", res.Rows[0][0])
+	}
+}
+
+func TestPGCatalogIndexRelations(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT c.relname, i.indisunique, i.indisprimary, i.indkey
+		FROM pg_catalog.pg_index i
+		JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
+		JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
+		WHERE t.relname = 'orders'
+		ORDER BY c.relname`)
+	want := [][]string{
+		{"idx_orders_note", "false", "false", "{3}"},
+		{"orders_pkey", "true", "true", "{1}"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("pg_index = %v, want %v", rows, want)
+	}
+
+	rows = catalogRows(t, e, `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'orders' ORDER BY indexname`)
+	if len(rows) != 2 {
+		t.Fatalf("pg_indexes rows = %v, want 2", rows)
+	}
+	if !strings.Contains(rows[0][1], "CREATE INDEX idx_orders_note ON public.orders USING btree (note)") {
+		t.Fatalf("indexdef = %q", rows[0][1])
+	}
+	if !strings.Contains(rows[1][1], "CREATE UNIQUE INDEX orders_pkey ON public.orders USING btree (id)") {
+		t.Fatalf("indexdef = %q", rows[1][1])
+	}
+
+	// Composite keys keep declaration order in pg_index.indkey.
+	rows = catalogRows(t, e, `
+		SELECT i.indkey FROM pg_index i
+		JOIN pg_class c ON c.oid = i.indexrelid
+		WHERE c.relname = 'pairs_pkey'`)
+	if len(rows) != 1 || rows[0][0] != "{1,2}" {
+		t.Fatalf("composite indkey = %v, want {1,2}", rows)
+	}
+}
+
+func TestPGCatalogConstraints(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT conname, contype, conkey, confkey, pg_get_constraintdef(oid)
+		FROM pg_constraint
+		WHERE conrelid = 'orders'::regclass
+		ORDER BY conname`)
+	want := [][]string{
+		{"fk_orders_account", "f", "{2}", "{1}", "FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE"},
+		{"orders_check", "c", "<nil>", "<nil>", "CHECK (((id > 0)))"},
+		{"orders_pkey", "p", "{1}", "<nil>", "PRIMARY KEY (id)"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("pg_constraint = %v, want %v", rows, want)
+	}
+
+	rows = catalogRows(t, e, `SELECT conname, contype FROM pg_constraint WHERE conrelid = 'accounts'::regclass ORDER BY conname`)
+	want = [][]string{
+		{"accounts_pkey", "p"},
+		{"uniq_accounts_email", "u"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("account constraints = %v, want %v", rows, want)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT constraint_name, constraint_type FROM information_schema.table_constraints
+		WHERE table_name = 'orders' ORDER BY constraint_name`)
+	want = [][]string{
+		{"fk_orders_account", "FOREIGN KEY"},
+		{"orders_check", "CHECK"},
+		{"orders_pkey", "PRIMARY KEY"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("table_constraints = %v, want %v", rows, want)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT constraint_name, column_name, ordinal_position, position_in_unique_constraint
+		FROM information_schema.key_column_usage
+		WHERE table_name = 'orders' ORDER BY constraint_name, ordinal_position`)
+	want = [][]string{
+		{"fk_orders_account", "account_id", "1", "1"},
+		{"orders_pkey", "id", "1", "<nil>"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("key_column_usage = %v, want %v", rows, want)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT constraint_name, unique_constraint_name, delete_rule, update_rule
+		FROM information_schema.referential_constraints ORDER BY constraint_name`)
+	want = [][]string{{"fk_orders_account", "accounts_pkey", "CASCADE", "NO ACTION"}}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("referential_constraints = %v, want %v", rows, want)
+	}
+}
+
+// regclass is symbolic in PostgreSQL: a name literal resolves to the same OID
+// pg_class reports, and rendering the OID yields the name again.
+func TestPGCatalogRegclassRoundTrip(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT c.relname, c.oid, 'orders'::regclass, c.oid::regclass
+		FROM pg_class c WHERE c.relname = 'orders'`)
+	if len(rows) != 1 {
+		t.Fatalf("rows = %v", rows)
+	}
+	if rows[0][0] != "orders" || rows[0][2] != "orders" || rows[0][3] != "orders" {
+		t.Fatalf("regclass rendering = %v", rows)
+	}
+
+	// Text output of a regclass value is the relation name, as in PostgreSQL;
+	// ::oid exposes the numeric OID for comparisons.
+	rows = catalogRows(t, e, `
+		SELECT 'orders'::regclass::text,
+		       'orders'::regclass::oid = (SELECT oid FROM pg_class WHERE relname = 'orders'),
+		       (SELECT oid FROM pg_class WHERE relname = 'orders') = 'orders'::regclass`)
+	if rows[0][0] != "orders" || rows[0][1] != "true" || rows[0][2] != "true" {
+		t.Fatalf("regclass text/comparison = %v", rows)
+	}
+
+	// regtype resolves the built-in type table.
+	rows = catalogRows(t, e, `SELECT 'integer'::regtype, 'integer'::regtype = 'int4'::regtype`)
+	if rows[0][0] != "integer" || rows[0][1] != "true" {
+		t.Fatalf("regtype = %v", rows)
+	}
+
+	if _, err := execSQL(e, `SELECT 'missing_relation'::regclass`); err == nil {
+		t.Fatal("unknown relation cast must fail")
+	}
+}
+
+func TestPGCatalogIntrospectionFunctions(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT current_schema(), current_database(),
+		       pg_table_is_visible('orders'::regclass),
+		       has_table_privilege('orders', 'SELECT'),
+		       has_table_privilege('missing_table', 'SELECT')`)
+	if rows[0][0] != "public" || rows[0][1] != "testdb" || rows[0][2] != "true" || rows[0][3] != "true" || rows[0][4] != "false" {
+		t.Fatalf("identity/privilege functions = %v", rows)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT pg_get_serial_sequence('orders', 'id'),
+		       obj_description('orders'::regclass, 'pg_class'),
+		       col_description('orders'::regclass, 1)`)
+	if len(rows) != 1 || rows[0][0] != "<nil>" || rows[0][1] != "<nil>" || rows[0][2] != "<nil>" {
+		t.Fatalf("description functions = %v, want NULLs", rows)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT pg_get_expr(d.adbin, d.adrelid)
+		FROM pg_attrdef d WHERE d.adrelid = 'orders'::regclass`)
+	if len(rows) != 1 || rows[0][0] != "'new'" {
+		t.Fatalf("pg_get_expr = %v, want the stored default text", rows)
+	}
+
+	rows = catalogRows(t, e, `SELECT pg_catalog.version(), current_setting('search_path')`)
+	if !strings.Contains(rows[0][0], "PostgreSQL") || rows[0][1] != "public" {
+		t.Fatalf("version/current_setting = %v", rows)
+	}
+
+	rows = catalogRows(t, e, `SELECT format_type(23, -1), format_type(1043, 14), format_type(999999, -1)`)
+	if rows[0][0] != "integer" || rows[0][1] != "character varying(10)" || rows[0][2] != "???" {
+		t.Fatalf("format_type = %v", rows)
+	}
+}
+
+// A psql-style column listing combines the catalog relations with LEFT JOINs;
+// it must return one row per live attribute and mark the primary key.
+func TestPGCatalogPsqlStyleDescribe(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT a.attname,
+		       pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
+		       a.attnotnull AS notnull,
+		       pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS dflt,
+		       i.indisprimary AS is_pk
+		FROM pg_catalog.pg_class c
+		JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+		JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
+		LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
+		LEFT JOIN pg_catalog.pg_index i ON i.indrelid = c.oid AND a.attnum = ANY(i.indkey)
+		WHERE c.relname = 'orders' AND c.relkind = 'r' AND n.nspname = 'public'
+		  AND a.attnum > 0 AND NOT a.attisdropped
+		ORDER BY a.attnum`)
+	want := [][]string{
+		{"id", "integer", "true", "<nil>", "true"},
+		{"account_id", "integer", "true", "<nil>", "<nil>"},
+		{"note", "text", "false", "'new'", "false"},
+		{"tags", "text[]", "false", "<nil>", "<nil>"},
+		{"meta", "jsonb", "false", "<nil>", "<nil>"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("psql-style describe = %v, want %v", rows, want)
+	}
+}
+
+// Catalog rows reflect DDL immediately, and transactional DDL is visible only
+// to the connection that staged it.
+func TestPGCatalogTransactionalDDLVisibility(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	writer := newExec(schema, table)
+	observer := newExec(schema, table)
+
+	execMust(t, writer, "CREATE TABLE base (id INTEGER PRIMARY KEY)")
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "CREATE TABLE staged (id INTEGER PRIMARY KEY)")
+	execMust(t, writer, "ALTER TABLE base ADD COLUMN note TEXT")
+	execMust(t, writer, "CREATE INDEX idx_base_note ON base (note)")
+
+	res := execMust(t, writer, `SELECT table_name FROM information_schema.tables WHERE table_name = 'staged'`)
+	if len(res.Rows) != 1 {
+		t.Fatalf("writer does not see staged table: %v", res.Rows)
+	}
+	res = execMust(t, writer, `
+		SELECT column_name FROM information_schema.columns
+		WHERE table_name = 'base' ORDER BY ordinal_position`)
+	if len(res.Rows) != 2 || res.Rows[1][0] != "note" {
+		t.Fatalf("writer does not see staged column: %v", res.Rows)
+	}
+	res = execMust(t, writer, `SELECT count(*) FROM pg_indexes WHERE tablename = 'base' AND indexname = 'idx_base_note'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("writer does not see staged index: %v", res.Rows)
+	}
+
+	res = execMust(t, observer, `SELECT count(*) FROM information_schema.tables WHERE table_name = 'staged'`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("observer sees staged table: %v", res.Rows)
+	}
+	res = execMust(t, observer, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'base'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("observer sees staged column: %v", res.Rows)
+	}
+
+	execMust(t, writer, "ROLLBACK")
+	res = execMust(t, writer, `SELECT count(*) FROM information_schema.tables WHERE table_name = 'staged'`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("rolled-back table still visible: %v", res.Rows)
+	}
+	res = execMust(t, writer, `SELECT count(*) FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid WHERE c.relname = 'base'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("rolled-back column still visible: %v", res.Rows)
+	}
+}
+
+// OIDs are deterministic per database, so two connections and repeated
+// statements agree.
+func TestPGCatalogOIDsAreDeterministic(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	first := newExec(schema, table)
+	execMust(t, first, "CREATE TABLE deterministic (id INTEGER PRIMARY KEY, name TEXT)")
+	query := `SELECT oid, relname FROM pg_class WHERE relname = 'deterministic'`
+	one := catalogRows(t, first, query)
+	two := catalogRows(t, first, query)
+	if fmt.Sprint(one) != fmt.Sprint(two) {
+		t.Fatalf("OID changed between statements: %v vs %v", one, two)
+	}
+	second := newExec(schema, table)
+	three := catalogRows(t, second, query)
+	if fmt.Sprint(one) != fmt.Sprint(three) {
+		t.Fatalf("OID differs across connections: %v vs %v", one, three)
+	}
+	if len(one) != 1 || one[0][1] != "deterministic" {
+		t.Fatalf("unexpected relation row: %v", one)
+	}
+}
+
+func TestPGCatalogRejectsWrites(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	for _, sql := range []string{
+		`INSERT INTO pg_catalog.pg_class VALUES (1)`,
+		`INSERT INTO information_schema.tables VALUES ('x')`,
+		`UPDATE pg_catalog.pg_type SET typname = 'x'`,
+		`DELETE FROM pg_catalog.pg_attribute`,
+		`CREATE INDEX idx_pg_class_name ON pg_class (relname)`,
+		`ALTER TABLE pg_class ADD COLUMN nope TEXT`,
+		`DROP TABLE pg_catalog.pg_class`,
+		`CREATE VIEW pg_class AS SELECT 1`,
+	} {
+		_, err := execSQL(e, sql)
+		if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Fatalf("%s: err = %v, want 0A000 feature_not_supported", sql, err)
+		}
+	}
+}
+
+func TestPGSearchPathSettings(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	for _, sql := range []string{
+		"SET search_path TO public",
+		"SET search_path = public",
+		"SET client_encoding TO 'UTF8'",
+		"SET TIME ZONE 'UTC'",
+		"SET statement_timeout = 0",
+		"SET NAMES 'utf8'",
+		"SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED",
+	} {
+		if _, err := execSQL(e, sql); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	res := execMust(t, e, "SHOW search_path")
+	if len(res.Rows) != 1 || res.Rows[0][0] != "public" {
+		t.Fatalf("SHOW search_path = %v", res.Rows)
+	}
+	res = execMust(t, e, "SHOW server_version")
+	if len(res.Rows) != 1 || res.Rows[0][0] == "" {
+		t.Fatalf("SHOW server_version = %v", res.Rows)
+	}
+	if _, err := execSQL(e, "SHOW definitely_not_a_setting"); err == nil {
+		t.Fatal("unknown SHOW setting must fail")
+	}
+}
+
+// The SQLite introspection surface keeps working alongside the PostgreSQL
+// catalogs; sqlite_master must not list the virtual relations.
+func TestPGCatalogDoesNotChangeSQLiteIntrospection(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	res := execMust(t, e, `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
+	names := make([]string, len(res.Rows))
+	for i, row := range res.Rows {
+		names[i] = fmt.Sprintf("%v", row[0])
+	}
+	if strings.Join(names, ",") != "accounts,orders,pairs" {
+		t.Fatalf("sqlite_master tables = %v", names)
+	}
+	res = execMust(t, e, `SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name`)
+	if len(res.Rows) != 2 {
+		t.Fatalf("sqlite_master indexes = %v", res.Rows)
+	}
+	res = execMust(t, e, `PRAGMA table_info(orders)`)
+	if len(res.Rows) != 5 {
+		t.Fatalf("PRAGMA table_info rows = %v", res.Rows)
+	}
+}
+
+// An index may share its table's name. pg_class then holds two rows with the
+// same relname, so relkind is part of the relation identity: the rows keep
+// distinct OIDs and pg_index/pg_attribute stay consistent with each of them.
+func TestPGCatalogIndexSharingTableName(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE shared (id INTEGER PRIMARY KEY, label TEXT)")
+	execMust(t, e, "CREATE INDEX shared ON shared (label)")
+
+	rows := catalogRows(t, e, `SELECT relkind, oid FROM pg_class WHERE relname = 'shared' ORDER BY relkind`)
+	if len(rows) != 2 {
+		t.Fatalf("pg_class rows for shared = %v, want a table and an index", rows)
+	}
+	if rows[0][0] != "i" || rows[1][0] != "r" {
+		t.Fatalf("pg_class row kinds = %v, want i then r", rows)
+	}
+	if rows[0][1] == rows[1][1] {
+		t.Fatalf("table and index share OID %v", rows[0][1])
+	}
+
+	// pg_index must point the index row at the table row, not at itself.
+	rows = catalogRows(t, e, `
+		SELECT c.relname, i.indexrelid, i.indrelid, i.indrelid = t.oid
+		FROM pg_index i
+		JOIN pg_class c ON c.oid = i.indexrelid
+		JOIN pg_class t ON t.oid = i.indrelid
+		WHERE c.relname = 'shared'`)
+	if len(rows) != 1 || rows[0][0] != "shared" || rows[0][1] == rows[0][2] || rows[0][3] != "true" {
+		t.Fatalf("pg_index row = %v, want indexrelid != indrelid = t.oid", rows)
+	}
+
+	// The table's attributes belong to exactly one relation, so the index row
+	// must not double them.
+	res := execMust(t, e, `
+		SELECT count(*) FROM pg_attribute a
+		JOIN pg_class c ON c.oid = a.attrelid
+		WHERE c.relname = 'shared' AND c.relkind = 'r' AND a.attnum > 0`)
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("pg_attribute rows for the shared table = %v, want 2", res.Rows[0][0])
+	}
+
+	// regclass resolution prefers the table when the names collide.
+	res = execMust(t, e, `SELECT 'shared'::regclass::oid = (SELECT oid FROM pg_class WHERE relname = 'shared' AND relkind = 'r')`)
+	if res.Rows[0][0] != true {
+		t.Fatalf("regclass did not resolve to the table: %v", res.Rows)
+	}
+}
+
+// A durable table named <x>_pkey occupies the synthetic primary-key index name,
+// so the synthesized index must pick a free name instead of colliding with it.
+func TestPGCatalogSyntheticPrimaryKeyNameCollision(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE owner (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE owner_pkey (id INTEGER PRIMARY KEY)")
+
+	rows := catalogRows(t, e, `SELECT relname, relkind FROM pg_class WHERE relname LIKE 'owner%' ORDER BY relname, relkind`)
+	want := [][]string{
+		{"owner", "r"},
+		{"owner_pkey", "r"},
+		{"owner_pkey2", "i"},
+		{"owner_pkey_pkey", "i"},
+	}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("pg_class rows = %v, want %v", rows, want)
+	}
+
+	// The primary key constraint still points at the renamed synthetic index.
+	rows = catalogRows(t, e, `
+		SELECT con.conname, c.relname
+		FROM pg_constraint con
+		JOIN pg_class c ON c.oid = con.conindid
+		WHERE con.conrelid = 'owner'::regclass AND con.contype = 'p'`)
+	if len(rows) != 1 || rows[0][1] != "owner_pkey2" {
+		t.Fatalf("primary key index = %v, want owner_pkey2", rows)
+	}
+
+	// No name is reported twice with the same relkind.
+	res := execMust(t, e, `
+		SELECT count(*) FROM (
+			SELECT relname, relkind, count(*) AS n FROM pg_class GROUP BY relname, relkind
+		) d WHERE d.n > 1`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("pg_class contains duplicate relname/relkind rows: %v", res.Rows)
+	}
+}
+
+// An unknown regclass or regtype reports undefined_table (42P01) instead of an
+// internal error, and it fails the statement even inside a WHERE clause.
+// to_regclass/to_regtype are the NULL-returning variants.
+func TestPGCatalogUndefinedRelationError(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	for _, sql := range []string{
+		`SELECT 'missing_relation'::regclass`,
+		`SELECT 'missing_type'::regtype`,
+		`SELECT count(*) FROM pg_class WHERE oid = 'missing_relation'::regclass`,
+		`SELECT count(*) FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid WHERE a.attrelid = 'missing_relation'::regclass`,
+		// A constant predicate that fails is a statement error even when the
+		// table has no rows to evaluate it against.
+		`SELECT count(*) FROM orders WHERE 'missing_relation'::regclass IS NOT NULL`,
+	} {
+		_, err := execSQL(e, sql)
+		if !errors.Is(err, pgerror.ErrUndefinedTable) {
+			t.Fatalf("%s: err = %v, want an undefined_table error", sql, err)
+		}
+	}
+
+	// A failing predicate in a join is a statement error too, not a filter that
+	// silently returns 0 rows.
+	execMust(t, e, "INSERT INTO pairs (a, b, v) VALUES ('x', 'y', 'z')")
+	for _, sql := range []string{
+		`SELECT * FROM pairs p1 JOIN pairs p2 ON true WHERE 'missing_relation'::regclass IS NOT NULL`,
+		`SELECT * FROM pairs p1, pairs p2 WHERE 'missing_relation'::regclass IS NOT NULL`,
+	} {
+		_, err := execSQL(e, sql)
+		if !errors.Is(err, pgerror.ErrUndefinedTable) {
+			t.Fatalf("%s: err = %v, want an undefined_table error", sql, err)
+		}
+	}
+
+	rows := catalogRows(t, e, `SELECT to_regclass('orders'), to_regclass('missing_relation'), to_regtype('missing_type')`)
+	if len(rows) != 1 || rows[0][0] != "orders" || rows[0][1] != "<nil>" || rows[0][2] != "<nil>" {
+		t.Fatalf("to_regclass/to_regtype = %v", rows)
+	}
+	res := execMust(t, e, `
+		SELECT to_regclass('orders')::oid = (SELECT oid FROM pg_class WHERE relname = 'orders' AND relkind = 'r')`)
+	if res.Rows[0][0] != true {
+		t.Fatalf("to_regclass OID = %v, want the pg_class OID", res.Rows)
+	}
+	res = execMust(t, e, `SELECT to_regclass('orders')`)
+	if fmt.Sprint(res.ColumnTypes) != "[REGCLASS]" {
+		t.Fatalf("to_regclass result type = %v, want REGCLASS", res.ColumnTypes)
+	}
+}
+
+// A CTE shadows a virtual catalog relation of the same name, exactly as a CTE
+// shadows a table.
+func TestPGCatalogCTEShadowsVirtualRelation(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `WITH pg_attribute AS (SELECT 7 AS attnum) SELECT * FROM pg_attribute`)
+	if len(rows) != 1 || rows[0][0] != "7" {
+		t.Fatalf("non-recursive CTE did not shadow pg_attribute: %v", rows)
+	}
+
+	rows = catalogRows(t, e, `
+		WITH RECURSIVE pg_class(oid) AS (SELECT 42 UNION ALL SELECT oid + 1 FROM pg_class WHERE oid < 44)
+		SELECT * FROM pg_class`)
+	if len(rows) != 3 || rows[0][0] != "42" || rows[2][0] != "44" {
+		t.Fatalf("recursive CTE did not shadow pg_class: %v", rows)
+	}
+
+	res := execMust(t, e, `
+		WITH pg_attribute(attname) AS (SELECT 'shadowed')
+		SELECT count(*) FROM pg_catalog.pg_attribute`)
+	if res.Rows[0][0] == int64(1) {
+		t.Fatalf("CTE shadowed an explicitly qualified catalog relation")
+	}
+}
+
+// A schema qualifier only resolves inside the schema it names: public.pg_class
+// is not pg_catalog.pg_class, so it is an undefined table.
+func TestPGCatalogQualifiedNameStaysInItsSchema(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	for _, sql := range []string{
+		`SELECT count(*) FROM public.pg_class`,
+		`SELECT count(*) FROM public.pg_attribute`,
+	} {
+		_, err := execSQL(e, sql)
+		if !errors.Is(err, pgerror.ErrUndefinedTable) {
+			t.Fatalf("%s: err = %v, want an undefined_table error", sql, err)
+		}
+	}
+
+	// A real public table still resolves through the qualifier.
+	res := execMust(t, e, `SELECT count(*) FROM public.orders`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("public.orders rows = %v", res.Rows)
+	}
+	if res := execMust(t, e, `SELECT count(*) FROM pg_catalog.pg_class`); res.Rows[0][0] == int64(0) {
+		t.Fatal("pg_catalog.pg_class is empty")
+	}
+}
+
+// A view listed by information_schema is listed everywhere else with the same
+// shape: pg_class relkind 'v', its pg_attribute rows, and its columns.
+func TestPGCatalogViewShape(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+
+	rows := catalogRows(t, e, `SELECT relname, relkind, relnatts FROM pg_class WHERE relname = 'active_orders'`)
+	if len(rows) != 1 || rows[0][1] != "v" || rows[0][2] != "2" {
+		t.Fatalf("pg_class view row = %v", rows)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT a.attnum, a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod)
+		FROM pg_attribute a
+		JOIN pg_class c ON c.oid = a.attrelid
+		WHERE c.relname = 'active_orders' AND c.relkind = 'v' AND a.attnum > 0
+		ORDER BY a.attnum`)
+	want := [][]string{{"1", "id", "integer"}, {"2", "note", "text"}}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("view attributes = %v, want %v", rows, want)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT table_type, is_insertable_into FROM information_schema.tables WHERE table_name = 'active_orders'`)
+	if len(rows) != 1 || rows[0][0] != "VIEW" || rows[0][1] != "NO" {
+		t.Fatalf("information_schema.tables view row = %v", rows)
+	}
+
+	rows = catalogRows(t, e, `
+		SELECT column_name, data_type, ordinal_position
+		FROM information_schema.columns WHERE table_name = 'active_orders'
+		ORDER BY ordinal_position`)
+	want = [][]string{{"id", "integer", "1"}, {"note", "text", "2"}}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("information_schema.columns view rows = %v, want %v", rows, want)
+	}
+
+	res := execMust(t, e, `SELECT count(*) FROM information_schema.views WHERE table_name = 'active_orders'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("information_schema.views rows = %v, want 1", res.Rows[0][0])
+	}
+
+	// Every view information_schema lists has the same number of columns in
+	// pg_attribute and information_schema.columns.
+	res = execMust(t, e, `
+		SELECT count(*) FROM information_schema.tables t
+		WHERE t.table_type = 'VIEW' AND t.table_schema = 'public'
+		  AND (SELECT count(*) FROM information_schema.columns c
+		       WHERE c.table_name = t.table_name)
+		      <> (SELECT relnatts FROM pg_class c
+		          WHERE c.relname = t.table_name AND c.relkind = 'v')`)
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("a public view has an inconsistent catalog shape: %v", res.Rows[0][0])
+	}
+}
+
+// pg_type's function attributes are regproc columns holding 0, not the text "-".
+func TestPGCatalogTypeRegprocAttributes(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	rows := catalogRows(t, e, `
+		SELECT typname, typinput, typoutput, typreceive, typsend, typmodin, typanalyze
+		FROM pg_type WHERE typname = 'int4'`)
+	want := [][]string{{"int4", "0", "0", "0", "0", "0", "0"}}
+	if fmt.Sprint(rows) != fmt.Sprint(want) {
+		t.Fatalf("pg_type regproc attributes = %v, want %v", rows, want)
+	}
+
+	res := execMust(t, e, `SELECT typinput, typoutput FROM pg_type WHERE typname = 'text'`)
+	if fmt.Sprint(res.ColumnTypes) != "[REGPROC REGPROC]" {
+		t.Fatalf("pg_type regproc column types = %v", res.ColumnTypes)
+	}
+}
+
+// search_path follows PostgreSQL's transaction rules: SET LOCAL expires at
+// COMMIT (and on ROLLBACK TO SAVEPOINT), and a session SET inside an aborted
+// transaction is undone by ROLLBACK.
+func TestPGSearchPathTransactionScope(t *testing.T) {
+	e := setupPGCatalogFixture(t)
+	showPath := func() string {
+		t.Helper()
+		res := execMust(t, e, "SHOW search_path")
+		if len(res.Rows) != 1 {
+			t.Fatalf("SHOW search_path = %v", res.Rows)
+		}
+		return fmt.Sprintf("%v", res.Rows[0][0])
+	}
+
+	// SET LOCAL outside a transaction block has no effect.
+	execMust(t, e, "SET LOCAL search_path TO pg_catalog")
+	if got := showPath(); got != "public" {
+		t.Fatalf("search_path after SET LOCAL outside a transaction = %q", got)
+	}
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "SET LOCAL search_path TO pg_catalog")
+	if got := showPath(); got != "pg_catalog" {
+		t.Fatalf("search_path inside the transaction = %q", got)
+	}
+	if res := execMust(t, e, "SELECT current_schema()"); res.Rows[0][0] != "pg_catalog" {
+		t.Fatalf("current_schema() inside the transaction = %v", res.Rows[0][0])
+	}
+	execMust(t, e, "COMMIT")
+	if got := showPath(); got != "public" {
+		t.Fatalf("search_path after COMMIT = %q, want SET LOCAL to expire", got)
+	}
+
+	// A session SET is transactional: ROLLBACK restores the previous value,
+	// COMMIT keeps it.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "SET search_path TO pg_catalog")
+	execMust(t, e, "ROLLBACK")
+	if got := showPath(); got != "public" {
+		t.Fatalf("search_path after ROLLBACK = %q, want the pre-transaction value", got)
+	}
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "SET search_path TO pg_catalog")
+	execMust(t, e, "COMMIT")
+	if got := showPath(); got != "pg_catalog" {
+		t.Fatalf("search_path after COMMIT = %q, want the session SET to persist", got)
+	}
+	execMust(t, e, "SET search_path TO public")
+
+	// ROLLBACK TO SAVEPOINT undoes a SET LOCAL issued after the savepoint.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "SAVEPOINT sp")
+	execMust(t, e, "SET LOCAL search_path TO pg_catalog")
+	execMust(t, e, "ROLLBACK TO SAVEPOINT sp")
+	if got := showPath(); got != "public" {
+		t.Fatalf("search_path after ROLLBACK TO SAVEPOINT = %q", got)
+	}
+	execMust(t, e, "COMMIT")
+}

+ 18 - 0
pkg/executor/result_types.go

@@ -33,6 +33,12 @@ func (e *Executor) snapshotTypeResolver(refs []parser.TableRef) columnTypeResolv
 		if e.cteTableExists(ref.Name) {
 			continue
 		}
+		// A virtual catalog relation has no durable schema: its columns come from
+		// catalog metadata, and asking the session for them from inside a locked
+		// scan would deadlock on the session lock the scan already holds.
+		if _, ok := e.catalogRelationFor(ref); ok {
+			continue
+		}
 		schema, err := e.session.GetSchema(ref.Name)
 		if err != nil {
 			continue
@@ -387,6 +393,14 @@ func castResultType(name string) (string, bool) {
 		return "BLOB", true
 	case "JSONB":
 		return "JSONB", true
+	case "REGCLASS":
+		return "REGCLASS", true
+	case "REGTYPE":
+		return "REGTYPE", true
+	case "OID":
+		return "OID", true
+	case "NAME":
+		return "NAME", true
 	default:
 		return "", false
 	}
@@ -477,6 +491,10 @@ func functionResultType(fn *parser.FunctionCall, resolve columnTypeResolver) str
 		// These return one of the input values unchanged, so the result type
 		// follows the first argument (and therefore keeps JSONB).
 		return firstArgType(fn, resolve)
+	case "TO_REGCLASS":
+		return "REGCLASS"
+	case "TO_REGTYPE":
+		return "REGTYPE"
 	default:
 		return "TEXT"
 	}

+ 156 - 0
pkg/executor/session_settings.go

@@ -0,0 +1,156 @@
+package executor
+
+import (
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
+)
+
+// Session settings.
+//
+// Drivers and ORMs open a connection with SET statements for parameters that
+// have no PizzaSQL equivalent (client_encoding, extra_float_digits,
+// statement_timeout, ...). Rejecting them makes an unmodified client unusable,
+// so the engine acknowledges every SET and retains only search_path, which
+// current_schema() and SHOW report. The engine has no other configurable
+// settings; SHOW returns honest constants for the values it exposes.
+
+// normalizeSettingName lowercases a setting name and collapses whitespace so
+// SET TIME ZONE and SHOW time zone agree.
+func normalizeSettingName(name string) string {
+	return strings.ToLower(strings.Join(strings.Fields(name), " "))
+}
+
+// effectiveSearchPath is the search path the session currently observes: SET
+// LOCAL while a transaction is open, otherwise the session value.
+func (e *Executor) effectiveSearchPath() []string {
+	if len(e.localSearchPath) > 0 {
+		return e.localSearchPath
+	}
+	if len(e.searchPath) == 0 {
+		return []string{publicSchemaName}
+	}
+	return e.searchPath
+}
+
+// beginSettingScope records the session search path so ROLLBACK can restore it
+// and clears any SET LOCAL left by a previous transaction.
+func (e *Executor) beginSettingScope() {
+	e.txSearchPath = e.searchPath
+	e.localSearchPath = nil
+}
+
+// commitSettingScope ends a transaction: SET LOCAL expires and a session SET
+// stays in effect.
+func (e *Executor) commitSettingScope() {
+	e.localSearchPath = nil
+	e.txSearchPath = nil
+	e.pgCatalog = nil
+}
+
+// rollbackSettingScope restores the session search path captured at BEGIN, so a
+// session SET inside an aborted transaction has no lasting effect.
+func (e *Executor) rollbackSettingScope() {
+	if e.txSearchPath != nil {
+		e.searchPath = e.txSearchPath
+	}
+	e.localSearchPath = nil
+	e.txSearchPath = nil
+	e.pgCatalog = nil
+}
+
+// executeSet acknowledges a session setting.
+func (e *Executor) executeSet(stmt *parser.SetStmt) (*Result, error) {
+	name := normalizeSettingName(stmt.Name)
+	if name == "search_path" {
+		var entries []string
+		for _, value := range stmt.Values {
+			for _, part := range strings.Split(value, ",") {
+				part = strings.Trim(strings.TrimSpace(part), `"`)
+				if part != "" {
+					entries = append(entries, part)
+				}
+			}
+		}
+		// Only the emulated schemas exist; drop anything else so SHOW and
+		// current_schema() describe a schema the engine can actually resolve.
+		known := make([]string, 0, len(entries))
+		for _, entry := range entries {
+			switch strings.ToLower(entry) {
+			case publicSchemaName, pgCatalogSchemaName, informationSchemaName:
+				known = append(known, strings.ToLower(entry))
+			}
+		}
+		if len(known) == 0 {
+			known = []string{publicSchemaName}
+		}
+		switch {
+		case stmt.Local:
+			// SET LOCAL is transaction-scoped: it has no effect outside a
+			// transaction block and expires at COMMIT or ROLLBACK.
+			if e.inTransaction {
+				e.localSearchPath = known
+			}
+		default:
+			// A session SET inside a transaction is undone by ROLLBACK, so
+			// BEGIN keeps the previous value.
+			e.searchPath = known
+			e.localSearchPath = nil
+		}
+		// A different search path changes which schema is visible; drop the
+		// statement-scoped catalog snapshot so the next read re-evaluates it.
+		e.pgCatalog = nil
+	}
+	return NewResult("SET"), nil
+}
+
+// executeShow returns the value of a setting. Values that the engine does not
+// model are reported as constants that describe the actual behavior, and
+// unknown names fail rather than inventing a value.
+func (e *Executor) executeShow(stmt *parser.ShowStmt) (*Result, error) {
+	name := normalizeSettingName(stmt.Name)
+	value, err := e.settingValue(name)
+	if err != nil {
+		return nil, err
+	}
+
+	result := NewResult("SHOW")
+	result.AddColumnWithType(name, "TEXT")
+	result.AddRow(value)
+	return result, nil
+}
+
+// settingValue reports the value of a setting exposed by SHOW and
+// current_setting(). Unknown names are rejected rather than invented.
+func (e *Executor) settingValue(name string) (string, error) {
+	name = normalizeSettingName(name)
+	var value string
+	switch name {
+	case "search_path":
+		value = strings.Join(e.effectiveSearchPath(), ", ")
+	case "server_version":
+		value = version.PostgreSQLCompatVersion
+	case "server_version_num":
+		// PostgreSQL encodes major*10000 + minor*100.
+		value = "140000"
+	case "server_encoding", "client_encoding":
+		value = "UTF8"
+	case "transaction_isolation", "default_transaction_isolation":
+		value = "read committed"
+	case "standard_conforming_strings", "integer_datetimes":
+		value = "on"
+	case "datestyle":
+		value = "ISO, MDY"
+	case "timezone", "time zone":
+		value = "UTC"
+	case "application_name":
+		value = ""
+	case "is_superuser":
+		value = "off"
+	default:
+		return "", pgerror.Unsupported("unrecognized configuration parameter %q", name)
+	}
+	return value, nil
+}

+ 7 - 0
pkg/httpserver/handler.go

@@ -558,6 +558,13 @@ func sqlStateHTTPError(err error) *HTTPError {
 			Message: message,
 		}
 	}
+	if errors.Is(err, pgerror.ErrUndefinedTable) || (errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTableNotFound) {
+		return &HTTPError{
+			Status:  http.StatusNotFound,
+			Code:    pgerror.CodeUndefinedTable,
+			Message: message,
+		}
+	}
 	return &HTTPError{
 		Status:  http.StatusInternalServerError,
 		Code:    "EXECUTION_ERROR",

+ 2 - 0
pkg/httpserver/sqlstate_test.go

@@ -9,6 +9,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
@@ -134,6 +135,7 @@ func TestSQLStateHTTPErrorMapping(t *testing.T) {
 		{"typed primary key", &storage.PrimaryKeyViolationError{Table: "t", Key: "1"}, 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"},
+		{"undefined table", pgerror.UndefinedTable("relation %q does not exist", "missing"), http.StatusNotFound, "42P01"},
 		{"other", errors.New("boom"), http.StatusInternalServerError, "EXECUTION_ERROR"},
 	}
 	for _, tt := range tests {

+ 22 - 0
pkg/parser/ast.go

@@ -532,6 +532,28 @@ type NotifyStmt struct {
 func (s *NotifyStmt) node()     {}
 func (s *NotifyStmt) stmtNode() {}
 
+// SetStmt represents a PostgreSQL SET statement. Drivers and ORMs issue these
+// for session configuration; PizzaSQL accepts them for compatibility and only
+// gives search_path observable behavior. Name is the normalized setting name
+// (for example "search_path" or "time zone") and Values holds the unquoted
+// value tokens in declaration order.
+type SetStmt struct {
+	Name   string
+	Values []string
+	Local  bool
+}
+
+func (s *SetStmt) node()     {}
+func (s *SetStmt) stmtNode() {}
+
+// ShowStmt represents a PostgreSQL SHOW statement for a single setting.
+type ShowStmt struct {
+	Name string
+}
+
+func (s *ShowStmt) node()     {}
+func (s *ShowStmt) stmtNode() {}
+
 // Expression types
 
 // BinaryExpr represents a binary expression.

+ 11 - 8
pkg/parser/cte.go

@@ -270,14 +270,17 @@ func substituteTableRefCTEs(ref *TableRef, ctes []*cteDef) error {
 		if err := substituteSelectCTEs(ref.Subquery, ctes); err != nil {
 			return err
 		}
-	} else if cte := lookupCTE(ref.Name, ctes); cte != nil {
-		alias := ref.Alias
-		if alias == "" {
-			alias = cte.name
-		}
-		ref.Subquery = cte.query
-		ref.Name = ""
-		ref.Alias = alias
+	} else if ref.Schema == "" {
+		// A schema-qualified name names a real relation, never a CTE.
+		if cte := lookupCTE(ref.Name, ctes); cte != nil {
+			alias := ref.Alias
+			if alias == "" {
+				alias = cte.name
+			}
+			ref.Subquery = cte.query
+			ref.Name = ""
+			ref.Alias = alias
+		}
 	}
 	if ref.Join != nil {
 		return substituteJoinCTEs(ref.Join, ctes)

+ 100 - 1
pkg/parser/parser.go

@@ -144,6 +144,8 @@ func (p *Parser) parseStatement() (Statement, error) {
 			return p.parseUnlisten()
 		case "NOTIFY":
 			return p.parseNotify()
+		case "SHOW":
+			return p.parseShow()
 		}
 	}
 	switch p.curToken.Type {
@@ -167,6 +169,8 @@ func (p *Parser) parseStatement() (Statement, error) {
 		return p.parseDetach()
 	case lexer.TokenPRAGMA:
 		return p.parsePragma()
+	case lexer.TokenSET:
+		return p.parseSet()
 	case lexer.TokenANALYZE:
 		return p.parseAnalyze()
 	case lexer.TokenEXPLAIN:
@@ -1348,7 +1352,11 @@ func (p *Parser) tryParseGeneratedColumn(col *ColumnDef) (bool, error) {
 // to their canonical data type name. These are stored with SQLite text
 // affinity and never gain native PostgreSQL semantics.
 var identTypeAliases = map[string]string{
-	"UUID": "UUID",
+	"UUID":     "UUID",
+	"OID":      "OID",
+	"REGCLASS": "REGCLASS",
+	"REGTYPE":  "REGTYPE",
+	"NAME":     "NAME",
 }
 
 func (p *Parser) parseDataType() (*DataType, error) {
@@ -2249,6 +2257,91 @@ func (p *Parser) parsePragma() (*PragmaStmt, error) {
 	return stmt, nil
 }
 
+// parseSet parses a PostgreSQL SET statement. Drivers and ORMs issue session
+// settings that have no effect on a PizzaSQL connection; the parser accepts the
+// setting name and values so the executor can acknowledge them. Only
+// search_path is retained, because current_schema() and pg_table_is_visible()
+// report it.
+func (p *Parser) parseSet() (*SetStmt, error) {
+	stmt := &SetStmt{}
+	p.nextToken() // consume SET
+
+	// SESSION CHARACTERISTICS AS TRANSACTION ... is a session-wide statement
+	// whose "SESSION" is not a scope modifier.
+	sessionCharacteristics := p.curTokenIs(lexer.TokenIdent) && strings.EqualFold(p.curToken.Literal, "SESSION") &&
+		p.peekTokenIs(lexer.TokenIdent) && strings.EqualFold(p.peekToken.Literal, "CHARACTERISTICS")
+	if !sessionCharacteristics && p.curTokenIs(lexer.TokenIdent) {
+		switch strings.ToUpper(p.curToken.Literal) {
+		case "LOCAL":
+			stmt.Local = true
+			p.nextToken()
+		case "SESSION":
+			p.nextToken()
+		}
+	}
+
+	var name strings.Builder
+	for p.isSettingNameToken() {
+		if name.Len() > 0 {
+			name.WriteByte(' ')
+		}
+		name.WriteString(p.curToken.Literal)
+		p.nextToken()
+	}
+	if name.Len() == 0 {
+		return nil, p.curError("expected setting name after SET")
+	}
+	stmt.Name = name.String()
+
+	if p.curTokenIs(lexer.TokenTO) || p.curTokenIs(lexer.TokenEq) {
+		p.nextToken()
+	}
+
+	// The remaining tokens are the setting value. They are retained as text for
+	// search_path and otherwise ignored, so forms such as SET TIME ZONE 'UTC',
+	// SET NAMES 'utf8', and SET SESSION CHARACTERISTICS AS TRANSACTION
+	// ISOLATION LEVEL READ COMMITTED parse without a value grammar.
+	for !p.curTokenIs(lexer.TokenSemicolon) && !p.curTokenIs(lexer.TokenEOF) {
+		switch p.curToken.Type {
+		case lexer.TokenIdent, lexer.TokenNumber, lexer.TokenString:
+			stmt.Values = append(stmt.Values, p.curToken.Literal)
+		}
+		p.nextToken()
+	}
+	return stmt, nil
+}
+
+// parseShow parses SHOW name. SHOW ALL is not supported because the engine has
+// no settings catalog.
+func (p *Parser) parseShow() (*ShowStmt, error) {
+	p.nextToken() // consume SHOW
+	if !p.isSettingNameToken() {
+		return nil, p.curError("expected setting name after SHOW")
+	}
+	var name strings.Builder
+	for p.isSettingNameToken() {
+		if name.Len() > 0 && p.curTokenIs(lexer.TokenIdent) {
+			name.WriteByte(' ')
+		}
+		name.WriteString(p.curToken.Literal)
+		p.nextToken()
+	}
+	return &ShowStmt{Name: name.String()}, nil
+}
+
+// isSettingNameToken reports whether the current token can be part of a SET or
+// SHOW setting name. PostgreSQL setting names may contain non-reserved words
+// that the lexer promotes to type keywords (TIME ZONE), so every token with
+// literal text qualifies except the TO/= punctuation that starts the value.
+func (p *Parser) isSettingNameToken() bool {
+	switch p.curToken.Type {
+	case lexer.TokenTO, lexer.TokenEq, lexer.TokenComma, lexer.TokenSemicolon,
+		lexer.TokenEOF, lexer.TokenLParen, lexer.TokenRParen:
+		return false
+	}
+	return p.curToken.Literal != ""
+}
+
 // parseAnalyze parses ANALYZE [schema.]table. PizzaSQL does not maintain
 // optimizer statistics, so the statement is accepted and ignored at execution.
 func (p *Parser) parseAnalyze() (*AnalyzeStmt, error) {
@@ -3085,6 +3178,12 @@ func (p *Parser) parseIdentOrFunction() (Expr, error) {
 			return nil, p.curError("expected column name after dot")
 		}
 		col := p.curToken.Literal
+		// A schema-qualified function call (pg_catalog.format_type(...)) keeps
+		// the bare function name: the engine has one function namespace.
+		if p.peekTokenIs(lexer.TokenLParen) {
+			p.nextToken()
+			return p.parseFunctionCall(col)
+		}
 		p.nextToken()
 		return &ColumnRef{Table: name, Column: col}, nil
 	}

+ 12 - 0
pkg/pgerror/pgerror.go

@@ -18,9 +18,21 @@ const (
 	CodeCheckViolation       = "23514"
 	CodeSerializationFailure = "40001"
 	CodeSyntaxError          = "42601"
+	CodeUndefinedTable       = "42P01"
 	CodeInternalError        = "XX000"
 )
 
+// ErrUndefinedTable marks a reference to a relation or type name that does not
+// exist. Protocol layers map it to SQLSTATE 42P01 (undefined_table) instead of a
+// generic internal error, which is what a regclass lookup typo must report.
+var ErrUndefinedTable = errors.New("undefined table")
+
+// UndefinedTable returns an error that wraps ErrUndefinedTable with a
+// client-facing explanation of the name that did not resolve.
+func UndefinedTable(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrUndefinedTable, fmt.Sprintf(format, args...))
+}
+
 // 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.

+ 67 - 242
pkg/pgserver/connection.go

@@ -11,8 +11,6 @@ import (
 	"log"
 	"net"
 	"os"
-	"regexp"
-	"sort"
 	"strconv"
 	"strings"
 	"sync/atomic"
@@ -414,17 +412,6 @@ func (c *Connection) handleQuery(msg *Message) error {
 			return c.handleShowCommand(strings.ToUpper(kind))
 		}
 	}
-	if result, handled, err := c.catalogResult(sql); handled {
-		if err != nil {
-			c.sendError("ERROR", ErrCodeInternalError, err.Error())
-			return c.sendReadyForQuery()
-		}
-		if err := c.sendTabularResult(result, "SELECT", nil); err != nil {
-			return err
-		}
-		return c.sendReadyForQuery()
-	}
-
 	// Parse the complete batch before executing its first statement. This keeps
 	// unsupported trailing clauses from turning into committed writes.
 	l := lexer.New(sql)
@@ -479,6 +466,11 @@ func (c *Connection) handleQuery(msg *Message) error {
 				if c.txStatus == TxStatusInBlock {
 					c.txStatus = TxStatusFailed
 				}
+			} else if errors.Is(err, pgerror.ErrUndefinedTable) || analysisUndefinedTable(err) {
+				code = ErrCodeUndefinedTable
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if analysisErr := analysisTypeMismatch(err); analysisErr {
 				code = ErrCodeDatatypeMismatch
 				if c.txStatus == TxStatusInBlock {
@@ -754,37 +746,29 @@ type executeError struct {
 // means the portal is ready to stream.
 func (c *Connection) materializePortal(portal *portal) *executeError {
 	execution := &portalExecution{}
-	if result, handled, catalogErr := c.catalogResult(portal.query); handled {
-		if catalogErr != nil {
-			return &executeError{code: ErrCodeInternalError, err: catalogErr}
-		}
-		execution.result = result
-		execution.commandTag = fmt.Sprintf("SELECT %d", len(result.Rows))
-	} else {
-		stmt, err := parser.New(lexer.New(portal.query)).Parse()
-		if err != nil {
-			code := ErrCodeSyntaxError
-			if errors.Is(err, pgerror.ErrFeatureNotSupported) {
-				code = ErrCodeFeatureNotSupported
-				if c.txStatus == TxStatusInBlock {
-					c.txStatus = TxStatusFailed
-				}
+	stmt, err := parser.New(lexer.New(portal.query)).Parse()
+	if err != nil {
+		code := ErrCodeSyntaxError
+		if errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			code = ErrCodeFeatureNotSupported
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
 			}
-			return &executeError{code: code, err: fmt.Errorf("syntax error: %w", err)}
-		}
-		result, err := c.executor.Execute(stmt)
-		if err != nil && isIdleRollback(stmt, c.txStatus, err) {
-			result, err = executor.NewResult("ROLLBACK"), nil
-		}
-		if err != nil {
-			return c.classifyExecuteError(stmt, err)
 		}
-		execution.result = result
-		// getCommandTag applies the statement's transaction transition, so it
-		// belongs here: a repeated Execute must not replay BEGIN/COMMIT/ROLLBACK.
-		execution.commandTag = c.getCommandTag(stmt, result)
-		execution.clearsPortals = resetsPortals(stmt)
+		return &executeError{code: code, err: fmt.Errorf("syntax error: %w", err)}
+	}
+	result, err := c.executor.Execute(stmt)
+	if err != nil && isIdleRollback(stmt, c.txStatus, err) {
+		result, err = executor.NewResult("ROLLBACK"), nil
+	}
+	if err != nil {
+		return c.classifyExecuteError(stmt, err)
 	}
+	execution.result = result
+	// getCommandTag applies the statement's transaction transition, so it
+	// belongs here: a repeated Execute must not replay BEGIN/COMMIT/ROLLBACK.
+	execution.commandTag = c.getCommandTag(stmt, result)
+	execution.clearsPortals = resetsPortals(stmt)
 	columnTypes := wireColumnTypes(execution.result)
 	formats, formatErr := c.resolveResultFormats(portal.resultFormats, columnTypes, len(execution.result.Columns))
 	if formatErr != nil {
@@ -824,6 +808,11 @@ func (c *Connection) classifyExecuteError(stmt parser.Statement, err error) *exe
 		if c.txStatus == TxStatusInBlock {
 			c.txStatus = TxStatusFailed
 		}
+	} else if errors.Is(err, pgerror.ErrUndefinedTable) || analysisUndefinedTable(err) {
+		code = ErrCodeUndefinedTable
+		if c.txStatus == TxStatusInBlock {
+			c.txStatus = TxStatusFailed
+		}
 	} else if analysisTypeMismatch(err) {
 		code = ErrCodeDatatypeMismatch
 		if c.txStatus == TxStatusInBlock {
@@ -919,203 +908,6 @@ func (c *Connection) sendPortalRows(portal *portal, maxRows int32) error {
 	return nil
 }
 
-// catalogSession is the catalog view this connection serves. The executor's
-// session is authoritative because it resolves against the transaction-scoped
-// DDL overlay, so catalog emulation inside a transaction sees staged tables,
-// columns, and indexes. The fallback only covers a connection that has not
-// completed startup yet.
-func (c *Connection) catalogSession() *storage.Session {
-	if c.executor != nil {
-		return c.executor.Session()
-	}
-	if c.schema == nil || c.table == nil {
-		return nil
-	}
-	if c.catalogFallback == nil {
-		c.catalogFallback = storage.NewSession(c.schema, c.table)
-	}
-	return c.catalogFallback
-}
-
-var catalogFilterPattern = regexp.MustCompile(`(?i)\b(table_name|tablename|table_schema|schemaname|constraint_name|indexname)\s*=\s*'((?:''|[^'])*)'`)
-
-func (c *Connection) catalogResult(sql string) (*executor.Result, bool, error) {
-	if !isSingleStatementSQL(sql) {
-		return nil, false, nil
-	}
-	source := catalogSource(sql)
-	if source == "" {
-		return nil, false, nil
-	}
-	session := c.catalogSession()
-	if session == nil {
-		return nil, false, nil
-	}
-
-	tables, err := session.ListTables()
-	if err != nil {
-		return nil, true, err
-	}
-	rows := make([]map[string]interface{}, 0)
-	switch source {
-	case "INFORMATION_SCHEMA.TABLES":
-		for _, table := range tables {
-			rows = append(rows, map[string]interface{}{
-				"table_catalog": c.database, "table_schema": "public", "table_name": table, "table_type": "BASE TABLE",
-			})
-		}
-	case "PG_TABLES":
-		for _, table := range tables {
-			indexes, _ := session.ListTableIndexes(table)
-			rows = append(rows, map[string]interface{}{
-				"schemaname": "public", "tablename": table, "tableowner": c.params["user"], "tablespace": nil,
-				"hasindexes": len(indexes) > 0, "hasrules": false, "hastriggers": false, "rowsecurity": false,
-			})
-		}
-	case "INFORMATION_SCHEMA.COLUMNS":
-		for _, table := range tables {
-			schema, schemaErr := session.GetSchema(table)
-			if schemaErr != nil {
-				continue
-			}
-			for i, column := range schema.Columns {
-				rows = append(rows, map[string]interface{}{
-					"table_catalog": c.database, "table_schema": "public", "table_name": table,
-					"column_name": column.Name, "ordinal_position": int64(i + 1), "column_default": column.DefaultLiteral(),
-					"is_nullable": yesNo(column.Nullable), "data_type": strings.ToLower(column.Type),
-				})
-			}
-		}
-	case "INFORMATION_SCHEMA.TABLE_CONSTRAINTS", "INFORMATION_SCHEMA.KEY_COLUMN_USAGE":
-		for _, table := range tables {
-			schema, schemaErr := session.GetSchema(table)
-			if schemaErr != nil || schema.UsesSyntheticRowID() {
-				continue
-			}
-			columns := schema.PrimaryKeyList()
-			if source == "INFORMATION_SCHEMA.TABLE_CONSTRAINTS" {
-				rows = append(rows, map[string]interface{}{
-					"constraint_catalog": c.database, "constraint_schema": "public", "constraint_name": table + "_pkey",
-					"table_catalog": c.database, "table_schema": "public", "table_name": table,
-					"constraint_type": "PRIMARY KEY", "is_deferrable": "NO", "initially_deferred": "NO",
-				})
-				continue
-			}
-			// KEY_COLUMN_USAGE reports one row per key column, in key order.
-			for i, column := range columns {
-				rows = append(rows, map[string]interface{}{
-					"constraint_catalog": c.database, "constraint_schema": "public", "constraint_name": table + "_pkey",
-					"table_catalog": c.database, "table_schema": "public", "table_name": table,
-					"column_name": column, "ordinal_position": int64(i + 1),
-				})
-			}
-		}
-	case "PG_INDEXES":
-		for _, table := range tables {
-			indexes, _ := session.ListTableIndexes(table)
-			for _, index := range indexes {
-				columns := make([]string, len(index.Columns))
-				for i, column := range index.Columns {
-					columns[i] = column.Name
-				}
-				unique := ""
-				if index.Unique {
-					unique = "UNIQUE "
-				}
-				rows = append(rows, map[string]interface{}{
-					"schemaname": "public", "tablename": table, "indexname": index.Name, "tablespace": nil,
-					"indexdef": fmt.Sprintf("CREATE %sINDEX %s ON %s (%s)", unique, index.Name, table, strings.Join(columns, ", ")),
-				})
-			}
-		}
-	}
-
-	for _, match := range catalogFilterPattern.FindAllStringSubmatch(sql, -1) {
-		column, expected := strings.ToLower(match[1]), strings.ReplaceAll(match[2], "''", "'")
-		filtered := rows[:0]
-		for _, row := range rows {
-			if value, ok := row[column]; ok && strings.EqualFold(fmt.Sprintf("%v", value), expected) {
-				filtered = append(filtered, row)
-			}
-		}
-		rows = filtered
-	}
-
-	columns := catalogProjection(sql, rows)
-	result := executor.NewResult("SELECT")
-	if len(columns) == 1 && columns[0] == "count(*)" {
-		result.AddColumnWithType("count", "INTEGER")
-		result.AddRow(int64(len(rows)))
-		return result, true, nil
-	}
-	for _, column := range columns {
-		columnType := "TEXT"
-		if column == "ordinal_position" {
-			columnType = "INTEGER"
-		} else if strings.HasPrefix(column, "has") || column == "rowsecurity" {
-			columnType = "BOOLEAN"
-		}
-		result.AddColumnWithType(column, columnType)
-	}
-	for _, row := range rows {
-		values := make([]interface{}, len(columns))
-		for i, column := range columns {
-			values[i] = row[column]
-		}
-		result.AddRow(values...)
-	}
-	return result, true, nil
-}
-
-// isRollbackStatement reports whether sql parses as a single ROLLBACK
-// statement, including ROLLBACK TO SAVEPOINT.
-func isRollbackStatement(sql string) bool {
-	stmt, err := parser.New(lexer.New(sql)).Parse()
-	if err != nil {
-		return false
-	}
-	_, ok := stmt.(*parser.RollbackStmt)
-	return ok
-}
-
-func catalogProjection(sql string, rows []map[string]interface{}) []string {
-	upper := strings.ToUpper(sql)
-	selectPos, fromPos := strings.Index(upper, "SELECT"), strings.Index(upper, " FROM ")
-	if selectPos < 0 || fromPos < 0 || fromPos <= selectPos+6 {
-		return nil
-	}
-	projection := strings.TrimSpace(sql[selectPos+6 : fromPos])
-	projection = strings.TrimSpace(strings.TrimPrefix(strings.ToUpper(projection), "DISTINCT "))
-	if projection == "*" && len(rows) > 0 {
-		columns := make([]string, 0, len(rows[0]))
-		for column := range rows[0] {
-			columns = append(columns, column)
-		}
-		sort.Strings(columns)
-		return columns
-	}
-	parts := strings.Split(projection, ",")
-	columns := make([]string, 0, len(parts))
-	for _, part := range parts {
-		column := strings.TrimSpace(part)
-		if index := strings.Index(strings.ToUpper(column), " AS "); index >= 0 {
-			column = strings.TrimSpace(column[:index])
-		}
-		if index := strings.LastIndex(column, "."); index >= 0 {
-			column = column[index+1:]
-		}
-		columns = append(columns, strings.ToLower(strings.Trim(column, `"`)))
-	}
-	return columns
-}
-
-func yesNo(value bool) string {
-	if value {
-		return "YES"
-	}
-	return "NO"
-}
-
 func (c *Connection) sendTabularResult(result *executor.Result, tag string, bindFormats []int16) error {
 	formats, err := c.resolveResultFormats(bindFormats, result.ColumnTypes, len(result.Columns))
 	if err != nil {
@@ -1214,6 +1006,13 @@ func analysisTypeMismatch(err error) bool {
 	return errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch
 }
 
+// analysisUndefinedTable reports whether the error is the analyzer's
+// table-not-found, which PostgreSQL reports as 42P01 (undefined_table).
+func analysisUndefinedTable(err error) bool {
+	var analysisErr *analyzer.AnalysisError
+	return errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTableNotFound
+}
+
 type boundParameter struct {
 	value  []byte
 	oid    int32
@@ -1814,13 +1613,24 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 		return 701 // FLOAT8OID
 	case "JSONB":
 		return 3802 // JSONBOID
+	case "OID":
+		return 26 // OIDOID
+	case "REGCLASS":
+		return 2205 // REGCLASSOID
+	case "REGTYPE":
+		return 2206 // REGTYPEOID
+	case "REGPROC":
+		// pg_type.typinput and friends are regproc attributes.
+		return 24 // REGPROCOID
+	case "NAME":
+		return 19 // NAMEOID
 	case "TEXT", "VARCHAR", "CHAR", "UUID":
 		// UUID is stored as text (SQLite-style), so it reports TEXTOID rather
 		// than the native PG UUID OID.
 		return 25 // TEXTOID
-	case "REAL", "FLOAT":
+	case "REAL", "FLOAT", "FLOAT4":
 		return 700 // FLOAT4OID
-	case "DOUBLE":
+	case "DOUBLE", "FLOAT8":
 		return 701 // FLOAT8OID
 	case "BOOLEAN", "BOOL":
 		return 16 // BOOLOID
@@ -1851,9 +1661,9 @@ func (c *Connection) getTypeSizeForType(typeName string) int16 {
 		return 8
 	case "NUMERIC", "DECIMAL":
 		return 8 // Reported as float8; see getOIDForType.
-	case "REAL", "FLOAT":
+	case "REAL", "FLOAT", "FLOAT4":
 		return 4
-	case "DOUBLE":
+	case "DOUBLE", "FLOAT8":
 		return 8
 	case "BOOLEAN", "BOOL":
 		return 1
@@ -1861,6 +1671,10 @@ func (c *Connection) getTypeSizeForType(typeName string) int16 {
 		return 8
 	case "DATE":
 		return 4
+	case "OID", "REGCLASS", "REGTYPE", "REGPROC":
+		return 4
+	case "NAME":
+		return 64
 	default:
 		return -1 // Variable length
 	}
@@ -1971,3 +1785,14 @@ func (c *Connection) handleShowCommand(sqlUpper string) error {
 
 	return c.sendReadyForQuery()
 }
+
+// isRollbackStatement reports whether sql parses as a single ROLLBACK
+// statement, including ROLLBACK TO SAVEPOINT.
+func isRollbackStatement(sql string) bool {
+	stmt, err := parser.New(lexer.New(sql)).Parse()
+	if err != nil {
+		return false
+	}
+	_, ok := stmt.(*parser.RollbackStmt)
+	return ok
+}

+ 218 - 0
pkg/pgserver/pg_catalog_test.go

@@ -0,0 +1,218 @@
+package pgserver
+
+import (
+	"net"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+func simpleQuery(sql string) *Message {
+	return &Message{Type: MsgQuery, Data: append([]byte(sql), 0)}
+}
+
+// Catalog columns carry PostgreSQL's types over the wire (oid 26, name 19), so
+// a driver decodes them without falling back to TEXT.
+func TestPgCatalogWireTypes(t *testing.T) {
+	c, client := newDataConnection(t)
+	for _, sql := range []string{
+		"CREATE TABLE wire (id INTEGER PRIMARY KEY, payload JSONB)",
+		"CREATE INDEX idx_wire_payload ON wire (payload)",
+	} {
+		if msgs := runQuery(t, c, client, simpleQuery(sql)); errorCode(msgs[0]) != "" {
+			t.Fatalf("%s: %s", sql, errorMessage(msgs[0]))
+		}
+	}
+
+	msgs := runQuery(t, c, client, simpleQuery(`
+		SELECT c.oid, c.relname, c.relkind
+		FROM pg_catalog.pg_class c
+		WHERE c.relname = 'wire'`))
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("catalog query failed: %s", errorMessage(msg))
+	}
+	rd := findRowDescription(t, msgs)
+	if got := rowDescriptionOIDs(t, rd.Data); len(got) != 3 || got[0] != 26 || got[1] != 19 || got[2] != 25 {
+		t.Fatalf("catalog OIDs = %v, want [26 19 25]", got)
+	}
+	rows := catalogRows(t, msgs)
+	if len(rows) != 1 || rows[0][1] != "wire" || rows[0][2] != "r" {
+		t.Fatalf("pg_class row = %v", rows)
+	}
+	if _, err := strconv.ParseUint(rows[0][0], 10, 32); err != nil {
+		t.Fatalf("oid %q is not numeric: %v", rows[0][0], err)
+	}
+}
+
+// A join across pg_class/pg_namespace/pg_attribute works over the simple-query
+// protocol, which is how migration tools list columns.
+func TestPgCatalogJoinOverWire(t *testing.T) {
+	c, client := newDataConnection(t)
+	for _, sql := range []string{
+		"CREATE TABLE wire_join (id INTEGER PRIMARY KEY, label TEXT NOT NULL)",
+	} {
+		if msgs := runQuery(t, c, client, simpleQuery(sql)); errorCode(msgs[0]) != "" {
+			t.Fatalf("%s: %s", sql, errorMessage(msgs[0]))
+		}
+	}
+	msgs := runQuery(t, c, client, simpleQuery(`
+		SELECT n.nspname, a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod)
+		FROM pg_catalog.pg_class c
+		JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+		JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
+		WHERE c.relname = 'wire_join' AND a.attnum > 0
+		ORDER BY a.attnum`))
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("catalog join failed: %s", errorMessage(msg))
+	}
+	rows := catalogRows(t, msgs)
+	if len(rows) != 2 || rows[0][1] != "id" || rows[0][2] != "integer" || rows[1][1] != "label" {
+		t.Fatalf("catalog join rows = %v", rows)
+	}
+}
+
+// ORM connections issue SET during startup; the engine acknowledges it and
+// SHOW reports the retained search path.
+func TestSetAndShowSearchPathOverWire(t *testing.T) {
+	c, client := newDataConnection(t)
+	if msgs := runQuery(t, c, client, simpleQuery("SET search_path TO public")); errorCode(msgs[0]) != "" {
+		t.Fatalf("SET search_path failed: %s", errorMessage(msgs[0]))
+	}
+	if msgs := runQuery(t, c, client, simpleQuery("SET TIME ZONE 'UTC'")); errorCode(msgs[0]) != "" {
+		t.Fatalf("SET TIME ZONE failed: %s", errorMessage(msgs[0]))
+	}
+	msgs := runQuery(t, c, client, simpleQuery("SHOW search_path"))
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("SHOW search_path failed: %s", errorMessage(msg))
+	}
+	rows := catalogRows(t, msgs)
+	if len(rows) != 1 || rows[0][0] != "public" {
+		t.Fatalf("SHOW search_path = %v", rows)
+	}
+	msgs = runQuery(t, c, client, simpleQuery("SHOW client_encoding"))
+	if rows := catalogRows(t, msgs); len(rows) != 1 || !strings.EqualFold(rows[0][0], "UTF8") {
+		t.Fatalf("SHOW client_encoding = %v", rows)
+	}
+}
+
+// An unknown regclass/regtype reaches the client as SQLSTATE 42P01 over the
+// simple-query protocol, both in a projection and inside a WHERE clause, and
+// to_regclass returns NULL instead of failing.
+func TestPgCatalogUndefinedRelationSQLState(t *testing.T) {
+	c, client := newDataConnection(t)
+	for _, sql := range []string{
+		"CREATE TABLE wire_state (id INTEGER PRIMARY KEY)",
+	} {
+		if msgs := runQuery(t, c, client, simpleQuery(sql)); errorCode(msgs[0]) != "" {
+			t.Fatalf("%s: %s", sql, errorMessage(msgs[0]))
+		}
+	}
+
+	for _, sql := range []string{
+		`SELECT 'missing_relation'::regclass`,
+		`SELECT 'missing_type'::regtype`,
+		`SELECT count(*) FROM pg_catalog.pg_class WHERE oid = 'missing_relation'::regclass`,
+		`SELECT count(*) FROM public.pg_class`,
+	} {
+		msgs := runQuery(t, c, client, simpleQuery(sql))
+		msg := findError(msgs)
+		if msg == nil {
+			t.Fatalf("%s: expected an error, got %v", sql, msgs)
+		}
+		if code := errorCode(msg); code != ErrCodeUndefinedTable {
+			t.Fatalf("%s: SQLSTATE = %q, want %q", sql, code, ErrCodeUndefinedTable)
+		}
+	}
+
+	msgs := runQuery(t, c, client, simpleQuery(`SELECT to_regclass('missing_relation'), to_regclass('wire_state')`))
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("to_regclass failed: %s", errorMessage(msg))
+	}
+	rd := findRowDescription(t, msgs)
+	if got := rowDescriptionOIDs(t, rd.Data); len(got) != 2 || got[0] != 2205 || got[1] != 2205 {
+		t.Fatalf("to_regclass OIDs = %v, want regclass 2205 twice", got)
+	}
+	rows := catalogRows(t, msgs)
+	if len(rows) != 1 || rows[0][0] != "<null>" || rows[0][1] != "wire_state" {
+		t.Fatalf("to_regclass rows = %v, want NULL then wire_state", rows)
+	}
+}
+
+// pg_type's function attributes are regproc (OID 24) with numeric values, not
+// the text "-".
+func TestPgCatalogRegprocWireType(t *testing.T) {
+	c, client := newDataConnection(t)
+	msgs := runQuery(t, c, client, simpleQuery(`SELECT typinput, typoutput FROM pg_type WHERE typname = 'int4'`))
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("pg_type query failed: %s", errorMessage(msg))
+	}
+	rd := findRowDescription(t, msgs)
+	if got := rowDescriptionOIDs(t, rd.Data); len(got) != 2 || got[0] != 24 || got[1] != 24 {
+		t.Fatalf("regproc OIDs = %v, want [24 24]", got)
+	}
+	rows := catalogRows(t, msgs)
+	if len(rows) != 1 || rows[0][0] != "0" || rows[0][1] != "0" {
+		t.Fatalf("pg_type regproc values = %v, want 0/0", rows)
+	}
+}
+
+// A catalog-backed predicate in a write must not deadlock the session: the
+// engine resolves the snapshot before the locked scan. A deadlock here leaks the
+// table gate, so a second connection is probed for reads and writes after the
+// error, bounded by a read deadline.
+func TestPgCatalogWritePredicateKeepsSecondSessionUsable(t *testing.T) {
+	kv := testkv.New(t)
+	pool := kv.Pool(8)
+	t.Cleanup(func() { pool.Close() })
+	schema := storage.NewSchemaManager(pool, "pg_catalog_lock")
+	table := storage.NewTableManager(pool, schema, "pg_catalog_lock")
+
+	writer, writerClient := newDataConnectionFor(t, schema, table)
+	reader, readerClient := newDataConnectionFor(t, schema, table)
+
+	run := func(c *Connection, client net.Conn, sql string) []*Message {
+		t.Helper()
+		// A blocked statement never answers; bound the wait so a regression
+		// fails instead of hanging the suite.
+		if err := client.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
+			t.Fatalf("set deadline: %v", err)
+		}
+		defer func() { _ = client.SetReadDeadline(time.Time{}) }()
+		return runQuery(t, c, client, simpleQuery(sql))
+	}
+
+	for _, sql := range []string{
+		"CREATE TABLE locked_write (id INTEGER PRIMARY KEY, v TEXT NOT NULL)",
+		"INSERT INTO locked_write VALUES (1, 'a')",
+	} {
+		if msgs := run(writer, writerClient, sql); errorCode(msgs[0]) != "" {
+			t.Fatalf("%s: %s", sql, errorMessage(msgs[0]))
+		}
+	}
+
+	msgs := run(writer, writerClient, `UPDATE locked_write SET v = 'z' WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`)
+	msg := findError(msgs)
+	if msg == nil {
+		t.Fatalf("expected an error for the unknown regclass, got %v", msgs)
+	}
+	if code := errorCode(msg); code != ErrCodeUndefinedTable {
+		t.Fatalf("SQLSTATE = %q, want %q (%s)", code, ErrCodeUndefinedTable, errorMessage(msg))
+	}
+
+	// The failed statement left no partial write and released its locks.
+	msgs = run(reader, readerClient, `SELECT count(*) FROM locked_write WHERE v = 'z'`)
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("second session SELECT failed: %s", errorMessage(msg))
+	}
+	if rows := catalogRows(t, msgs); len(rows) != 1 || rows[0][0] != "0" {
+		t.Fatalf("updated rows = %v, want 0", rows)
+	}
+	msgs = run(reader, readerClient, `INSERT INTO locked_write VALUES (2, 'b')`)
+	if msg := findError(msgs); msg != nil {
+		t.Fatalf("second session INSERT failed: %s", errorMessage(msg))
+	}
+}

+ 153 - 0
pkg/pgserver/pgx_catalog_test.go

@@ -0,0 +1,153 @@
+package pgserver
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/jackc/pgx/v5/pgconn"
+)
+
+// A stock pgx connection introspects the catalog with its normal
+// Parse/Bind/Execute path. This covers the real driver reading oid/name/regclass
+// columns and issuing SET during a session.
+func TestPGXCatalogIntrospection(t *testing.T) {
+	conn := connectPGX(t)
+	ctx := context.Background()
+	for _, sql := range []string{
+		`CREATE TABLE catalog_parent (id INTEGER PRIMARY KEY, label TEXT NOT NULL UNIQUE)`,
+		`CREATE TABLE catalog_child (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL, note TEXT DEFAULT 'n', CONSTRAINT fk_catalog_child FOREIGN KEY (parent_id) REFERENCES catalog_parent(id))`,
+		`CREATE INDEX idx_catalog_child_note ON catalog_child (note)`,
+	} {
+		if _, err := conn.Exec(ctx, sql); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+
+	var (
+		oid     uint32
+		relname string
+		relkind string
+	)
+	err := conn.QueryRow(ctx, `
+		SELECT c.oid, c.relname, c.relkind
+		FROM pg_catalog.pg_class c
+		WHERE c.relname = 'catalog_child'`).Scan(&oid, &relname, &relkind)
+	if err != nil {
+		t.Fatalf("pg_class scan: %v", err)
+	}
+	if oid == 0 || relname != "catalog_child" || relkind != "r" {
+		t.Fatalf("pg_class = %d %q %q", oid, relname, relkind)
+	}
+
+	rows, err := conn.Query(ctx, `
+		SELECT n.nspname, a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod)
+		FROM pg_catalog.pg_class c
+		JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+		JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
+		WHERE c.relname = 'catalog_child' AND a.attnum > 0
+		ORDER BY a.attnum`)
+	if err != nil {
+		t.Fatalf("catalog join: %v", err)
+	}
+	defer rows.Close()
+	var columns []string
+	for rows.Next() {
+		var schema, column, typ string
+		if err := rows.Scan(&schema, &column, &typ); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		if schema != "public" {
+			t.Fatalf("schema = %q", schema)
+		}
+		columns = append(columns, column+":"+typ)
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatalf("rows: %v", err)
+	}
+	want := []string{"id:integer", "parent_id:integer", "note:text"}
+	if len(columns) != len(want) {
+		t.Fatalf("columns = %v, want %v", columns, want)
+	}
+	for i := range want {
+		if columns[i] != want[i] {
+			t.Fatalf("columns = %v, want %v", columns, want)
+		}
+	}
+
+	var (
+		tableName  string
+		columnName string
+		dataType   string
+		isNullable string
+	)
+	err = conn.QueryRow(ctx, `
+		SELECT table_name, column_name, data_type, is_nullable
+		FROM information_schema.columns
+		WHERE table_name = 'catalog_child' AND column_name = 'note'`).
+		Scan(&tableName, &columnName, &dataType, &isNullable)
+	if err != nil {
+		t.Fatalf("information_schema scan: %v", err)
+	}
+	if tableName != "catalog_child" || columnName != "note" || dataType != "text" || isNullable != "YES" {
+		t.Fatalf("information_schema row = %q %q %q %q", tableName, columnName, dataType, isNullable)
+	}
+
+	if _, err := conn.Exec(ctx, "SET search_path TO public"); err != nil {
+		t.Fatalf("SET search_path: %v", err)
+	}
+	var searchPath string
+	if err := conn.QueryRow(ctx, "SHOW search_path").Scan(&searchPath); err != nil {
+		t.Fatalf("SHOW search_path: %v", err)
+	}
+	if searchPath != "public" {
+		t.Fatalf("search_path = %q", searchPath)
+	}
+
+	var regclassRows int
+	if err := conn.QueryRow(ctx, `
+		SELECT count(*) FROM pg_catalog.pg_constraint
+		WHERE conrelid = 'catalog_child'::regclass AND contype = 'f'`).Scan(&regclassRows); err != nil {
+		t.Fatalf("regclass constraint query: %v", err)
+	}
+	if regclassRows != 1 {
+		t.Fatalf("foreign key constraint count = %d, want 1", regclassRows)
+	}
+}
+
+// An unknown regclass over the extended protocol is a 42P01 PgError, not an
+// XX000 internal error, and to_regclass returns NULL for the same name.
+func TestPGXCatalogUndefinedRelationSQLState(t *testing.T) {
+	conn := connectPGX(t)
+	ctx := context.Background()
+	if _, err := conn.Exec(ctx, `CREATE TABLE catalog_state (id INTEGER PRIMARY KEY)`); err != nil {
+		t.Fatalf("create table: %v", err)
+	}
+
+	for _, sql := range []string{
+		`SELECT 'missing_relation'::regclass`,
+		`SELECT 'missing_type'::regtype`,
+		`SELECT count(*) FROM pg_catalog.pg_class WHERE oid = 'missing_relation'::regclass`,
+		`SELECT count(*) FROM public.pg_class`,
+	} {
+		var n int64
+		err := conn.QueryRow(ctx, sql).Scan(&n)
+		var pgErr *pgconn.PgError
+		if !errors.As(err, &pgErr) {
+			t.Fatalf("%s: err = %v, want a PgError", sql, err)
+		}
+		if pgErr.Code != "42P01" {
+			t.Fatalf("%s: SQLSTATE = %q, want 42P01 (%s)", sql, pgErr.Code, pgErr.Message)
+		}
+	}
+
+	var missing, present *string
+	err := conn.QueryRow(ctx, `SELECT to_regclass('missing_relation'), to_regclass('catalog_state')`).
+		Scan(&missing, &present)
+	if err != nil {
+		t.Fatalf("to_regclass: %v", err)
+	}
+	if missing != nil || present == nil || *present != "catalog_state" {
+		t.Fatalf("to_regclass = %v / %v, want NULL / catalog_state", missing, present)
+	}
+}

+ 25 - 0
pkg/pgserver/result_binary.go

@@ -34,10 +34,15 @@ func canEncodeBinaryResult(oid int32) bool {
 		21,   // int2
 		23,   // int4
 		25,   // text
+		19,   // name
+		26,   // oid
 		700,  // float4
 		701,  // float8
 		1043, // varchar
 		1700, // numeric
+		24,   // regproc
+		2205, // regclass
+		2206, // regtype
 		3802: // jsonb
 		return true
 	}
@@ -135,6 +140,17 @@ func appendBinaryResultValue(dst []byte, oid int32, value interface{}) ([]byte,
 		default:
 			return appendUint64(dst, uint64(n)), nil
 		}
+	case 19: // name
+		if reg, ok := value.(executor.RegOID); ok {
+			return append(dst, reg.String()...), nil
+		}
+		return append(dst, textValueString(value)...), nil
+	case 26, 24, 2205, 2206: // oid, regproc, regclass, regtype
+		n, err := catalogOIDBinaryValue(value)
+		if err != nil {
+			return dst, err
+		}
+		return append(dst, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)), nil
 	case 25, 1043: // text, varchar
 		return append(dst, textValueString(value)...), nil
 	case 700: // float4
@@ -227,6 +243,15 @@ func appendUint64(dst []byte, n uint64) []byte {
 // Text is accepted because SQLite affinity can leave a text value in an integer
 // column; a value that does not fit the reported OID is an encoding error
 // instead of a silently truncated one.
+// catalogOIDBinaryValue encodes an oid/regclass/regtype value. A RegOID carries
+// its numeric OID separately from its display name.
+func catalogOIDBinaryValue(value interface{}) (int64, error) {
+	if reg, ok := value.(executor.RegOID); ok {
+		return binaryInt64(reg.OID(), 23)
+	}
+	return binaryInt64(value, 23)
+}
+
 func binaryInt64(value interface{}, oid int32) (int64, error) {
 	var n int64
 	switch v := value.(type) {

+ 3 - 1
pkg/pgserver/result_binary_test.go

@@ -865,7 +865,9 @@ func TestBinaryResultsRejectAfterDMLExecution(t *testing.T) {
 // results: their text and boolean columns encode as binary.
 func TestBinaryResultsCatalogQuery(t *testing.T) {
 	c, conn := extendedResultConnection(t)
-	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)); err != nil {
+	// A table without a primary key or explicit index has no indexes; the
+	// synthetic primary-key index is covered by the catalog tests.
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER)`)); err != nil {
 		t.Fatal(err)
 	}
 	sendExtended(t, c,

Some files were not shown because too many files changed in this diff