Skip to content

fix(postgres): own a subquery's aggregate of a whole row by bare name - #76

Merged
colinhacks merged 1 commit into
mainfrom
fix/pg-subquery-row-agg
Oct 23, 2026
Merged

colinhacks merged 1 commit into
mainfrom
fix/pg-subquery-row-agg

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

A scalar subquery that aggregates a whole row by its bare name came back nullable, even though an aggregate without a group by always returns one row. Wrapping it in coalesce(…, '[]') didn't help either:

-- orders (id int primary key, user_id int not null references users, note text)
select u.name, (select coalesce(jsonb_agg(o), '[]') from orders o where o.user_id = u.id) as orders from users u
  • Before: orders: ({ id: number; user_id: number; note: string | null })[] | null
  • After: orders: ({ id: number; user_id: number; note: string | null })[]

coalesce(jsonb_agg(o.id), '[]') and jsonb_agg(o order by o.id) were already right, because a qualified column says which query the aggregate belongs to.

The rule

An aggregate belongs to the innermost query whose columns it reads. A bare name means a column of any FROM in scope before it means an item's whole row, so the check that decides which query owns an aggregate used to treat a bare o as possibly the outer query's. Now jsonb_agg(o) counts as the subquery's own when all three of these hold:

  • an item of the subquery's FROM goes by o;
  • that FROM shows no column o;
  • no FROM around the subquery shows a column o, and the caller knows every FROM around it.

Where the caller doesn't know the FROMs around the subquery, the old answer stands. Both readers pass in what is around: the JSON shapes, from their stack of FROMs, and the expression reader, from the top FROM and each subquery it enters.

Oracle

  • aggregate_levels/whole_row_own: (select count(posts) from posts where false): n: int8? → n: int8
  • New in json_subqueries, each checked over seeded rows:
    • correlated coalesce(jsonb_agg(q), '[]'), plain, with '[]'::jsonb and with json_agg
    • uncorrelated, with and without a FROM outside
    • coalesce(array_agg(q), '{}') (the array is now non-null; its element fields are unchanged)
    • guards that already passed: bare jsonb_agg(q) stays nullable, plus coalesce(jsonb_agg(q.id), '[]'), count(*) and the top-level coalesce(jsonb_agg(q), '[]')

Copilot AI balanced review requested due to automatic review settings October 23, 2026 18:09
@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 9:19pm 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 insufficient remaining quota.

@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.

Important

The rule is sound for the user's own statement, but infer::plan also refines JSON-shape leaf probes. A probe's text leaves out the outer FROMs, so the new rule can type as non-null a value Postgres returns as NULL. This is a regression from main: I reproduced it with the oracle (details inline).

Reviewed changes

I reviewed the single commit, which lets a scalar subquery own an aggregate of a whole row named by a bare name, when every FROM in scope is known not to have a column of that name.

  • Level::whole_row / Level::within: owns now accepts a bare q when an inner item goes by q and no FROM, inside or around, shows a column q. Without outside, it keeps the old answer.
  • Expression reader scope: Reader.scope starts from the text's top FROM in plan_here and grows by one Level for each subquery it enters.
  • JSON-shape scope: the SubLink arm builds outside from the builder's whole stack.
  • Oracle: aggregate_levels/whole_row_own goes from int8? to int8, and there are nine new json_subqueries statements. engine.wasm is rebuilt.

On a local Postgres 16, the PR's json_subqueries and aggregate_levels cases and the levels unit tests pass.

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

Comment thread engine/postgres/src/infer.rs Resolved
@colinhacks
colinhacks force-pushed the fix/pg-subquery-row-agg branch from 00a4e64 to b56c77d Compare October 23, 2026 20:14

@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. The leaf-probe regression from the last review is closed by the rebase onto #78.

Reviewed changes

I reviewed the rebase of the single commit onto #78. The engine and oracle diff is the same as before. The only text that changed is the json_subqueries/setup.sql header comment, now merged with #78's.

  • Leaf-probe scope, through #78: Builder::renamed_in_probe now sends a leaf to the widened probe when one of its bare names (subqueries included) might be a column of a FROM around it. So the top FROM that plan_here seeds Reader.scope with holds that outer column, and Level::whole_row declines. When the narrow probe is still used, each bare name either is a column of the current FROM, which fails lacks in the probe too, or is certainly absent from every outer FROM. Either way it resolves in the probe as it does in the statement.
  • Guard: json_subqueries/field_outer_column_in_subquery.sql is the earlier repro, written against this case's schema. It stays n: int8? on this branch.

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

@colinhacks
colinhacks force-pushed the fix/pg-subquery-row-agg branch from b56c77d to c9b8999 Compare October 23, 2026 21:02
@colinhacks
colinhacks force-pushed the fix/pg-subquery-row-agg branch from c9b8999 to 24224c6 Compare October 23, 2026 21:17
@colinhacks
colinhacks merged commit b1d6598 into main Oct 23, 2026
6 checks passed
@colinhacks
colinhacks deleted the fix/pg-subquery-row-agg branch October 23, 2026 21:26
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