Skip to content

feat(#63,#31): broaden application/geo+json + byte parity with PostgREST 14.12#65

Merged
milmazz merged 12 commits into
mainfrom
geojson-broadening-31-63
Jul 12, 2026
Merged

feat(#63,#31): broaden application/geo+json + byte parity with PostgREST 14.12#65
milmazz merged 12 commits into
mainfrom
geojson-broadening-31-63

Conversation

@milmazz

@milmazz milmazz commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

Both issues shared one root cause: the advanced read path (embeds/aggregates/
spread/computed columns) pre-collapsed each row into a scalar
json_build_object(...) expression. PostgreSQL renders json_build_object
spaced ("k" : v), diverging from PostgREST's compact wire bytes (#31), and a
pre-collapsed JSON scalar is not a record ST_AsGeoJSON can consume — which is
why application/geo+json had been left flat-path-only (#63).

The restructure (spec §0-1). Bier.Embed.build_row_object/6 became a
select-list builder returning {select_items, lateral_joins, state} instead of
one scalar JSON expression: plain fields/aggregates/computed columns render as
expr AS "out_name"; non-spread embeds stay correlated scalar subqueries but
as named columns (:manyCOALESCE(json_agg(...), '[]') over an ordered
subquery, :oneto_json of a LIMIT 1 subquery); spread embeds become
LEFT JOIN LATERAL with the child's columns pulled into the parent select
list (PostgREST's actual SQL shape), deleting the old (obj::jsonb || spread)::json merge and its null_template COALESCE hack. build_advanced
then mirrors build_simple's three-layer shape: an inner named-projection
subquery, a paging layer that projects the bare record (not
pre-rendered JSON) alongside a count(*) OVER() window, and an outer
json_agg that renders PostgREST's exact wire bytes — compact row objects
separated by , \n , jsonb-style embed internals (to_jsonb/json_agg( to_jsonb(...))) with key re-sorting. This also fixed the simple path,
which previously aggregated a pre-rendered JSON value and silently dropped the
, \n row separator on every multi-row response.

Producer broadening (spec §2). With every rendering path now able to
produce a real record, application/geo+json was safe to broaden beyond flat
reads:

  • Mutations (POST/PATCH/PUT/DELETE) negotiate geo+json against the
    postgis-gated producer list unconditionally; whether the response carries a
    body (and thus a FeatureCollection) still depends on Prefer
    (return=representation renders one, return=minimal/headers-only
    don't). Bier.Mutation threads format: :geojson into the query executor
    so the representation body is a FeatureCollection.
    Preference-Applied handling is unchanged; Location and Content-Range were
    aligned with live PostgREST (see Resolved divergences below).
  • RPC (Bier.Rpc) offers geo+json under the same PostGIS gate for both
    GET and POST dispatch. setof_rel results thread the format into the
    same read-builder path as table reads; scalar/composite/setof_record results
    gain a geojson variant wrapping the result in ST_AsGeoJSON. A relation or
    result without a geometry column raises SQLSTATE 22023 ("geometry column
    is missing") at execution — no new error shape, mirroring PostgREST.
  • Edge: empty-payload mutation responses under geojson return an empty
    FeatureCollection ({"type":"FeatureCollection","features":[]}) rather than
    [] — live-verified.

Live PostgREST 14.12 diff — 17/17 requests byte-identical

Setup: PostgREST 14.12 on :3009 with db-schemas = "test, geotest",
db-anon-role = postgrest_test_anonymous, db-tx-end = rollback,
db-aggregates-enabled = true; Bier on :3010 with the mirrored config.
Identical requests issued to both; compared status, body bytes, and the
content-type, location, content-range, content-length,
content-profile, preference-applied headers.

# Request Result
1 GET /projects?select=id,name,clients(name)&order=id&limit=2 ✓ ([{"id":1,"name":"Windows 7","clients":{"name": "Microsoft"}}, \n {...}])
2 GET /projects?select=name,tasks(name)&order=id&limit=1&tasks.order=id
3a GET /projects?select=id,...clients(client_name:name)&order=id&limit=1
3b &id=eq.5 (missing to-one spread) ✓ ([{"id":5,"client_name":null}])
4 GET /projects?select=client_id,id.count()&order=client_id ✓ (, \n row separators)
5a GET /projects?select=id,...tasks(task_names:name)&order=id&limit=1 ✓ ([{"id":1,"task_names":["Design w7", "Code w7"]}])
5b &id=eq.5 (EMPTY to-many spread) ✓ ([{"id":5,"task_names":[]}] — PostgREST renders [], confirming Bier's per-key COALESCE(json_agg(...), '[]'))
6 geo+json read with embed (/shops?select=id,address,shop_geom,shop_bles(name)…) ✓ after fix (Content-Range)
7 POST /shops repr + geo+json ✓ after fix (Location)
8 POST /shops body [] repr + geo+json ✓ after fix (Content-Range); status 201, body exactly {"type" : "FeatureCollection", "features" : []} — Bier's hardcoded literal confirmed byte-for-byte
9a PATCH /shops?id=eq.1 repr + geo+json
9b DELETE /shops?id=eq.3 repr + geo+json
10a GET /rpc/get_shops + geo+json ✓ after fix (Content-Range)
10b POST /rpc/get_shops (body {}) + geo+json ✓ after fix (Content-Range)
11 GET /rpc/get_shop_geom?id=1 + geo+json ✓ after fix (Content-Range); body {"type" : "FeatureCollection", "features" : [{"type": "Feature", "geometry": {"type":"Point","coordinates":[-71.10044,42.373695]}, "properties": {}}]} — Bier's FeatureCollection-of-one shape confirmed
12a GET /projects + geo+json (no geometry) ✓ 400 {"code":"22023","details":null,"hint":null,"message":"geometry column is missing"}
12b POST /plain repr + geo+json ✓ same envelope

All response bodies matched byte-for-byte on the first run (the #31
restructure and #63 broadening were already byte-correct). The divergences
found were all header-level; four were fixed in lib/:

Resolved divergences (Bier bugs, fixed)

  1. Content-Range under geo+json reads and setof RPC (cases 6, 10a, 10b).
    PostgREST: Content-Range: 0-2/*; Bier: */*. PostgREST computes the range
    from the row window regardless of the negotiated media type; Bier's
    Response.row_count/1 decoded the FeatureCollection object to 0 rows.
    Fixed by counting features of a {"type":"FeatureCollection"} body as
    rows (lib/bier/response.ex).
  2. Location on mutation representation/minimal responses (case 7).
    PostgREST emits Location ONLY for Prefer: return=headers-only
    (live-verified: POST repr with PK selected → no Location; no-Prefer POST →
    no Location; headers-only → Location: /shops?id=eq.6; matches the frozen
    suite's InsertSpec notes). Bier computed a Location on representation and
    minimal responses whenever the PK was in the selected output. Removed
    maybe_location (and pk_in_select?) from those branches
    (lib/bier/mutation.ex); the headers-only force_location and the
    response.headers GUC override (frozen case 1569) are unchanged.
  3. Content-Range on the empty-payload mutation short-circuit (case 8).
    PostgREST: Content-Range: */* (both JSON and geo+json, repr and minimal);
    Bier omitted the header on the zero-row short-circuit. Added
    */{total_part(pref, 0)} to respond_empty_set (lib/bier/mutation.ex),
    so count=exact yields */0 like the SQL-backed path.
  4. Content-Range on scalar/composite RPC without a count preference
    (case 11).
    PostgREST always emits a read-style range for the single-row
    result — 0-0/* by default (JSON and geo+json alike), 0-0/1 under
    count=exact (frozen case 1403). Bier emitted the header only when a count
    preference was present. maybe_scalar_content_range now emits 0-0/* for
    count=none (lib/bier/rpc.ex).
  5. Content-Profile echo under multi-schema exposure (all non-error
    cases).
    With db-schemas = "test, geotest", PostgREST echoes
    Content-Profile: <resolved schema> on every success response (test for
    default-profile requests, geotest for explicit profiles) and omits it on
    error responses and single-schema configs. Bier only echoed through the
    db_profile_schemas/db_profile_default model built for the frozen
    MultipleSchemaSpec surface. Fixed additively
    (lib/bier/plugs/action_controller.ex): when that model is NOT configured,
    the resolved schema is echoed whenever more than one schema is exposed. The
    shared conformance instance (which configures the model) and single-schema
    instances are behaviorally unchanged; the frozen suite stays green.

Accepted divergence (out of scope)

See Follow-up below (PUT … Prefer: return=headers-only).

Follow-up

  • PUT … Prefer: return=headers-only: live PostgREST returns 204 with
    no Location and no Content-Range, while Bier emits both. Not
    part of this branch's matrix and no frozen case covers PUT headers-only —
    candidate for a new issue.
  • Bier.Embed.spread_entry is missing a child_cols == [] guard: a to-many
    spread of an empty projection currently emits a lateral with an empty
    select list, which is valid SQL that silently multiplies parent rows (one
    duplicate per child row) rather than erroring.
  • No test covers the :composite ret_kind + geo+json result_sql branch.

Also in this branch

Closes #63
Closes #31

🤖 Generated with Claude Code

https://claude.ai/code/session_01BZSDq398iCeY6uLG6KtfGW

milmazz and others added 12 commits July 10, 2026 02:26
…-path JSON

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ures

PostgREST is not uniformly compact: rows are compact but separated by
', \n ' (json_agg over a record), and embed internals are jsonb-styled
(': ' spacing, jsonb key normalization). Spec §0 records the verified
profile and SQL mechanisms; Task 1 literals/snippets updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
json_build_object spaces `"k" : v` and the spread ::jsonb merge spaces
`"k": v`; both diverged from PostgREST's wire bytes. The advanced path
now projects a named select list into a derived table (spread via
LEFT JOIN LATERAL, matching PostgREST's SQL shape) and threads the row
RECORD through the paged layer, so the outer json_agg renders compact
rows separated by ', \n ' exactly like PostgREST — this also restores
the newline separator the simple path was silently dropping. Embed
internals render via to_jsonb / json_agg(to_jsonb(...)), matching
PostgREST's jsonb-styled spacing and key normalization (live-verified
against PostgREST 14.12; see spec §0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ST 14.12

Live 12-case diff against the real binary (test+geotest, both servers
db-tx-end=rollback): every response body matched byte-for-byte on the first
run; the five divergences found were header-level and are fixed here:

* Content-Range under geo+json reads/RPC now reflects the row window
  (FeatureCollection features count as rows), matching 0-2/*.
* Location is emitted ONLY for return=headers-only — PostgREST never
  computes one for representation/minimal responses.
* The empty-payload mutation short-circuit now carries Content-Range */*
  (*/0 under count=exact).
* Scalar/composite RPC always emits Content-Range (0-0/* without a count
  preference; 0-0/1 with one, frozen case 1403).
* Content-Profile echoes the resolved schema whenever more than one schema
  is exposed and the MultipleSchemaSpec profile model is not configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hex.audit flagged plug 1.20.2 (EEF-CVE-2026-56813 cookie attribute
injection, EEF-CVE-2026-56814 multipart DoS) and postgrex 0.22.2
(EEF-CVE-2026-58225 Notifications reconnect SQL injection). Patched
releases (plug 1.20.3, postgrex 0.22.3) exist within the mix.exs
constraints; `mix deps.update plug postgrex` picks them up (plus a
transitive db_connection 2.10.1 -> 2.10.2 bump). hex.audit is now
clean; full `mix test` re-run passes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… + RPC count guard

The advanced path's empty-projection dummy row now renders through
ST_AsGeoJSON under geo+json, so a projection without a geometry column
fails 400/22023 like PostgREST instead of emitting non-Feature objects.
The RPC geojson count guard (a no-op turned wrong once row_count learned
to count FeatureCollection features) is removed, so count=exact + geo+json
yields a real Content-Range. CHANGELOG now records the four host-visible
header changes; stale README/CONFORMANCE_IMPL sentences corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@milmazz
milmazz merged commit 98dd0cd into main Jul 12, 2026
3 checks passed
@milmazz
milmazz deleted the geojson-broadening-31-63 branch July 12, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant