Skip to content

fix(postgres): scope the names json shapes read as postgres does - #53

Merged
colinhacks merged 6 commits into
mainfrom
json-scoping
Oct 23, 2026
Merged

colinhacks merged 6 commits into
mainfrom
json-scoping

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Several ways Postgres's name scoping was ignored when typing JSON, each of which typed a value as something the row doesn't hold. The new oracle case json_scoping has 20 statements; on main, 12 of them contradict their types against real rows.

Statement On main Now
with a as (… from tally), tally as (select 'one'::text as x) select o from a (a WITH query's body reads a later sibling's name) { n: text }, holds 0 { n: int4 } (the table)
with snap as (select jsonb_build_object('n', snap.o) as o from snap) select o from snap { n: { n: { n: … } } } { n: int4? } (the table)
select (with team as (select 1 as name) select jsonb_agg(team.name) from team) [text], holds 1 json
with team as (select 1 as motto) update team set … returning jsonb_build_object('m', motto) { m: int4 }, holds text json
with team as (select 'x'::text as motto) update team … returning motto || '!' text, holds NULL text?
select jsonb_build_object('n', name) from team group by rollup (name) { n: text }, holds null { n: text? }
select jsonb_build_object('k', 1)::jsonb, '"s"'::jsonb as jsonb … jsonb: { k: int4 }, holds "s" jsonb_build_object: { k: int4 }, jsonb: json
with team as (select :n::int as name) select jsonb_build_object('n', team.name) from team { n: text }, holds 1 { n: unknown? }

What changed:

  • infer.rs: a non-recursive WITH query's statement carries only the queries before it. A recursive WITH keeps the whole clause. An arm or subquery with a WITH of its own is no statement of its own where an outer WITH exists, rather than silently dropping the outer one. A DML statement whose WITH names its target gets no expression probe.
  • jsonshape.rs:
    • A DML statement whose WITH names its target gets no shapes.
    • A scalar subquery with its own WITH, or its own grouping sets, is read as nothing.
    • A grouping set of the statement makes a leaf nullable, except inside a plain aggregate's argument, and a whole row unknown.
    • default_name follows Postgres's FigureColname (f(…)::jsonb is named f).
    • A probe that reads a WITH query left out for holding a placeholder gets no probe, since it would read a table of that name.
  • postgres.rs: settle held a borrow of the relation cache across item_columns. Following an item could prefetch a relation and panic (RefCell already borrowed). The scoping change reaches that path in derived_tables, so the borrow is released around the call.
  • The oracle (oracle/src/seed.rs): a JSON value of the wrong kind for an array, a missing key, and a top-level JSON null in a non-null column are now contradictions. Main already checked an object's and a list's kind.

Tests: engine unit tests for each rule, the new oracle case json_scoping, and the full oracle with --strict-coverage (101 cases, no expected output changed elsewhere). Gates: engine and oracle tests, the wasm fingerprint, typecheck, package and site. The loader tests fail only tests/representation.test.ts › "SQLite: JSON a statement builds…", which fails on main under Node 26.11's SQLite 3.54.1 and which #51 fixes.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 00:10
@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
reylo Ready Ready Preview Oct 23, 2026 4:53am UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No correctness issues found. One formatting nit is inline.

Reviewed changes

I reviewed all 27 files: the Postgres engine's scoping rules for JSON shapes and expression probes, the settle borrow fix, the oracle's tighter JSON checks and the new json_scoping case.

  • WITH scoping in Items::read: a non-recursive CTE body's item query now carries only the queries before it. arm_text refuses an arm whose own WITH would hide an outer one, where it used to drop the outer one. Both changes only lose typing or match Postgres, so they stay sound.
  • with_shadows_target: a DML statement whose WITH reuses its unqualified target's name now gets no expression probe and no shapes. This is correct: Postgres never resolves a DML target to a WITH query.
  • Grouping sets in jsonshape.rs: under grouping sets, a leaf is now nullable and a whole row reads as Unknown. The exception is the argument of a plain aggregate of the top statement, which reads the rows before grouping. Window calls and nested scalar subqueries take the nullable path, which costs precision but stays sound.
  • default_name / figure: these follow FigureColnameInternal's strength rules: a cast's operand name outranks the type's name, and ?column? is the fallback. The nodes it leaves out (A_Indirection, NULLIF, the SQL/JSON constructors) are never constructor targets under is_constructor, so nothing depends on them.
  • WITH queries dropped for a placeholder: a probe that reads one of those names, in its FROM, items or filter, now gets no SQL. Before, it would have read a table of that name.
  • settle borrow: the code now drops known before item_columns and takes it again after, so a prefetch can write the relation cache without a RefCell already borrowed panic. The values computed from the first borrow are still valid afterwards.
  • Oracle seed.rs: three things are now contradictions: a top-level JSON null in a non-null column, a missing object key, and a non-array where an array is typed.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/postgres/src/jsonshape.rs Resolved
@colinhacks

Copy link
Copy Markdown
Owner Author

Added 2bff318 fix(postgres): null what a grouping set may group by. Under grouping sets, a leaf was already nullable, but an object or array the statement builds was still typed non-null. It can be a grouping expression itself, so a super-aggregate row nulls it: group by rollup (o), group by rollup (1), or the constructor written out, as for the inner object of jsonb_build_object('a', jsonb_build_object('b', name)) grouped by rollup ((jsonb_build_object('b', name))). The oracle saw NULLs in all three. Now everything built at a grouping-set level is nullable, except inside a plain aggregate's argument, which reads the rows before grouping. The new statements are in oracle/cases/json_scoping (rollup_by_alias, rollup_by_ordinal, rollup_nested), and rollup_leaf's object becomes nullable for the same reason.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

I reviewed the two commits added since the last Pullfrog review: a new grouping-set rule in jsonshape.rs and the re-indent of Builder::aggregate.

  • Nulled what a grouping set may group by: walk is now a thin wrapper over build. Under grouping sets, it runs every built shape through force_nullable, except inside a plain aggregate's argument at the top level. A built object or array can be a grouping expression itself — by alias (ROLLUP (o)), by position (ROLLUP (1)) or as a nested constructor — so its super-aggregate rows hold NULL. The rule doesn't check which expressions are grouped, so ROLLUP (name) also drops the object's non-null (rollup_leaf becomes { n: text? }?). That loses precision but stays sound, and a plain GROUP BY o stays non-null, as the new unit test asserts. Values inside an aggregate's argument are not forced, because in_aggregate is set across aggregate. The aggregate's own result is still forced once the flag is restored.
  • Re-indented aggregate: the FILTER body now sits at the same indentation as the rest of impl Builder. This resolves the earlier nit.
  • Rebuilt the engine artifact: both commits carry a rebuilt packages/postgres/engine.wasm. Three new oracle statements (rollup_by_alias, rollup_by_ordinal, rollup_nested) cover the new rule.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the last Pullfrog review, the branch was rebased onto main after #45. I reviewed how this PR's grouping-set rule merges with #45's grouped probes in jsonshape.rs.

  • Renamed this PR's flag to under_aggregate: #45 adds in_aggregate: usize, which counts the no-FILTER aggregate arguments the walk is inside so that outside_aggregates() can choose the probe that carries the GROUP BY. This PR's bool, which exempts a plain top-level aggregate's argument from grouping-set nulling, is now under_aggregate. The two flags still mean different things, and each is set and restored around the same calls as before the rebase.
  • Kept the two rules apart: group is Some only for a plain GROUP BY (where grouped already excludes grouping sets) or for an ungrouped aggregating statement. A statement with grouping sets therefore never gets a probe that carries a GROUP BY, and the nulling rule alone decides its leaves.
  • Merged the probe text: the text closure in plan_with now checks the probe's GROUP BY too when it looks for a WITH query dropped for holding a placeholder (reads_relation(&(from, items, filter, group), …)), and builds the SQL with #45's grouped_select_text.
  • Rebuilt the engine artifact: the last commit carries a rebuilt packages/postgres/engine.wasm. The rebase changed no oracle expected.txt, and the 23 json_scoping statements read the same as before.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@colinhacks

Copy link
Copy Markdown
Owner Author

Rebased onto main past #45, which rewrote the same aggregate and probe code. The merge keeps #45's carried GROUP BY and its in_aggregate counter beside this PR's grouping_sets, whose own flag is now under_aggregate. The probe text is #45's grouped_select_text, inside this PR's guard against a WITH query dropped for a placeholder (the GROUP BY is checked too). names_output again skips an unaliased bare column: this PR's default_name now names it after its column, and that name reads the same column whether Postgres takes it for the FROM's or the select list's.

@colinhacks
colinhacks force-pushed the json-scoping branch 3 times, most recently from d9cc88c to 8bb2025 Compare October 23, 2026 04:08
@colinhacks

Copy link
Copy Markdown
Owner Author

Rebased past #58, which covers two pieces of this PR by its own mechanism; the merge takes #58's for both:

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the last Pullfrog review, the branch was rebased onto main after #58, which now covers part of this PR's probe logic. One new commit re-blesses a statement whose type that change improves.

  • Dropped the DML-target rule: #58's target_named_by_with checks the same thing as this PR's with_shadows_target: an unqualified target that a WITH query has the name of. It also runs in both plan_here and plan_with, so the duplicate and its cte_names helper are gone. dml_target*.sql read as before.
  • Dropped the placeholder-WITH refusal: #58 now carries the WITH clause into any probe that reads one of its queries (reads_with), and Engine::pin pins and renumbers its placeholders. This PR's dropped list, reads_relation and the empty probe they produced are no longer needed. The text closure in plan_with now calls grouped_select_text directly. No helper or import is left unused (contains_param is still read in draft).
  • Re-blessed param_with_table: the field went from { n: unknown? } to { n: int4 }. That is sound: :n is a non-null int4 parameter, and the probe now reads the WITH query instead of the table team.
  • Rebuilt the engine artifact: tests/fingerprint.test.ts passes for all three committed engine.wasm files.

ℹ️ The PR description still lists rules the rebase removed

The description's jsonshape.rs bullets still say "A DML statement whose WITH names its target gets no shapes" and "A probe that reads a WITH query left out for holding a placeholder gets no probe". Its table still shows { n: unknown? } for the :n::int row. All three now come from #58 or no longer apply. A squash merge can carry the description into the commit body, so updating it would keep the history accurate.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

A WITH query's body read the queries after it and its own name, a
subquery's own WITH was ignored, a DML target named like a WITH query
took its columns, a grouping set's nulls were missed, and a cast was
named by its type, so some JSON was typed as what the row does not hold.
The oracle now checks a JSON value's kind and keys too.
@colinhacks
colinhacks merged commit ee14b92 into main Oct 23, 2026
6 checks passed
@colinhacks
colinhacks deleted the json-scoping branch October 23, 2026 05:19
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.

2 participants