Skip to content

fix: match PostgREST wire behavior — 42501→401, add plan+json EXPLAIN#49

Merged
tamnd merged 7 commits into
mainfrom
fix/postgrest-compat-401
Jun 10, 2026
Merged

fix: match PostgREST wire behavior — 42501→401, add plan+json EXPLAIN#49
tamnd merged 7 commits into
mainfrom
fix/postgrest-compat-401

Conversation

@tamnd

@tamnd tamnd commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Two behavioral fixes to make dbrest 100% wire-compatible with PostgREST.

1. 42501 (insufficient_privilege) → HTTP 401 not 403

PostgREST maps the PostgreSQL 42501 error 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: private writes with web_anon).

Updated the status mapping in backend/postgres/postgres.go and the corresponding unit test.

2. vnd.pgrst.plan+json EXPLAIN endpoint

When Accept: application/vnd.pgrst.plan+json is requested, PostgREST runs
EXPLAIN (FORMAT JSON) on the query and returns the plan JSON.

This PR:

  • Adds mediaPlan = "application/vnd.pgrst.plan+json" to the negotiation table
  • Adds planAnalyze() helper to detect options=analyze in the Accept params
  • Adds optional backend.Explainer interface with ExplainRead(ctx, plan, rc, analyze) ([]byte, error)
  • Implements ExplainRead on the postgres backend: compiles the read query, runs
    EXPLAIN [(ANALYZE,)] FORMAT JSON in a read-only transaction with full session setup
  • The frontend type-asserts to Explainer; backends without it (SQLite, MySQL, MongoDB) return 406

Test plan

  • go test ./... passes
  • postgrest-compat PR ci: move to checkout v5 and setup-go v6 #11 CI: test-dbrest job runs all 615 tests against this build
  • Schema-write tests (Content-Profile: private) confirm 401 not 403
  • Plan tests (vnd.pgrst.plan+json) confirm 200 with JSON plan body

tamnd added 7 commits June 10, 2026 16:41
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.
@tamnd tamnd merged commit f32bba6 into main Jun 10, 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