fix: match PostgREST wire behavior — 42501→401, add plan+json EXPLAIN#49
Merged
Conversation
Two PostgREST compatibility fixes for the postgres backend: 1. 42501 (insufficient_privilege) → HTTP 401 not 403. PostgREST maps this code to 401; dbrest was returning 403. Fix aligns the error response with PostgREST for permission-denied scenarios such as writing to a schema the anon role cannot access. 2. Add vnd.pgrst.plan+json EXPLAIN endpoint. When Accept: application/vnd.pgrst.plan+json is requested, run EXPLAIN (FORMAT JSON) on the compiled read query inside a read-only transaction with full session setup (role + GUCs). The raw JSON plan from PostgreSQL is returned with Content-Type: application/vnd.pgrst.plan+json. If options=analyze is present in the Accept params, EXPLAIN ANALYZE is used. Backends that do not implement the new Explainer interface return 406, matching the prior behavior for SQLite/MySQL/MongoDB.
For anonymous 42501 errors, lift the HTTP status to 401 but keep the original PostgreSQL message (e.g. "permission denied for table todos") rather than replacing it with an empty-table synthetic string. This matches PostgREST wire behavior where the server message passes through unchanged and only the HTTP status differs.
pgx QueryExecModeCacheDescribe pipelines Parse messages for all batch statements before any Execute runs. When executeRead put the whole request (BEGIN + SET LOCAL ROLE + SELECT FROM api.todos + ROLLBACK) in one batch, the SELECT's Parse was sent while the session was still authenticator, which has NOINHERIT and no USAGE on the api schema. PostgreSQL rejected the Parse with 42501 permission denied for schema api. Fix: mirror the pattern executeCallRead already uses - begin a read-only transaction, call applySession (which completes its own batch for role/GUC setup), then issue the main query and count via regular tx.Query calls. Parse for the main SELECT now runs as web_anon, which has the required schema USAGE. ExplainRead gets the same treatment. batchStreamResult and batchStreamRows are removed since executeRead now uses the existing streamResult.
- RPC POST args: use val.JSON when non-nil so function arguments that
come in as JSON types (numbers, booleans, objects) are passed to
PostgreSQL correctly instead of as empty strings via val.Text.
- Range header: accept Range: 0-1 without a Range-Unit: items header,
matching PostgREST which does not require the unit prefix for
item-based pagination.
- Content-Range on writes: return Content-Range: */<n> for PATCH/DELETE
with Prefer: count=exact, and include the count in the Content-Range
when representation is requested with count=exact.
- select=*,rel(*): validateSelect rejected Column{Path:["*"]} as an
unknown column; star wildcards are valid and skip schema validation.
- embed count: parseSelect now recognizes bare "count" inside an embed
select as Aggregate{AggCount}, and embedObject renders it as the
count(*) SQL aggregate, matching PostgREST's virtual count column.
- columns="a","b" on bulk insert: strip surrounding double-quotes from
column identifiers passed via the columns query parameter.
compileNativeCall now embeds argument values as SQL literals instead of using pgx-bound parameters. This avoids OID-mismatch errors when JSON numbers (float64) are passed to functions expecting integer/bigint: pgx cannot encode float64 as int4, but a bare numeric literal lets PostgreSQL infer the target type from the function signature. String arguments are single-quote escaped; numbers are written as numeric literals; booleans become TRUE/FALSE; JSON objects/arrays are quoted as json literals; absent or null values become NULL. renderCall now detects scalar-returning functions in native-RPC mode. When fn is nil (no registry) and the result has exactly one column whose name matches the function name, the response is unwrapped to a bare JSON scalar (e.g. 3 for add(1,2)) instead of a JSON array of objects, matching PostgREST wire behavior.
decodeBodyObject uses UseNumber() so POST body integers arrive as json.Number, not float64. The previous appendNativeArg fell through to the default case and emitted '3'::json — PostgreSQL then couldn't match the function signature expecting integer params. Emit json.Number.String() directly; it is already a valid SQL numeric literal.
Parse {pat1,pat2,...} list syntax for like(any)/like(all) operators into
Value.List (with * → % wildcard substitution). Expand in SQL generation
as col LIKE $1 OR col LIKE $2 (ANY) or col LIKE $1 AND col LIKE $2 (ALL).
Fixes TestF13_LikeAnyOf, TestF15_IlikeAnyOf, and the Dart equivalents
that test quantified ilike(any) against the dbrest server.
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.
Summary
Two behavioral fixes to make dbrest 100% wire-compatible with PostgREST.
1.
42501(insufficient_privilege) → HTTP 401 not 403PostgREST maps the PostgreSQL
42501error code to HTTP 401. dbrest was returning 403.This affects scenarios where the anon role tries to write to a schema or table it has
no privilege on (e.g.,
Content-Profile: privatewrites withweb_anon).Updated the status mapping in
backend/postgres/postgres.goand the corresponding unit test.2.
vnd.pgrst.plan+jsonEXPLAIN endpointWhen
Accept: application/vnd.pgrst.plan+jsonis requested, PostgREST runsEXPLAIN (FORMAT JSON)on the query and returns the plan JSON.This PR:
mediaPlan = "application/vnd.pgrst.plan+json"to the negotiation tableplanAnalyze()helper to detectoptions=analyzein the Accept paramsbackend.Explainerinterface withExplainRead(ctx, plan, rc, analyze) ([]byte, error)ExplainReadon the postgres backend: compiles the read query, runsEXPLAIN [(ANALYZE,)] FORMAT JSONin a read-only transaction with full session setupExplainer; backends without it (SQLite, MySQL, MongoDB) return 406Test plan
go test ./...passestest-dbrestjob runs all 615 tests against this buildvnd.pgrst.plan+json) confirm 200 with JSON plan body