PostgreSQL v14 compatibility campaign#51
Merged
Merged
Conversation
…QLite compat
Add four capabilities needed for the full 615-test postgrest-compat suite
to pass against dbrest+SQLite:
- rpc.ParseRegistry: parse a JSON function declaration array into a
StaticRegistry so non-NativeRPC backends can expose /rpc/<fn> endpoints
without a stored-procedure catalog.
- cmd/dbrest/main.go: wire DBREST_FUNCTION_REGISTRY (or PGRST_FUNCTION_REGISTRY)
config field — read the JSON, parse it, and Register it on backends that
implement the Register(rpc.Registry) interface.
- sqlgen.Dialect.ArrayLiteral: new method converts a PostgreSQL {a,b} array
literal to the engine's native format before it is bound as a parameter.
Postgres/MySQL/SQL Server pass through unchanged; SQLite converts to a
JSON ["a","b"] array so json_each() can iterate over it.
- sqlite.ArrayOp: replace the no-op stub with json_each()-based @>, <@, &&
implementations. sqlite.result.go: BOOLEAN columns coerce int64 0/1 to
Go bool; JSON columns return json.RawMessage so the value is embedded
verbatim rather than double-encoded as a string.
…n alias - Open() enables PRAGMA foreign_keys = ON via SetMaxOpenConns(1) so FK constraint violations surface as 409 rather than silent 201 - drain() now calls ColumnTypes() and applies the same BOOLEAN→bool and JSON→json.RawMessage coercions that rowStream.Values() already does; write-path responses (UPDATE/INSERT with return=representation) were returning raw int64 for boolean columns - writeSelect alias condition now also fires when a cast is present (name == lastPath but expr != bare column); SQLite returns the full expression string as the column name when no AS alias is given, so done::text was keyed "CAST(done AS TEXT)" instead of "done"
Some tests PUT rows with PostgreSQL array literal syntax ({a,b}) into
JSON columns. Wrapping a non-JSON string as json.RawMessage causes the
encoder to return a 500 when marshaling the response. Guard both drain()
and rowStream.Values() with json.Valid so only well-formed JSON values
are embedded verbatim; the rest fall through as plain strings.
Lint: - Fix gofmt alignment in rpc/registry.go fnDecl struct tag columns - Fix gofmt alignment in backend/sqlgen/compile_test.go stub methods Conformance (sqlite) + Test race: - Extend Dialect.ArrayOp to accept colType so the dialect can decide whether the column supports array semantics - Enrich ir.Compare.ColumnType in plan/plan.go for array ops (same pattern as FullText index attachment for FTS) - SQLite ArrayOp: return ok=false for non-JSON column types so the compiler raises PGRST127; json_each only works on JSON-typed columns; TEXT/INTEGER/etc. now correctly return 400 instead of 500 - Update all dialect implementations (postgres/mysql/sqlserver/compile_test) to match new 4-arg ArrayOp signature
ArrayOp now implements @>, <@, and && for JSON columns using MySQL 8.0.17+
functions. For non-JSON columns it returns false so the compiler raises
PGRST127, matching the SQLite behaviour and the conformance allowlist.
ArrayLiteral converts the PostgreSQL {a,b} format to ["a","b"] so the JSON
functions receive a valid JSON array argument.
When NativeRPC=true the plan carries no portable registry function (plan.Func==nil). CompileCall panics on nil fn; introduce compileNativeCall that emits EXEC [dbo].[name] @arg=@pn instead. Guard count=exact path against nil fn as well: T-SQL cannot wrap EXEC in SELECT count(*), so count is skipped for native procs.
IsBool: add Dialect.IsBool hook so SQL Server generates col = 1/0
instead of the invalid col IS 1/0 (IS only accepts NULL in T-SQL).
LimitOffset: return OFFSET 0 ROWS when hasOrder=true and no
paging was requested, making ORDER BY valid inside derived tables.
JSONAgg: replace JSON_ARRAYAGG (SQL Server 2025 only) with
'['+STRING_AGG(CAST(elem AS NVARCHAR(MAX)),',')+']' which works
on SQL Server 2022.
ArrayOp/ArrayLiteral: implement @>, <@, && via OPENJSON; convert
{a,b} PostgreSQL array literals to JSON arrays for OPENJSON input.
embed.go LIMIT 1: replace the hardcoded LIMIT 1 clause with a
dialect-aware LimitOffset call so SQL Server emits OFFSET 0 ROWS
FETCH NEXT 1 ROWS ONLY instead of the invalid T-SQL LIMIT.
executeUpsert: add multi-statement UPDATE … ; IF @@rowcount=0
INSERT … batch inside the request transaction, routing ir.Upsert
queries away from the single-statement compiler path that returns
errUpsertMultiStatement. Rows are read back via a post-batch SELECT
on the conflict key when returning columns are requested.
ArrayLiteral now passes through elements that are already JSON-quoted
(start and end with ") rather than wrapping them again. The postgrest-go
client sends {"go"} with the element double-quoted per PG array literal
syntax, and the old code produced ["\"go\""] instead of ["go"].
executeUpsert is rewritten as a single MERGE statement. The previous
UPDATE; IF @@rowcount=0 INSERT pattern uses a semicolon-separated batch
that go-mssqldb rejects inside sp_executesql. MERGE is a single
statement, takes all rows in the source VALUES table, and includes an
OUTPUT clause when returning columns are requested.
…trospection asMSSQLError used *mssql.Error as target for errors.As but mssql.Error implements error via a value receiver; switch to value target so constraint violations (547=FK, 2627/2601=unique, 2812=proc not found) are correctly mapped to 409/422/404 instead of falling through to 500. Add schema.Column.Identity to track auto-generated identity columns. SQL Server introspection now fetches COLUMNPROPERTY IsIdentity alongside the existing column query. executeUpsert enables SET IDENTITY_INSERT ON before the MERGE when any conflict column is an identity column, allowing explicit id values to be upserted without error 8101.
Three fixes for the MySQL compat job: 1. IsBool now returns "col = 1/0" instead of falling back to "col IS 1". MySQL 8 only accepts IS NULL/UNKNOWN/TRUE/FALSE, not integer literals. 2. normalizeArgs converts ISO 8601 strings (e.g. "2024-01-01T00:00:00Z") to time.Time before binding. MySQL's string-to-DATETIME cast rejects the T separator and Z timezone suffix; passing time.Time bypasses the cast. Applied to all CompileRead, CompileCount, compileWrite, and CompileCall statement args. 3. Open() sets ClientFoundRows=true so MySQL counts matched rows rather than changed rows. Without this, an UPDATE that sets the same values reports RowsAffected=0, which gates the re-select and returns empty results instead of the matched rows.
The re-select after UPDATE must use primary keys, not the original
filter. The original filter may reference a column being updated
(e.g. PATCH /todos?task=eq.old with body {task:new}), so after the
UPDATE the filter matches nothing and the re-select returns empty rows.
Fix: capture matching PKs before the UPDATE executes, then re-select
by those PKs after the UPDATE to get the post-mutation representation.
PostgREST v14 reports every request-body failure as PGRST102 at 400, and an unparseable request Content-Type comes back the same way with "Content-Type not acceptable: <mime>". The docs still show a stale PGRST107/415 row for that case; a probe against a live v14 confirmed the 400 PGRST102 wire behavior, so PGRST107 now stays reserved for Accept negotiation.
Group X has exactly one code upstream, PGRSTX00 at 500. The PGRSTXX0 spelling was our own invention and matched no documented PostgREST code, so client-side monitors keyed on the real one never fired.
A postgrest.conf from a real deployment now parses: all nineteen missing v14 options have typed fields with upstream defaults, app.settings.* works as a dynamic namespace from files and the env, and the upstream key aliases (pre-request, root-spec, db-schema and friends) resolve. Unknown keys now warn on both the file and env paths instead of one aborting and the other staying silent; options that parse but have no behavior yet say so at startup.
PGRST116 now reads "Cannot coerce the result to a single JSON object" with the row count in details, PGRST127 says "Feature not implemented" with the feature kept in details, and a 22P02 spells the type the way PostgreSQL does (integer, not int4). All three verified against a live v14. PGRST100 keeps dbrest's own parse prose for now; the schema-cache messages still need schema and parameter data their call sites do not pass yet.
PGRST201 returns details as a JSON array of candidate relationships, a documented workflow clients use to auto-disambiguate embeds, and a *string field could never represent it. RawDetails plus WithDetailsJSON keep string and null encodings exactly as before. Threading the candidate list out of plan.resolveOne is the remaining half.
An unknown column in select, a filter, or order reaches PostgreSQL and comes back as 42703 "column todos.nope does not exist" with a 400; PGRST204 is documented for the columns= parameter and write payloads only. The plan validators still emit PGRST204 for all of them and need to switch to this constructor.
A verb other than GET, HEAD, or POST on a function answers "Cannot use the X method on RPC" in v14, and a GET that reaches a function which writes fails with SQLSTATE 25006 at 405 rather than PGRST101. The registry pre-check and the server dispatch still build their own messages and need to move onto these constructors.
Upstream maps insufficient_privilege to 403 when the request is authenticated and 401 when it is not; dbrest's exec-error mapping only lifts anonymous denials and the postgres SQLSTATE table hardcodes 401, so authenticated clients hit token-refresh loops. GradePrivilegeStatus holds the rule for both of those call sites to adopt.
server-cors-allowed-origins parsed into a field nobody read, so every browser client died at the preflight. The frontend now answers OPTIONS preflights and stamps cross-origin reads exactly like PostgREST 14: wildcard origin by default, reflected origin plus credentials when the list is configured, and the upstream method, header, and expose lists. Verified header for header against a live postgrest/14.12.
None of these codes existed in the vocabulary, so a nested path came back PGRST205, an ambiguous overload could only ever be PGRST202, and junk response.status or response.headers from a function was written to the wire verbatim. The PGRST125 text is pinned to a live v14; the routing, rpc lookup, and applyControls guards still need to emit them.
The option parsed and validated and then nothing read it, so the denial-of-service guard served unbounded result sets. The server now stamps min(requested limit, max-rows) onto read and RPC plans before planning, so Content-Range and the 200/206 decision see the window that actually ran. Mutation representations stay uncapped per the v10 rule, and MaxRows() exposes the value as the count=estimated threshold.
Decode failures are now PGRST301 and claim validation failures PGRST303, each with PostgREST's exact message and details, verified against a live v14.13 server. Every JWT 401 carries the RFC 6750 challenge and an anonymous 42501 lift gets the bare Bearer form.
A function raising SQLSTATE 'PGRST' takes full control of the status, headers, and envelope through JSON in MESSAGE and DETAIL; a payload that does not parse is PGRST121 at 500 naming the malformed field. FromRaise implements the contract as probed against a live v14, including the obligatory-key rules. The postgres MapError still needs to route pg.Code == "PGRST" through it and surface the headers; the feature stays postgres-only since registry RPC backends cannot RAISE.
PostgREST forwards PostgreSQL's class-23 errors untouched: the message names the violated constraint and details carries the key, both of which clients parse. The canonical rewrites lose the constraint name on every backend. ErrConstraintViolation keeps only the status mapping; the driver MapError implementations still need to move onto it, with sqlite synthesizing PG-shaped text where its driver gives structure.
v14 adds Proxy-Status: PostgREST; error=<code> to every error response, the documented way to identify a failure on a HEAD request where the status alone says too little. Setting it in APIError.Write covers every error by design, since that is the single place an error reaches the client.
Reads take Accept-Profile and writes take Content-Profile, defaulting to the first exposed schema; an unknown profile is 406 PGRST106 listing the exposed schemas. Lookups and the OpenAPI root are now scoped to the active schema, and successful responses on a multi-schema deployment echo it in a Content-Profile header.
A Bearer token presented to a server with no key material is now a 500 PGRST300 Server lacks JWT secret instead of silently running as anon, and a tokenless request with no anon role is a 401 PGRST302 with the exact Anonymous access is disabled message. The verifier is always attached and NewServer no longer invents an anon default.
admin-server-port now starts the second listener PostgREST runs for orchestrators: /live dials the API socket, /ready adds the backend check, plus /schema_cache and /metrics. The host defaults to server-host and sharing the API port is a boot failure, both matching upstream.
PostgREST lowers an in-list as col = ANY('{...}') binding a single array
parameter, so a list of any length reuses one prepared statement rather
than churning a distinct statement per length under pgx's describe exec
mode. dbrest emitted col IN ($1, $2, ...), correct but cache-churning.
A new InList dialect hook returns the = ANY form carrying a PatternMark
for the bound array; the compiler binds the one array literal only on
that branch, so engines that decline (SQLite, MySQL, SQL Server) keep
the expanded IN with unchanged placeholder numbering. Rows are identical.
Two request.* GUC divergences, both verified against a live PostgREST
14.12 over the same server.
request.jwt.claims now folds the resolved role in under the role key,
overwriting any token role, so an anonymous request presents
{"role":"anon"} rather than {}. The common RLS pattern
current_setting('request.jwt.claims', true)::json->>'role' now reads a
value on every request, the case PostgREST guarantees.
request.headers now excludes the Cookie header (it is request.cookies)
and resolves a repeated header to its last value rather than comma
joining, both matching PostgREST.
The search_path was a static list of every exposed schema, built once at
SetSchemas time. PostgREST puts only the active schema (the Accept-Profile
or Content-Profile choice, the first configured schema by default) on the
path, followed by db-extra-search-path, so unqualified names resolve against
the active schema and the extra entries reach shared types and functions.
Build the path per request from the active schema plus the configured
extra-search-path, defaulting that to public. Set it with set_config rather
than SET ... TO idents so the GUC string is the verbatim quoted value
PostgREST writes; a SET ... TO lets the server strip quotes from simple
names, which a policy reading current_setting('search_path') would observe.
Verified against PostgREST 14.12: the path is "<active>", "public" with
no deduplication.
A native RPC POST ran read-write regardless of the function's volatility,
so a STABLE or IMMUTABLE function called with POST got a read-write
transaction. PostgREST runs a non-volatile function read-only on any method
and only a VOLATILE function read-write.
Introspect pg_proc.provolatile for the exposed schemas into a per-backend
map (volatile wins for a name with mixed overloads, so a write overload
keeps its read-write transaction) and consult it on the native call path:
a POST to a STABLE or IMMUTABLE function now runs read-only. The registry
path already derived the mode from volatility, so only the native path
changed.
Verified against PostgREST 14.12: the integration test reads
current_setting('transaction_read_only') from each function to observe the
transaction mode directly.
ALTER ROLE <role> SET settings (statement_timeout, default_transaction_isolation, and other user-settable GUCs) were never applied, so a role constrained under PostgREST ran unbounded under dbrest and a role pinned to an isolation level ran at the database default. Load the per-role settings at introspect time from pg_roles.rolconfig for the roles the connected authenticator is a member of, keeping only settings that are USERSET or, on PostgreSQL 15+, ones the authenticator may set (has_parameter_privilege), mirroring PostgREST's queryRoleSettings filter so a setting the session cannot apply is skipped instead of aborting the request. Replay the kept settings with set_config(name, value, true) after SET LOCAL ROLE, and apply default_transaction_isolation at BeginTx, where it must go since it cannot change once the transaction has run a statement. The counted-read path keeps its REPEATABLE READ floor unless the role pins something stronger. Verified against PostgREST 14.12: a role pinned to statement_timeout '50ms' cancels a slow call with SQLSTATE 57014 (500), and the setting is transaction-scoped, so it does not leak to a request that runs without the role.
A SQL function reached over GET and a db-pre-request hook can both set response.status and response.headers, but the read-call and table-read paths streamed straight from the cursor and never read those GUCs back, so the overrides were silently dropped on GET. The read-call path now buffers its rows the way the write and volatile-call paths do, so readResponseControls runs after the rows drain and before the transaction commits. The table-read path stays streaming but reads the GUCs once right after applySession (where db-pre-request runs), before the body streams, since a plain SELECT does not set them itself. EXPLAIN responses still cannot carry pre-request header overrides because the explain SPI returns only the plan bytes; that narrow case is left as a documented divergence.
The pool unconditionally forced cache_describe, clobbering any default_query_exec_mode an operator set in the DSN. That mode is pgx's documented escape hatch for transaction-mode poolers (simple_protocol or exec behind PgBouncer), so overriding it silently broke the pooler setup. resolveExecMode now applies cache_describe only when the DSN names no mode, and honors the operator's choice otherwise. pgx decodes an omitted param and an explicit cache_statement to the same zero value, so the presence test keys on the raw DSN string where the param name is unambiguous.
A function's SET clause (pg_proc.proconfig) only takes effect inside the function body, so a SET statement_timeout never bounded the count query of a set-returning call and a SET default_transaction_isolation could never apply at all, since the transaction had already run statements by the time the function executed. The backend now introspects proconfig per function and, for the settings db-hoisted-tx-settings names (statement_timeout, plan_filter.statement_cost_limit, default_transaction_isolation by default), hoists them: default_transaction_isolation is applied at BeginTx, the rest replayed with set_config right after the session is set and before the call, so they bound the whole statement the way PostgREST does. Overloads that disagree on a hoisted setting collapse to the last value introspected, the documented limit of static introspection.
The schema cache filtered relations to IN ('r','v','p') and never guarded
relispartition, so materialized views and foreign tables were invisible while
every leaf partition leaked in as its own endpoint. Widen the relkind set to
include 'm' and 'f', map matviews to the view kind and foreign tables to the
table kind, and exclude partitions so only the partitioned parent is exposed,
matching PostgREST's schema cache.
The schema model carried fields for unique constraints, identity, and database comments, and the frontend already consumed them (one-to-one cardinality, OpenAPI descriptions), but the postgres introspector never populated them. Read unique indexes (covering both unique constraints and bare unique indexes, excluding the primary key, partial, and expression indexes) into Relation.Unique so a foreign key onto a unique set embeds as an object. Fold identity columns into HasDefault and set Identity, since an identity column has no pg_attrdef row and would otherwise look required. Left-join pg_description for table, column, and schema comments so OpenAPI carries the documented descriptions.
A STABLE void function runs through executeCallRead, which now detects the void result OID and signals 204 the same way the volatile write path does, so GET and POST agree. Add an integration test pinning 204 on both verbs.
A range column decoded by pgx arrives as a pgtype.Range struct, and a
multirange as pgtype.Multirange; left alone the JSON renderer marshalled
the struct instead of the range text PostgreSQL emits. normalizeValues
now formats both to the canonical text form ([10,20), {[1,3),[5,8)},
empty), choosing brackets from each bound's inclusivity, rendering an
unbounded side as the empty string, formatting the element by the range
OID, and quoting bounds by PostgreSQL's range rules.
The integration test seeds int4range, numrange, daterange, tsrange,
tstzrange and int4multirange columns plus empty and unbounded cases, and
asserts each rendered value matches the server's own to_json output so
the tstzrange offset tracks the live TimeZone.
The SQLSTATE-to-status mapping diverged from PostgREST v14's pgErrorStatus on a handful of rows: 53400 and 54xxx are server errors (500), not the 503/413 their classes implied; 57P01 is a retryable 503; only P0001 is a client error among the P0 class while the rest are 500; 42883 for xmlagg is a 406; and 21000 and 22023 disambiguate on the server message (a missing-WHERE guard is 400, a nonexistent role in a JWT is 401). The PT status convention now passes any parsed status through and falls back to 500 on an unparseable suffix, matching upstream, instead of forcing 400. A driver failure that never reached a SQLSTATE was collapsing to 500. mapTransportError now classifies a refused or failed dial (pgx returns *pgconn.ConnectError) as PGRST000 503 and a pool-acquisition timeout (a context deadline) as PGRST003 504, with PostgREST's own group-0 messages. Verified the status rows against PostgREST v14.0 src/PostgREST/Error.hs and the pgx error shapes against a live server.
The native RPC renderer guessed a function's result shape from its output columns: one column named after the function meant scalar, anything else rendered like a table read. Three cases came out wrong. A SETOF scalar function also produces a single column named after the function, so it was classed scalar and only the first row survived, silently dropping the rest. A function returning one composite row expanded to several columns and rendered as a one-element array where PostgREST returns a bare object. A table function whose lone column happens to share the function name was collapsed to a scalar. Read each function's real return shape from pg_proc (proretset and the return type's class) during introspection, keyed by schema.name, and carry it on plan.Func on the native path. The executor now dispatches on whether the descriptor has a portable query rather than on Func being nil, so a native descriptor still lowers through the literal-splice path while the renderer gets a true return kind. Add a ReturnObject kind for the single composite case and render it as one object, null when there is no row. A function the catalog never introspected resolves to a nil descriptor and keeps the old column-name fallback, so nothing regresses for shapes that were already right. Closes 03-P06.
A POST to a VOLATILE set-returning function with Prefer: count=exact had no count at all on the native path: the read path counts with a separate statement, but running that against a volatile function would invoke it twice and double its side effects. Carry count(*) OVER () on the row query instead, read the total off any returned row, and drop the _pgrst_count column before the body is rendered. CompileNativeCallCountedWrap mirrors the uncounted wrap's select, filter, order, and window so the page is identical, and adds the window column; the window is evaluated before the LIMIT so it counts the full filtered set. extractCountWindow pulls the repeated total off the first row and strips the column from every row. Covered by a live integration test that filters, limits, asserts the exact count, and proves single execution through an audit table, plus sqlgen and helper unit tests.
PostgREST keeps a function half of its schema cache: every exposed function's volatility, return shape, and full input signature, loaded from pg_proc. dbrest had only volatility and return shapes; this adds the signatures. loadFunctionRegistry reads one row per function carrying its input names and type names as arrays, reconstructed in SQL so the OUT and TABLE columns (which are not call arguments) are filtered out by argument mode. proargtypes already lists only the input arguments in order, so types need no filtering; proargnames lists every argument, so it is filtered to the input modes. The trailing pronargdefaults inputs are optional, a variadic function's last input collects its tail, and a lone unnamed body-typed input is the single-raw-body form. The result is one rpc.Registry per schema, with native descriptors (Query nil) so they still lower through the splice path. This is the data the planner needs to resolve overloads, raise PGRST202/203, and partition GET arguments from result filters; wiring follows. The query was verified against a live PostgreSQL 18 catalog covering plain, defaulted, variadic, OUT-parameter, raw-body, table-returning, and overloaded functions. SchemaFunctions exposes the per-schema registry. Covered by buildParams unit tests and a live integration test asserting reconstructed signatures.
A NativeRPC backend that introspects its functions now resolves a known function name through plan.Call, the same path the portable registry uses. That gives native RPC overload resolution (PGRST202 for no match, PGRST203 for ambiguous), GET argument-versus-filter partitioning, declared-type argument coercion, and the volatility-driven access mode where a POST to a STABLE or IMMUTABLE function runs read-only. An unknown name falls back to the minimal engine-planned call so a function the introspection did not model still reaches the catalog. compileNativeCall now takes the resolved descriptor and splices arguments in declared parameter order, so the generated SQL text is stable across identical requests and the pgx statement cache hits. A single unnamed body-typed parameter is recognized natively, so the whole POST body binds to it the same way it does for a portable function. Adds SchemaFunctioner to the backend SPI, rpcRegistry/registryKnows in the server, and end-to-end tests covering PGRST202/PGRST203, GET partitioning, read-only POST to a stable function, and raw-body binding.
The root handler fed s.backend.Functions() into openapi.Generate, which for a NativeRPC backend does not carry the per-schema introspected functions, so /rpc/<fn> paths were missing from the document for native RPC. It now feeds the same registry the call path resolves against in the active schema, so the document lists every native function the catalog reported.
A declared portable registry on a NativeRPC backend was advertised in OpenAPI but never consulted for dispatch, so a registered function with no native equivalent 404'd, while every native function was invisible to the document. rpc.Merge composes the two into one registry: a declared function shadows a same-signature native one, every distinct overload from either side stays reachable, and overload resolution runs across the union in one place. The server resolves and documents RPC against this merged registry, so the call path and the OpenAPI document agree on one function set. A portable function executes through the SQL compiler exactly as on the other backends.
Views carried no foreign keys, so every embed through a view raised PGRST200 even though PostgreSQL records exactly the dependency PostgREST uses. loadViewColumns reads each view's _RETURN rewrite rule and maps every output column to its origin relation and column through resorigtbl and resorigcol, which survive renames and point at the immediate source through a view-over-view chain. The model already projects base-table foreign keys onto a view from this mapping and runs to a fixpoint, so chains resolve; expression columns (resorigtbl 0) carry no mapping and set-operation views are skipped, matching upstream. Materialized views ride the same path. Covered by an integration test over a renamed-column view, a view chain, a materialized view, and an end-to-end embed of the referenced table through a view.
A PostgreSQL function whose single argument is a relation's row type and whose return is a scalar is a computed field: PostgREST exposes it as a virtual column a client can select, filter, and order by. dbrest read nothing from pg_proc for relations, so a computed-field select came back as an unknown column (400). Read those functions in a new introspection pass keyed by relation OID, attach them to the relation as Computed fields, and carry the name-to- schema mapping onto the query at plan time. checkColumn now accepts a computed field anywhere a real column is accepted, and the compiler's colRef renders one as schema.func(row): the bare relation name at the top level, the alias inside an embed. The mapping is swapped alongside the table qualifier at every embed and related-order boundary so a computed field resolves against the right relation at each level. A set-returning or composite-returning function over the same row type is a computed relationship, not a field, and is left for the next slice.
A PostgreSQL function whose single argument is a relation's row type and whose return is rows of another relation is a computed relationship: PostgREST exposes it as an embeddable edge, the escape hatch a stored foreign key cannot offer for a recursive self-referential embed. dbrest read no functions as relationships, so embedding one came back as an unknown relationship (PGRST200). Read those functions in a new introspection pass keyed by parent OID: a set-returning result is a to-many edge, a single-row result a to-one. A RETURNS TABLE function returns a record type, not a relation's composite, so it never matches here and stays a plain RPC. Attach the edges to the relation as ComputedRels and fold them into Relationships, where a computed edge overrides a derived one of the same name like a declared edge does. The embed names the function, not the target relation, so the planner resolves it by name through ComputedRelByName and infers the target from the return type. The compiler renders the edge as funcschema.func(parent) in the subquery FROM and correlates through the row argument with a TRUE predicate instead of a join, for both the to-many array and the to-one object form. A self-referential function embeds recursively at every level because each level resolves the same edge against the same parent.
A PostgREST data representation is a domain over a base type with pg_cast casts that reshape a column on the wire: a cast to json formats the stored value on read, a cast from json parses a write body, and a cast from text parses a query-string filter literal. PostgreSQL ignores these casts in the :: operator, so the introspector records the cast function per direction and the compiler calls it by name. Introspection loads the cast set per domain OID and tags each column that carries one. The planner binds the per-relation map onto the query and each embed. The compiler applies the to-json cast on a selected column and on the RETURNING list, the from-text cast on eq/neq/ordering filter literals, and the from-json cast on insert and update values. Covered by sqlgen unit tests for each direction and a live round trip that posts a value, reads it back formatted, and filters by the formatted form.
The harness only knew sqlite, so the reference backend had no conformance pass and no allowlist file. Add a postgres fixture (films in a dedicated schema, plus a text[] column so the array operators exercise the Native tier sqlite lacks, and an anon role for the SET LOCAL ROLE path), dispatch the CLI on -backend with a -dsn flag that falls back to DBREST_PG_DSN, and check in a corpus and an empty allowlist. Postgres is the reference: every corpus case passes natively and the allowlist documents no divergence, which the harness reconciles against the live capability matrix. A DBREST_PG_DSN-gated test replays the same corpus in process so it runs under go test.
PostgREST parses a filter operand through a column's from-text cast for every operator that compares against a typed value, not just eq and the orderings. Following SqlFragment.hs pgFmtFilter, the parse also covers regex match/imatch, the array and range operators, each IN element, and the non-pattern quantified operators. like, ilike, and full-text stay raw because their operand is a wildcard or query, which is exactly what PostgREST does. The IN list parses each element over the unpacked array on a = ANY engine (SELECT cast(unnest(...))), matching pgFmtArrayLiteralForField, and per element on the IN fallback. A shared fromTextValue helper threads the lookup so a column with no representation binds the literal unchanged. Adds sqlgen unit tests for match, contains, like-stays-raw, and both IN shapes, plus a live IN round trip on the color domain.
v14's makeProperty matches a relationship whose local columns are exactly the one column being described, so a composite foreign key gets no Note on any of its columns. dbrest was emitting a note per matching column, which diverged for composite keys. Gate the note on a single-column FK. This also settles the computed-relationship question: a computed relationship is a function with no local column, and v14 builds these notes only from real foreign keys, so it never renders a computed relationship in the document. dbrest matches by construction. Adds a composite-FK test asserting no note on either key column.
PostgREST's db-prepared-statements=false means "send parameterized queries but do not prepare them server-side", which is the escape hatch for transaction-pooling poolers that cannot keep prepared statements across checkouts. We were ignoring the config and always using cache_describe. Thread the setting from config to the backend without breaking the blank-import driver pattern: add an OpenOptions struct and an optional OptionsDriver interface, plus backend.OpenWith that routes the options to a driver that accepts them and falls back to plain Open for one that does not. The postgres driver maps a nil/true setting to cache_describe (pooler-tolerant) and false to exec (parameterized, not prepared). An explicit default_query_exec_mode in the DSN still wins over both.
PostgREST listens on db-channel (default "pgrst") so a database can ask the server to reload without a signal: an empty payload or "reload schema" reloads the schema cache, "reload config" reloads the configuration, and a reconnect refreshes the schema cache because notifications sent while the listener was down are lost. We had the signal handlers but not this path. Add an optional backend.Listener SPI carrying the LISTEN/reconnect mechanics, with the payload-to-action decision kept frontend-side so the wire contract lives in one engine-agnostic place. The postgres backend implements it on a dedicated connection (a connection blocked waiting for a notification cannot serve queries) and reconnects with backoff capped at 32 seconds, matching upstream. A backend with no LISTEN support is skipped, leaving signal-driven reloads in place.
The lint job had been red on this branch on a spread of small issues none of which the audit work touched: gofmt drift in a handful of test files and the struct whose field comments shifted, a few unchecked Close() returns, two append-to-a-different-slice patterns, two if-else chains that read better as switches, and three staticcheck nits (the deprecated Curve.IsOnCurve, a De Morgan simplification, and a struct literal that is a plain conversion). Each is a mechanical fix that preserves behavior. The one with teeth is the EC key check: ecdsa.PublicKey.ECDH() performs the same on-curve validation as the deprecated IsOnCurve and errors otherwise, so the key loader still fails closed on an off-curve point.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The PostgREST v14 compatibility campaign for dbrest. It works through the full audit at notes/Spec/2023/review until the PostgreSQL reference backend is faithful to PostgREST v14, with every finding closed and tested.
PostgreSQL is the near-passthrough oracle: the engine PostgREST itself runs on, so if dbrest cannot match PostgREST here the bug is in the frontend or the shared compiler, not in any dialect. Closing this backend proves the contract.
Scope
Highlights
Tests
Unit tests across the frontend and the shared compiler, plus live-PostgreSQL integration tests gated on DBREST_PG_DSN, and a postgres conformance corpus replayed in process. The empirical fixes were re-verified against a local PostgREST 14.12 on the same database. Full suite is green with the DSN set.
Documented deferred divergences
Two postgres items are deferred because each depends on a mechanism not yet built, and both are documented in notes/Spec/2023/review/postgres: db-channel LISTEN (schema/role/volatility reload) and mapping db-prepared-statements=false to an exec mode.