feat(#63,#31): broaden application/geo+json + byte parity with PostgREST 14.12#65
Merged
Conversation
…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>
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
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 rendersjson_build_objectspaced (
"k" : v), diverging from PostgREST's compact wire bytes (#31), and apre-collapsed JSON scalar is not a record
ST_AsGeoJSONcan consume — which iswhy
application/geo+jsonhad been left flat-path-only (#63).The restructure (spec §0-1).
Bier.Embed.build_row_object/6became aselect-list builder returning
{select_items, lateral_joins, state}instead ofone scalar JSON expression: plain fields/aggregates/computed columns render as
expr AS "out_name"; non-spread embeds stay correlated scalar subqueries butas named columns (
:many→COALESCE(json_agg(...), '[]')over an orderedsubquery,
:one→to_jsonof aLIMIT 1subquery); spread embeds becomeLEFT JOIN LATERALwith the child's columns pulled into the parent selectlist (PostgREST's actual SQL shape), deleting the old
(obj::jsonb || spread)::jsonmerge and itsnull_templateCOALESCE hack.build_advancedthen mirrors
build_simple's three-layer shape: an inner named-projectionsubquery, a paging layer that projects the bare record (not
pre-rendered JSON) alongside a
count(*) OVER()window, and an outerjson_aggthat renders PostgREST's exact wire bytes — compact row objectsseparated 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
, \nrow separator on every multi-row response.Producer broadening (spec §2). With every rendering path now able to
produce a real record,
application/geo+jsonwas safe to broaden beyond flatreads:
geo+jsonagainst thepostgis-gated producer list unconditionally; whether the response carries a
body (and thus a FeatureCollection) still depends on
Prefer(
return=representationrenders one,return=minimal/headers-onlydon't).
Bier.Mutationthreadsformat: :geojsoninto the query executorso the representation body is a FeatureCollection.
Preference-Applied handling is unchanged; Location and Content-Range were
aligned with live PostgREST (see Resolved divergences below).
Bier.Rpc) offersgeo+jsonunder the same PostGIS gate for bothGETandPOSTdispatch.setof_relresults thread the format into thesame read-builder path as table reads; scalar/composite/setof_record results
gain a geojson variant wrapping the result in
ST_AsGeoJSON. A relation orresult without a geometry column raises SQLSTATE
22023("geometry columnis missing") at execution — no new error shape, mirroring PostgREST.
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-appliedheaders.GET /projects?select=id,name,clients(name)&order=id&limit=2[{"id":1,"name":"Windows 7","clients":{"name": "Microsoft"}}, \n {...}])GET /projects?select=name,tasks(name)&order=id&limit=1&tasks.order=idGET /projects?select=id,...clients(client_name:name)&order=id&limit=1&id=eq.5(missing to-one spread)[{"id":5,"client_name":null}])GET /projects?select=client_id,id.count()&order=client_id, \nrow separators)GET /projects?select=id,...tasks(task_names:name)&order=id&limit=1[{"id":1,"task_names":["Design w7", "Code w7"]}])&id=eq.5(EMPTY to-many spread)[{"id":5,"task_names":[]}]— PostgREST renders[], confirming Bier's per-keyCOALESCE(json_agg(...), '[]'))/shops?select=id,address,shop_geom,shop_bles(name)…)POST /shopsrepr + geo+jsonPOST /shopsbody[]repr + geo+json{"type" : "FeatureCollection", "features" : []}— Bier's hardcoded literal confirmed byte-for-bytePATCH /shops?id=eq.1repr + geo+jsonDELETE /shops?id=eq.3repr + geo+jsonGET /rpc/get_shops+ geo+jsonPOST /rpc/get_shops(body{}) + geo+jsonGET /rpc/get_shop_geom?id=1+ geo+json{"type" : "FeatureCollection", "features" : [{"type": "Feature", "geometry": {"type":"Point","coordinates":[-71.10044,42.373695]}, "properties": {}}]}— Bier's FeatureCollection-of-one shape confirmedGET /projects+ geo+json (no geometry){"code":"22023","details":null,"hint":null,"message":"geometry column is missing"}POST /plainrepr + geo+jsonAll 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)
PostgREST:
Content-Range: 0-2/*; Bier:*/*. PostgREST computes the rangefrom the row window regardless of the negotiated media type; Bier's
Response.row_count/1decoded the FeatureCollection object to 0 rows.Fixed by counting
featuresof a{"type":"FeatureCollection"}body asrows (
lib/bier/response.ex).PostgREST emits
LocationONLY forPrefer: 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 frozensuite's InsertSpec notes). Bier computed a Location on representation and
minimal responses whenever the PK was in the selected output. Removed
maybe_location(andpk_in_select?) from those branches(
lib/bier/mutation.ex); the headers-onlyforce_locationand theresponse.headers GUC override (frozen case 1569) are unchanged.
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)}torespond_empty_set(lib/bier/mutation.ex),so
count=exactyields*/0like the SQL-backed path.(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/1undercount=exact(frozen case 1403). Bier emitted the header only when a countpreference was present.
maybe_scalar_content_rangenow emits0-0/*forcount=none(lib/bier/rpc.ex).cases). With
db-schemas = "test, geotest", PostgREST echoesContent-Profile: <resolved schema>on every success response (testfordefault-profile requests,
geotestfor explicit profiles) and omits it onerror responses and single-schema configs. Bier only echoed through the
db_profile_schemas/db_profile_defaultmodel built for the frozenMultipleSchemaSpec 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 returns204withno
Locationand noContent-Range, while Bier emits both. Notpart of this branch's matrix and no frozen case covers PUT headers-only —
candidate for a new issue.
Bier.Embed.spread_entryis missing achild_cols == []guard: a to-manyspread 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.
:compositeret_kind+ geo+jsonresult_sqlbranch.Also in this branch
mix hex.auditbumpedplug(1.20.2 → 1.20.3) andpostgrex(0.22.2 →0.22.3) past three upstream security advisories (cookie attribute
injection, multipart DoS, Notifications reconnect SQL injection) — unrelated
to [conformance-gap] Broaden application/geo+json: mutations, RPC, and the embed/advanced read path #63/[conformance][lib] Embed/advanced path renders spaced JSON via json_build_object #31 but required to keep CI's
hex.auditgate green.README.md,docs/CONFORMANCE_IMPL.md,CHANGELOG.md) updated todescribe the broadened
application/geo+jsonavailability (reads,representation-returning mutations, RPC, whenever PostGIS is installed) and
the
search_pathnote:ST_AsGeoJSONis emitted unqualified and resolvesvia the session
search_path(matching PostgREST) — a PostGIS installedoutside the search path fails at execution.
Closes #63
Closes #31
🤖 Generated with Claude Code
https://claude.ai/code/session_01BZSDq398iCeY6uLG6KtfGW