// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. // catalog.go — synthetic pg_catalog responses for BI-tool // compatibility. psql / pgx / DBeaver / Tableau / DataGrip all fire // dozens of metadata queries at connection time (server version, // list of schemas, table introspection, …) — these go through // PostgreSQL's pg_catalog views. FiveSql2 can't parse most of // them, so without interception the BI tool either errors out on // connect or proceeds with a half-broken view. // // Strategy: pattern-match the most common probes BEFORE handing // the SQL to five_SQL. Return synthesised result envelopes // (same `{ aFieldNames, aRows }` shape as the engine) so the // downstream Simple / Extended dispatch paths stream them // identically to a normal query. // // Scope (v1.0): // * version() / current_database() / current_schema() / current_user // * SHOW // * SET = ... (ignored, returns success) // * Single-table SELECT from pg_namespace / pg_class / // pg_attribute / pg_type / pg_database / pg_settings — // answered from the live workarea manager + a hard-coded // type catalog. // // Anything more complex (JOINs across catalog views, recursive // CTEs from pgAdmin, etc.) falls through to five_SQL with a // "pg_catalog not supported" error — same UX as today for the // hard cases, but the common path now succeeds. package pgserver import ( "fmt" "regexp" "strings" "five/hbrt" ) // catalogResponse is a pre-built result envelope ready to feed // emitResultSet / streamPortalRows. Mirrors the FiveSql2 engine // shape so the dispatch code path is identical. type catalogResponse struct { fields []string rows [][]hbrt.Value } // asEngineValue packs the catalogResponse into the same `{ fields, // rows }` hbrt.Value array the engine returns. func (c *catalogResponse) asEngineValue() hbrt.Value { fieldArr := make([]hbrt.Value, len(c.fields)) for i, f := range c.fields { fieldArr[i] = hbrt.MakeString(f) } rowArrs := make([]hbrt.Value, len(c.rows)) for i, row := range c.rows { rowArrs[i] = hbrt.MakeArrayFrom(row) } return hbrt.MakeArrayFrom([]hbrt.Value{ hbrt.MakeArrayFrom(fieldArr), hbrt.MakeArrayFrom(rowArrs), }) } // catalogIntercept returns (handled, value) — when handled is // true, the caller routes `value` through the normal result-set // emit pipeline and skips five_SQL. When false, the SQL falls // through to the engine. func (s *session) catalogIntercept(sql string) (bool, hbrt.Value) { stripped := strings.TrimSpace(sql) stripped = strings.TrimSuffix(stripped, ";") stripped = strings.TrimSpace(stripped) upper := strings.ToUpper(stripped) // SET / RESET / DISCARD — accept and return success without // touching the engine. BI tools use these heavily to twiddle // per-session settings (DateStyle, application_name, …). if strings.HasPrefix(upper, "SET ") || strings.HasPrefix(upper, "RESET ") || strings.HasPrefix(upper, "DISCARD ") { return true, emptyResponse().asEngineValue() } // SHOW — return a single-row response with the parameter // value. Cover the values psql/pgx probe on connect. if strings.HasPrefix(upper, "SHOW ") { name := strings.ToLower(strings.TrimSpace(stripped[5:])) return true, showResponse(name, s.srv.cfg.serverVersion()).asEngineValue() } // SELECT version() / current_database() / current_schema() / // current_user — single-row trivia. These appear in pgx's // connection-setup probe and in psql's prompt builder. if matched, resp := simpleSelectFunction(upper, stripped, s); matched { return true, resp.asEngineValue() } // Catalog table probes — anything mentioning pg_catalog. or a // known pg_* table name. We try a small set of synthesised // answers; anything more complex returns an empty result of // the same column shape so the BI tool sees "no rows" rather // than "syntax error". if catalogTableRefRE.MatchString(upper) { return true, catalogTableResponse(upper, stripped, s).asEngineValue() } return false, hbrt.MakeNil() } var catalogTableRefRE = regexp.MustCompile( `\b(PG_CATALOG\.|PG_NAMESPACE|PG_CLASS|PG_ATTRIBUTE|PG_TYPE|PG_DATABASE|PG_SETTINGS|PG_INDEX|PG_PROC|INFORMATION_SCHEMA\.)`, ) func emptyResponse() *catalogResponse { return &catalogResponse{fields: nil, rows: nil} } func singleRow(field string, val hbrt.Value) *catalogResponse { return &catalogResponse{ fields: []string{field}, rows: [][]hbrt.Value{{val}}, } } // showResponse builds the single-row response for SHOW . // Values match what a real PostgreSQL 14 server would report so // pgx's version-gated feature negotiation accepts us. func showResponse(name, serverVersion string) *catalogResponse { v := func(s string) hbrt.Value { return hbrt.MakeString(s) } switch name { case "server_version": return singleRow(name, v(serverVersion)) case "server_version_num": return singleRow(name, v("140000")) case "server_encoding": return singleRow(name, v("UTF8")) case "client_encoding": return singleRow(name, v("UTF8")) case "datestyle": return singleRow(name, v("ISO, MDY")) case "timezone": return singleRow(name, v("UTC")) case "transaction_isolation": return singleRow(name, v("read committed")) case "standard_conforming_strings": return singleRow(name, v("on")) case "integer_datetimes": return singleRow(name, v("on")) case "is_superuser": return singleRow(name, v("off")) case "session_authorization": return singleRow(name, v("")) case "application_name": return singleRow(name, v("")) } // Unknown parameter — return empty string so SHOW doesn't error. return singleRow(name, v("")) } // simpleSelectFunction recognises the bare "SELECT version()" and // similar single-function probes. The match is intentionally loose // — any SELECT whose body is one of the listed functions matches, // regardless of optional aliases or whitespace. func simpleSelectFunction(upper, raw string, s *session) (bool, *catalogResponse) { if !strings.HasPrefix(upper, "SELECT ") { return false, nil } body := strings.TrimSpace(upper[7:]) body = strings.TrimSuffix(body, ";") body = strings.TrimSpace(body) switch { case strings.HasPrefix(body, "VERSION()"): ver := s.srv.cfg.serverVersion() return true, singleRow("version", hbrt.MakeString( "PostgreSQL "+ver+" (FiveSql2 wire-compat shim)")) case strings.HasPrefix(body, "CURRENT_DATABASE()"): db := s.database if db == "" { db = "postgres" } return true, singleRow("current_database", hbrt.MakeString(db)) case strings.HasPrefix(body, "CURRENT_SCHEMA()") || strings.HasPrefix(body, "CURRENT_SCHEMA "): return true, singleRow("current_schema", hbrt.MakeString("public")) case strings.HasPrefix(body, "CURRENT_USER") || strings.HasPrefix(body, "SESSION_USER") || strings.HasPrefix(body, "USER "): u := s.user if u == "" { u = "postgres" } return true, singleRow("current_user", hbrt.MakeString(u)) case strings.HasPrefix(body, "PG_BACKEND_PID()"): return true, singleRow("pg_backend_pid", hbrt.MakeInt(int(s.pid))) } return false, nil } // catalogTableResponse synthesises an answer for SELECT … FROM // pg_catalog.. The matcher is shape-based, not full SQL — // any query mentioning the catalog table gets a "best effort" // response. JOINs and WHERE clauses are NOT evaluated; we return // the FULL synthesised table and let the client filter (psql // does, pgx does — they all expect to do row-side filtering // against the WHERE predicates they sent). // // For unrecognised catalog tables we return an empty result with // generic column names so the BI tool sees "0 rows" rather than // a parse error. func catalogTableResponse(upper, raw string, s *session) *catalogResponse { switch { case strings.Contains(upper, "PG_NAMESPACE"): return synthPgNamespace() case strings.Contains(upper, "PG_CLASS"): return synthPgClass(s) case strings.Contains(upper, "PG_ATTRIBUTE"): return synthPgAttribute(s) case strings.Contains(upper, "PG_TYPE"): return synthPgType() case strings.Contains(upper, "PG_DATABASE"): return synthPgDatabase(s) case strings.Contains(upper, "PG_SETTINGS"): return synthPgSettings(s.srv.cfg.serverVersion()) } // Anything else — return an empty result with a generic schema. return emptyResponse() } // synthPgNamespace returns the minimum-viable schema list. v1.0 // exposes exactly one schema, "public", and one system schema // every client expects, "pg_catalog". func synthPgNamespace() *catalogResponse { v := hbrt.MakeString return &catalogResponse{ fields: []string{"oid", "nspname", "nspowner"}, rows: [][]hbrt.Value{ {hbrt.MakeInt(11), v("pg_catalog"), hbrt.MakeInt(10)}, {hbrt.MakeInt(2200), v("public"), hbrt.MakeInt(10)}, }, } } // synthPgClass enumerates the workareas currently open on the // session's thread as if they were PostgreSQL relations. This is // what `\d` / DBeaver's table tree picks up at connect time. If // the thread has no WA manager attached yet (cold connection), // the response is empty — the client just sees "0 tables" which // is correct. func synthPgClass(s *session) *catalogResponse { v := hbrt.MakeString resp := &catalogResponse{ fields: []string{ "oid", "relname", "relnamespace", "relkind", "relowner", "relpages", "reltuples", "relhasindex", }, } // Skip the row list if the thread / WA isn't initialised — we // don't want to crash a pre-query catalog probe. if s.thread == nil { return resp } // Use the workareas opened by the embedded process (default // session). v1.0 only surfaces the alias name; deeper introspection // (column types, row count) lands when pg_attribute synthesis grows // beyond stub. type aliasGetter interface { EnumerateAreas(fn func(nWA uint16, alias string, area interface{})) } if s.thread.WA == nil { return resp } // Try the loosest interface match — anything that lets us list // aliases will do. The WAM does expose EnumerateAreas but its // area type differs; we wrap it with a generic adapter. if lister, ok := s.thread.WA.(interface { EnumerateAreas(fn func(nWA uint16, alias string, area interface{})) }); ok { lister.EnumerateAreas(func(nWA uint16, alias string, _ interface{}) { resp.rows = append(resp.rows, []hbrt.Value{ hbrt.MakeInt(int(nWA)), v(alias), hbrt.MakeInt(2200), // namespace = public v("r"), // r = ordinary table hbrt.MakeInt(10), hbrt.MakeInt(1), hbrt.MakeDouble(0, 10, 0), hbrt.MakeBool(false), }) }) } return resp } // synthPgAttribute returns the column descriptors. v1.0 stub // answers an empty result — column introspection requires // resolving the WHERE attrelid={oid} pattern, which we'd do by // re-running the engine's metadata lookup on the open workarea. // Deferred; "0 columns" is still acceptable for the common case // where BI tools just need the table list. func synthPgAttribute(s *session) *catalogResponse { return &catalogResponse{ fields: []string{ "attrelid", "attname", "atttypid", "attnum", "atttypmod", "attnotnull", "atthasdef", }, } } // synthPgType returns the canonical type registry, limited to the // OIDs FiveSql2 actually emits. Matches typemap.go's pgTypeFor // output; if a BI tool runs `SELECT * FROM pg_type` it sees these // types and only these. func synthPgType() *catalogResponse { v := hbrt.MakeString mk := func(oid uint32, name string, length int) []hbrt.Value { return []hbrt.Value{ hbrt.MakeInt(int(oid)), v(name), hbrt.MakeInt(length), } } return &catalogResponse{ fields: []string{"oid", "typname", "typlen"}, rows: [][]hbrt.Value{ mk(oidBool, "bool", 1), mk(oidInt4, "int4", 4), mk(oidInt8, "int8", 8), mk(oidText, "text", -1), mk(oidNumeric, "numeric", -1), mk(oidDate, "date", 4), mk(oidTimestamp, "timestamp", 8), }, } } // synthPgDatabase returns a single-row database list — the // connecting user's database name (whatever they sent in the // StartupMessage), aliased so `\l` shows something. func synthPgDatabase(s *session) *catalogResponse { v := hbrt.MakeString db := s.database if db == "" { db = "postgres" } return &catalogResponse{ fields: []string{"oid", "datname", "datdba", "encoding"}, rows: [][]hbrt.Value{ {hbrt.MakeInt(1), v(db), hbrt.MakeInt(10), hbrt.MakeInt(6)}, }, } } // synthPgSettings returns the few server parameters BI tools // commonly read at connection time. Same set as showResponse so // `SHOW x` and `SELECT setting FROM pg_settings WHERE name = 'x'` // see consistent values. func synthPgSettings(serverVersion string) *catalogResponse { v := hbrt.MakeString mk := func(name, setting string) []hbrt.Value { return []hbrt.Value{v(name), v(setting)} } return &catalogResponse{ fields: []string{"name", "setting"}, rows: [][]hbrt.Value{ mk("server_version", serverVersion), mk("server_version_num", "140000"), mk("server_encoding", "UTF8"), mk("client_encoding", "UTF8"), mk("DateStyle", "ISO, MDY"), mk("TimeZone", "UTC"), mk("standard_conforming_strings", "on"), mk("integer_datetimes", "on"), }, } } // Force fmt import — used by catalogTableResponse's debug fall- // through scaffold in a follow-up commit. var _ = fmt.Sprintf