Skip to content

PostgreSQL v14 compatibility campaign#51

Merged
tamnd merged 165 commits into
mainfrom
fix/v14-compat
Jun 13, 2026
Merged

PostgreSQL v14 compatibility campaign#51
tamnd merged 165 commits into
mainfrom
fix/v14-compat

Conversation

@tamnd

@tamnd tamnd commented Jun 13, 2026

Copy link
Copy Markdown
Owner

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

  • Engine-agnostic frontend and the shared Dialect-parameterized SQL compiler (ir, plan, httpapi, pgerr, pgtypes, schema, backend/sqlgen, openapi, rpc).
  • The postgres backend: dialect and lowering, session/transactions/security, introspection, the native RPC path, and result/error passthrough.
  • Conformance harness wired for postgres as the reference backend (empty allowlist), with a checked-in corpus and an in-process live replay.

Highlights

  • Native RPC path: the three empirically confirmed blockers are fixed (the count=exact panic on GET /rpc, the argument-misrouting 404, and POST /rpc ignoring select/filters/order/limit/offset), with post-filters, counts, schema and JSON arg binding, and return shaping from proretset.
  • Dialect and lowering: full-text respects tsvector columns, boolean equality is gated on the resolved column type, quantified and range operators lower to the real operators, casts pass through with an injection guard, JSON array payloads bind by target column type, wide embeds chunk past the json_build_object argument cap, in-lists lower to = ANY, and targetless upserts degrade to a plain insert.
  • Session and security: per-request search_path from the active profile plus db-extra-search-path, impersonated role settings, hoisted transaction settings, request.jwt.claims carrying the resolved role, response.status/headers read on read paths, and native RPC access mode following volatility.
  • Introspection and results: view FK inference, computed fields and computed relationships, temporal/range result rendering, and the SQLSTATE-to-status table aligned with PostgREST.
  • P11 feature set: computed fields, computed relationships, and data representations (domain casts applied by name across read/filter/write/RETURNING).
  • Error envelope parity verified against PostgREST including the PGRST family and the 42501/57P01/53400 mappings.

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.

tamnd added 30 commits June 10, 2026 19:20
…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.
tamnd added 29 commits June 13, 2026 18:33
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.
@tamnd tamnd merged commit 5fd6f73 into main Jun 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant