Ver Fonte

fix catalog OID collisions, undefined-table SQLSTATE, and set scope

BLOCKING: an index sharing its table's name produced one OID for both
relations, because the relation identity was schema.name only. pg_class then
returned duplicate rows, a pg_attribute join doubled the table's columns, and
pg_index reported indexrelid == indrelid. relkind is now part of the relation
key (schema.name#kind) and of the synthetic index name guard, which also
seeded table names so a table named <x>_pkey no longer collides with the
synthetic primary-key index of x. regclass resolution prefers the table when a
name is shared, since PostgreSQL keeps names unique per schema.

BLOCKING: an unknown regclass/regtype cast reported XX000, and a failed cast
inside WHERE was swallowed into zero rows. The cast now returns an
undefined-table error mapped to SQLSTATE 42P01 in the simple and extended
protocols (and the HTTP query API), the materialized WHERE path propagates the
predicate error, and to_regclass(text)/to_regtype(text) return NULL for a name
that does not exist.

Catalog semantics:
- a CTE in scope shadows a virtual catalog relation of the same name, and a
  schema-qualified name never resolves to a CTE or to another schema, so
  public.pg_class is an undefined table (42P01) while unqualified pg_class
  still resolves through pg_catalog
- session views are reported consistently: pg_class relkind 'v', pg_attribute,
  and information_schema.columns/views all describe the same column list
- pg_type function attributes (typinput/typoutput/typsend/typreceive/typmodin/
  typmodout/typanalyze) and pg_proc.prosupport are regproc columns holding 0
  instead of the text "-", with regproc (OID 24) text and binary encoding
- buildCatalogSnapshot reads each table schema once

Session settings: SET LOCAL is transaction-scoped (expires at COMMIT, undone by
ROLLBACK and by ROLLBACK TO SAVEPOINT, no effect outside a transaction block)
and a session SET inside an aborted transaction is undone by ROLLBACK, which is
what SHOW search_path and current_schema() now report. The capabilities
document and error-code list describe the resulting behavior.

Tests: shared table/index name and synthetic pkey name collisions, undefined
relation SQLSTATE over simple and extended protocols, to_regclass/to_regtype,
CTE shadowing, schema-qualified resolution, view catalog shape, regproc
attributes, SET LOCAL/search_path transaction scope, and 0A000 assertions for
every catalog write.
Danilo Fragoso há 20 horas atrás
pai
commit
400d4bd32b

+ 2 - 0
pkg/analyzer/types.go

@@ -292,6 +292,8 @@ var builtinFunctions = map[string]FunctionSignature{
 	"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},

+ 6 - 5
pkg/capabilities/capabilities.go

@@ -236,8 +236,8 @@ func Current() *Document {
 		},
 
 		Catalog: Catalog{
-			PgCatalog:         Feature{Supported: true, Status: "virtual_relations", SQLState: "0A000", Notes: "read-only relations synthesized from the durable schema the connection's session observes, including DDL staged by an open transaction: pg_class, pg_namespace, pg_attribute, pg_type, pg_index, pg_constraint, pg_attrdef, pg_database, pg_tablespace, pg_proc (the builtins the engine answers), pg_am, pg_description, pg_tables, and pg_indexes; SELECT supports WHERE, ORDER BY, LIMIT/OFFSET, projection, aggregates, and joins between catalog relations, with the emulated schemas pg_catalog (OID 11), public (OID 2200), and information_schema (OID 13207); INSERT/UPDATE/DELETE against a catalog relation fails with 0A000, comments and sequences are honestly empty/NULL, and a primary key gets a synthetic index so pg_index and pg_constraint agree; a unique index and a UNIQUE constraint are both reported as contype 'u' because the engine does not persist which declaration created the index"},
-			InformationSchema: Feature{Supported: true, Status: "virtual_views", Notes: "tables, columns, table_constraints, key_column_usage, referential_constraints, constraint_column_usage, schemata, sequences, and views; sequences is always empty because the engine has no sequence objects, and views lists session views with a NULL definition because the engine does not retain view SQL text; information_schema.check_constraints and domains are not emulated"},
+			PgCatalog:         Feature{Supported: true, Status: "virtual_relations", SQLState: "0A000", Notes: "read-only relations synthesized from the durable schema the connection's session observes, including DDL staged by an open transaction: pg_class, pg_namespace, pg_attribute, pg_type, pg_index, pg_constraint, pg_attrdef, pg_database, pg_tablespace, pg_proc (the builtins the engine answers), pg_am, pg_description, pg_tables, and pg_indexes; session views appear in pg_class with relkind 'v' plus their pg_attribute and information_schema.columns rows; SELECT supports WHERE, ORDER BY, LIMIT/OFFSET, projection, aggregates, and joins between catalog relations, with the emulated schemas pg_catalog (OID 11), public (OID 2200), and information_schema (OID 13207); a schema-qualified name only resolves in the schema named, so public.pg_class is an undefined table (42P01), and a CTE shadows a catalog relation of the same name; INSERT/UPDATE/DELETE against a catalog relation fails with 0A000, comments and sequences are honestly empty/NULL, pg_type function attributes are regproc 0 rather than invented function names, and a primary key gets a synthetic index so pg_index and pg_constraint agree; a unique index and a UNIQUE constraint are both reported as contype 'u' because the engine does not persist which declaration created the index"},
+			InformationSchema: Feature{Supported: true, Status: "virtual_views", Notes: "tables, columns, table_constraints, key_column_usage, referential_constraints, constraint_column_usage, schemata, sequences, and views; sequences is always empty because the engine has no sequence objects, and views lists exactly the session views pg_class reports as relkind 'v', with a NULL definition because the engine does not retain view SQL text; information_schema.check_constraints and domains are not emulated"},
 			Relations: []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",
@@ -254,11 +254,11 @@ func Current() *Document {
 				"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", "pg_get_userbyid",
+				"has_table_privilege", "pg_get_userbyid", "to_regclass", "to_regtype",
 			},
-			OIDStrategy:   "deterministic 32-bit FNV-1a of the schema-qualified name, lifted into [16384, 2^31-1] and collision-probed in sorted order; pg_class, pg_attribute, pg_type, pg_index, and pg_constraint share the same allocator so catalog joins agree",
+			OIDStrategy:   "deterministic 32-bit FNV-1a of the schema-qualified name plus relkind, lifted into [16384, 2^31-1] and collision-probed in sorted order; relkind is part of the key so an index that shares its table's name keeps its own OID, and pg_class, pg_attribute, pg_type, pg_index, and pg_constraint share the same allocator so catalog joins agree",
 			Writable:      false,
-			SetStatements: Feature{Supported: true, Status: "accepted_and_ignored", Notes: "SET [SESSION|LOCAL] name { TO | = } value, SET NAMES, SET TIME ZONE, and SET SESSION CHARACTERISTICS are accepted so driver initialization succeeds; only search_path is retained and reported by SHOW search_path and current_schema(), which resolve the emulated public schema; SHOW reports honest constants for server_version, server_encoding, client_encoding, transaction_isolation, standard_conforming_strings, integer_datetimes, datestyle, timezone, application_name, and is_superuser, and rejects unknown names"},
+			SetStatements: Feature{Supported: true, Status: "search_path_only", Notes: "SET [SESSION|LOCAL] name { TO | = } value, SET NAMES, SET TIME ZONE, and SET SESSION CHARACTERISTICS are accepted so driver initialization succeeds; only search_path is retained and reported by SHOW search_path and current_schema(), which resolve the emulated public schema. SET LOCAL is transaction-scoped (no effect outside a transaction block, expires at COMMIT, undone by ROLLBACK TO SAVEPOINT), and a session SET inside an aborted transaction is undone by ROLLBACK, matching PostgreSQL. Other settings are accepted and ignored. SHOW reports honest constants for server_version, server_encoding, client_encoding, transaction_isolation, standard_conforming_strings, integer_datetimes, datestyle, timezone, application_name, and is_superuser, and rejects unknown names"},
 		},
 
 		Parameters: Parameters{
@@ -294,6 +294,7 @@ func Current() *Document {
 			"22023": "invalid_parameter_value",
 			"42501": "insufficient_privilege (managed proxy)",
 			"42601": "syntax_error",
+			"42P01": "undefined_table",
 			"25P02": "in_failed_sql_transaction",
 			"08P01": "protocol_violation (managed proxy: TLS required)",
 			"53300": "too_many_connections (managed proxy: organization quota)",

+ 3 - 3
pkg/capabilities/capabilities_test.go

@@ -388,7 +388,7 @@ func TestCatalogIntrospectionIsAdvertised(t *testing.T) {
 	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",
+		"obj_description", "col_description", "has_table_privilege", "to_regclass", "to_regtype",
 	} {
 		found := false
 		for _, function := range catalog.Functions {
@@ -404,10 +404,10 @@ func TestCatalogIntrospectionIsAdvertised(t *testing.T) {
 	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 != "accepted_and_ignored" {
+	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"} {
+	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)
 		}

+ 63 - 22
pkg/executor/executor.go

@@ -47,17 +47,22 @@ type Executor struct {
 
 	// 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.
-	searchPath []string
+	// 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
@@ -106,8 +111,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
@@ -157,6 +165,7 @@ 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},
 	}
@@ -591,6 +600,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
 	}
 
@@ -1902,7 +1921,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)
@@ -4554,9 +4576,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
@@ -4570,24 +4605,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)
 			}
 		}
 	}
@@ -4598,6 +4623,7 @@ func (e *Executor) executeCreateView(stmt *parser.CreateViewStmt) (*Result, erro
 		Columns: viewCols,
 		IsView:  true,
 	})
+	e.viewColumns[name] = catalogColumns
 
 	return NewResult("CREATE VIEW"), nil
 }
@@ -4612,6 +4638,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
@@ -4870,6 +4897,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")
@@ -4894,6 +4923,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")
@@ -4918,6 +4948,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.
@@ -4943,6 +4974,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
@@ -4963,6 +4995,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
 		}
@@ -5063,6 +5098,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]

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

+ 129 - 60
pkg/executor/pg_catalog.go

@@ -88,10 +88,10 @@ var pgCatalogRelationDefs = []catalogRelationDef{
 		{"typowner", "OID"}, {"typlen", "INT2"}, {"typbyval", "BOOL"},
 		{"typtype", "CHAR"}, {"typcategory", "CHAR"}, {"typispreferred", "BOOL"},
 		{"typisdefined", "BOOL"}, {"typdelim", "CHAR"}, {"typrelid", "OID"},
-		{"typsubscript", "TEXT"}, {"typelem", "OID"}, {"typarray", "OID"},
-		{"typinput", "TEXT"}, {"typoutput", "TEXT"}, {"typreceive", "TEXT"},
-		{"typsend", "TEXT"}, {"typmodin", "TEXT"}, {"typmodout", "TEXT"},
-		{"typanalyze", "TEXT"}, {"typalign", "CHAR"}, {"typstorage", "CHAR"},
+		{"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"},
@@ -134,7 +134,7 @@ var pgCatalogRelationDefs = []catalogRelationDef{
 	{schema: pgCatalogSchemaName, name: "pg_proc", columns: []catalogColumnDef{
 		{"oid", "OID"}, {"proname", "NAME"}, {"pronamespace", "OID"},
 		{"proowner", "OID"}, {"prolang", "OID"}, {"procost", "FLOAT4"},
-		{"prorows", "FLOAT4"}, {"provariadic", "OID"}, {"prosupport", "TEXT"},
+		{"prorows", "FLOAT4"}, {"provariadic", "OID"}, {"prosupport", "REGPROC"},
 		{"prokind", "CHAR"}, {"prosecdef", "BOOL"}, {"proleakproof", "BOOL"},
 		{"proisstrict", "BOOL"}, {"proretset", "BOOL"}, {"provolatile", "CHAR"},
 		{"proparallel", "CHAR"}, {"pronargs", "INT2"}, {"pronargdefaults", "INT2"},
@@ -261,12 +261,16 @@ func buildCatalogRelationIndex() map[string]catalogRelationEntry {
 }
 
 // catalogRelationFor resolves a possibly schema-qualified table reference to a
-// virtual catalog relation. Unqualified names resolve to pg_catalog only when
-// no durable table shadows them, matching PostgreSQL's implicit search_path.
+// 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:
@@ -275,13 +279,16 @@ func (e *Executor) catalogRelationFor(ref parser.TableRef) (catalogRelationEntry
 	case informationSchemaName:
 		entry, ok := pgCatalogRelationIndex[informationSchemaName+"."+name]
 		return entry, ok
-	case "", publicSchemaName:
+	case "":
 		if e.session.TableExists(ref.Name) {
 			return catalogRelationEntry{}, false
 		}
 		entry, ok := pgCatalogRelationIndex[pgCatalogSchemaName+"."+name]
 		return entry, ok
 	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
 	}
 }
@@ -321,6 +328,14 @@ func fnv1a32(text string) uint32 {
 	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
@@ -505,6 +520,9 @@ type catalogRelation struct {
 	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.
@@ -536,8 +554,6 @@ type catalogSnapshot struct {
 
 	rows map[string][]storage.Row
 
-	views []string
-
 	// Preallocated OIDs keep every catalog relation deterministic regardless of
 	// the order in which row sets are materialized.
 	attrdefOIDs map[string]int64
@@ -601,7 +617,7 @@ func relationNamespaceOID(schemaName string) int64 {
 }
 
 // uniqueCatalogName returns base, or base with a numeric suffix, so a synthetic
-// relation name never collides with a durable index name.
+// 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++ {
@@ -620,7 +636,6 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 		tableIndexes:    make(map[string][]*catalogRelation),
 		constraintByOID: make(map[int64]*catalogConstraint),
 		rows:            make(map[string][]storage.Row),
-		views:           sortedViewNames(e.views),
 		attrdefOIDs:     make(map[string]int64),
 		procOIDs:        make(map[string]int64),
 		oidOwners:       make(map[int64]string),
@@ -642,11 +657,18 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 	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,
@@ -656,7 +678,6 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 		})
 	}
 
-	usedNames := make(map[string]bool, len(indexNames))
 	for _, indexName := range indexNames {
 		index, err := e.session.GetIndex(indexName)
 		if err != nil {
@@ -672,13 +693,25 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 		})
 	}
 
+	// 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, err := e.session.GetSchema(tableName)
-		if err != nil {
-			return nil, err
-		}
+		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.
@@ -709,6 +742,7 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 			name:       def.name,
 			kind:       'v',
 			columns:    pgCatalogRelationIndex[def.schema+"."+def.name].schema.Columns,
+			virtual:    true,
 		})
 	}
 	for _, def := range informationSchemaRelationDefs {
@@ -717,6 +751,7 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 			name:       def.name,
 			kind:       'v',
 			columns:    pgCatalogRelationIndex[def.schema+"."+def.name].schema.Columns,
+			virtual:    true,
 		})
 	}
 
@@ -731,9 +766,9 @@ func buildCatalogSnapshot(e *Executor) (*catalogSnapshot, error) {
 	})
 
 	for _, relation := range descriptors {
-		relation.oid = snapshot.allocateOID("relation", relation.schemaName+"."+relation.name)
+		relation.oid = snapshot.allocateOID("relation", catalogRelationKey(relation))
 		snapshot.relations = append(snapshot.relations, relation)
-		snapshot.relationByKey[strings.ToLower(relation.schemaName+"."+relation.name)] = relation
+		snapshot.relationByKey[catalogRelationKey(relation)] = relation
 		snapshot.relationByOID[relation.oid] = relation
 		if relation.kind == 'r' {
 			snapshot.tableByName[strings.ToLower(relation.name)] = relation
@@ -861,21 +896,34 @@ func (s *catalogSnapshot) regclassOID(name string) (int64, bool) {
 	if dot := strings.IndexByte(name, '.'); dot >= 0 {
 		schemaName := strings.Trim(strings.TrimSpace(name[:dot]), `"`)
 		tableName := strings.Trim(strings.TrimSpace(name[dot+1:]), `"`)
-		relation, ok := s.relationByKey[strings.ToLower(schemaName+"."+tableName)]
-		if ok {
+		if relation := s.regclassRelation(schemaName, tableName); relation != nil {
 			return relation.oid, true
 		}
 		return 0, false
 	}
-	if relation, ok := s.relationByKey[strings.ToLower(publicSchemaName+"."+name)]; ok {
+	if relation := s.regclassRelation(publicSchemaName, name); relation != nil {
 		return relation.oid, true
 	}
-	if relation, ok := s.relationByKey[strings.ToLower(pgCatalogSchemaName+"."+name)]; ok {
+	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]
@@ -1131,13 +1179,13 @@ func (s *catalogSnapshot) pgTypeRows() []storage.Row {
 			"typsubscript":   nil,
 			"typelem":        t.elem,
 			"typarray":       t.array,
-			"typinput":       "-",
-			"typoutput":      "-",
-			"typreceive":     "-",
-			"typsend":        "-",
-			"typmodin":       "-",
-			"typmodout":      "-",
-			"typanalyze":     "-",
+			"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,
@@ -1376,6 +1424,8 @@ var pgCatalogFunctions = []struct {
 	{"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 {
@@ -1494,22 +1544,6 @@ func (s *catalogSnapshot) informationSchemaTablesRows() []storage.Row {
 			"commit_action":                nil,
 		})
 	}
-	for _, view := range s.views {
-		rows = append(rows, storage.Row{
-			"table_catalog":                s.database,
-			"table_schema":                 publicSchemaName,
-			"table_name":                   view,
-			"table_type":                   "VIEW",
-			"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":           "NO",
-			"is_typed":                     "NO",
-			"commit_action":                nil,
-		})
-	}
 	return rows
 }
 
@@ -1576,7 +1610,7 @@ func informationSchemaColumn(relation *catalogRelation, column storage.Column, p
 func (s *catalogSnapshot) informationSchemaColumnsRows() []storage.Row {
 	var rows []storage.Row
 	for _, relation := range s.relations {
-		if relation.kind != 'r' {
+		if relation.kind == 'i' || relation.virtual {
 			continue
 		}
 		for i, column := range relation.columns {
@@ -1778,24 +1812,28 @@ func (s *catalogSnapshot) informationSchemaSchemataRows() []storage.Row {
 }
 
 // catalogSchemaByName resolves an unqualified pg_catalog relation name that is
-// not shadowed by a durable table.
+// 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.session.TableExists(name) {
+	if !ok || e.session.TableExists(name) || e.cteTableExists(name) {
 		return nil, false
 	}
 	return entry.schema, true
 }
 
 func (s *catalogSnapshot) informationSchemaViewsRows() []storage.Row {
-	// Views are session-local and the engine does not retain their SQL text, so
-	// information_schema.views lists them with an unknown definition.
+	// 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 _, view := range s.views {
+	for _, relation := range s.relations {
+		if relation.kind != 'v' || relation.virtual {
+			continue
+		}
 		rows = append(rows, storage.Row{
 			"table_catalog":              s.database,
-			"table_schema":               publicSchemaName,
-			"table_name":                 view,
+			"table_schema":               relation.schemaName,
+			"table_name":                 relation.name,
 			"view_definition":            nil,
 			"check_option":               "NONE",
 			"is_updatable":               "NO",
@@ -2048,7 +2086,7 @@ func (e *Executor) evalCatalogFunction(name string, fn *parser.FunctionCall, arg
 	}
 
 	switch name {
-	case "FORMAT_TYPE", "PG_TABLE_IS_VISIBLE", "PG_GET_INDEXDEF", "PG_GET_CONSTRAINTDEF", "HAS_TABLE_PRIVILEGE":
+	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
 	}
@@ -2059,6 +2097,36 @@ func (e *Executor) evalCatalogFunction(name string, fn *parser.FunctionCall, arg
 	}
 
 	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")
@@ -2133,12 +2201,13 @@ func (e *Executor) evalCatalogFunction(name string, fn *parser.FunctionCall, arg
 	}
 }
 
-// currentSchemaName reports the first schema in the session search path. The
+// 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 {
-	if len(e.searchPath) > 0 && e.searchPath[0] != "" {
-		return e.searchPath[0]
+	path := e.effectiveSearchPath()
+	if len(path) > 0 && path[0] != "" {
+		return path[0]
 	}
 	return publicSchemaName
 }
@@ -2159,14 +2228,14 @@ func (e *Executor) catalogCast(expr *parser.CastExpr, value interface{}) (interf
 			if typeName == "REGTYPE" {
 				oid, found := snapshot.typeOIDForName(text)
 				if !found {
-					return nil, true, fmt.Errorf("type %q does not exist", text)
+					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, fmt.Errorf("relation %q does not exist", text)
+				return nil, true, pgerror.UndefinedTable("relation %q does not exist", text)
 			}
 			name, _ := snapshot.regclassName(oid)
 			return RegOID{oid: oid, name: name}, true, nil

+ 308 - 5
pkg/executor/pg_catalog_test.go

@@ -1,9 +1,12 @@
 package executor
 
 import (
+	"errors"
 	"fmt"
 	"strings"
 	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 )
 
 // setupPGCatalogFixture creates a schema that exercises every catalog-backed
@@ -395,13 +398,14 @@ func TestPGCatalogRejectsWrites(t *testing.T) {
 		`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_catalog.pg_class (relname)`,
-		`ALTER TABLE pg_catalog.pg_class ADD COLUMN nope TEXT`,
+		`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_catalog.pg_class AS SELECT 1`,
+		`CREATE VIEW pg_class AS SELECT 1`,
 	} {
-		if _, err := execSQL(e, sql); err == nil {
-			t.Fatalf("%s: catalog write must be rejected", sql)
+		_, err := execSQL(e, sql)
+		if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Fatalf("%s: err = %v, want 0A000 feature_not_supported", sql, err)
 		}
 	}
 }
@@ -455,3 +459,302 @@ func TestPGCatalogDoesNotChangeSQLiteIntrospection(t *testing.T) {
 		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`,
+	} {
+		_, 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")
+}

+ 4 - 0
pkg/executor/result_types.go

@@ -485,6 +485,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"
 	}

+ 52 - 6
pkg/executor/session_settings.go

@@ -23,6 +23,44 @@ 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)
@@ -48,7 +86,19 @@ func (e *Executor) executeSet(stmt *parser.SetStmt) (*Result, error) {
 		if len(known) == 0 {
 			known = []string{publicSchemaName}
 		}
-		e.searchPath = known
+		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
@@ -79,11 +129,7 @@ func (e *Executor) settingValue(name string) (string, error) {
 	var value string
 	switch name {
 	case "search_path":
-		if len(e.searchPath) == 0 {
-			value = publicSchemaName
-		} else {
-			value = strings.Join(e.searchPath, ", ")
-		}
+		value = strings.Join(e.effectiveSearchPath(), ", ")
 	case "server_version":
 		value = version.PostgreSQLCompatVersion
 	case "server_version_num":

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

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

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

+ 21 - 1
pkg/pgserver/connection.go

@@ -466,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 {
@@ -803,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 {
@@ -996,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
@@ -1602,6 +1619,9 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 		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":
@@ -1651,7 +1671,7 @@ func (c *Connection) getTypeSizeForType(typeName string) int16 {
 		return 8
 	case "DATE":
 		return 4
-	case "OID", "REGCLASS", "REGTYPE":
+	case "OID", "REGCLASS", "REGTYPE", "REGPROC":
 		return 4
 	case "NAME":
 		return 64

+ 61 - 0
pkg/pgserver/pg_catalog_test.go

@@ -93,3 +93,64 @@ func TestSetAndShowSearchPathOverWire(t *testing.T) {
 		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)
+	}
+}

+ 40 - 0
pkg/pgserver/pgx_catalog_test.go

@@ -2,7 +2,10 @@ package pgserver
 
 import (
 	"context"
+	"errors"
 	"testing"
+
+	"github.com/jackc/pgx/v5/pgconn"
 )
 
 // A stock pgx connection introspects the catalog with its normal
@@ -111,3 +114,40 @@ func TestPGXCatalogIntrospection(t *testing.T) {
 		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)
+	}
+}

+ 2 - 1
pkg/pgserver/result_binary.go

@@ -40,6 +40,7 @@ func canEncodeBinaryResult(oid int32) bool {
 		701,  // float8
 		1043, // varchar
 		1700, // numeric
+		24,   // regproc
 		2205, // regclass
 		2206, // regtype
 		3802: // jsonb
@@ -144,7 +145,7 @@ func appendBinaryResultValue(dst []byte, oid int32, value interface{}) ([]byte,
 			return append(dst, reg.String()...), nil
 		}
 		return append(dst, textValueString(value)...), nil
-	case 26, 2205, 2206: // oid, regclass, regtype
+	case 26, 24, 2205, 2206: // oid, regproc, regclass, regtype
 		n, err := catalogOIDBinaryValue(value)
 		if err != nil {
 			return dst, err