8000 feat(postgres): carry json shapes through subqueries, with and case by colinhacks · Pull Request #16 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

feat(postgres): carry json shapes through subqueries, with and case - #16

Closed
colinhacks wants to merge 3 commits into
json-soundnessfrom
json-shapes
Closed

colinhacks wants to merge 3 commits into
json-soundnessfrom
json-shapes

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

Stacked on #5 (its branch is this PR's base); it retargets to main when that one merges.

JSON a statement builds kept its shape only through a scalar subquery: read through a subquery in FROM, a WITH query, a LATERAL join or a CASE, it came back as unknown. A LATERAL subquery that aggregates a child table is the usual way to return nested rows, so this was the common case.

Statement Before Now
select m.id, x.teams from member m left join lateral (select json_agg(json_build_object('id', t.id, 'name', t.name)) as teams from member_team mt join team t on t.id = mt.team_id where mt.member_id = m.id) x on true teams: unknown teams: { id: number | null; name: string | null }[] | null
… cross join lateral (select coalesce(json_agg(json_build_object('id', t.id)), '[]') as teams …) x teams: unknown teams: { id: number }[]
with x as (select jsonb_build_object('n', name, 'm', motto) as o from team) select o from x o: unknown o: { n: string; m: string | null }
select t.id, x.o from team t left join (select id, jsonb_build_object('n', first_name) as o from member) x on x.id = t.id o: unknown o: { n: string } | null
select case when m.id > 1 then jsonb_build_object('n', m.first_name) else null end as o from member m o: unknown o: { n: string } | null
  • A column of a FROM item (jsonshape.rs, derived_target and derived): x.col of a subquery in FROM, a WITH query or a LATERAL subquery, or a bare col of the one item a FROM holds, takes the shape of the target col there, built in that item's own context (its FROM and WHERE, and every FROM around it for a LATERAL one's correlated columns). An outer join that can null the item makes the value nullable. An item that is a set operation or selects a * is left as it was, since a name there may be another target.
  • A CASE whose one branch builds JSON and whose other branches are NULL takes that branch's shape, nullable. Two branches that build JSON may build two shapes, and are left as they were.
  • Some fields under LATERAL stay nullable where the probe reads a table twice; feat(postgres): infer more expressions non-null #15 tells those readings apart.

New oracle case oracle/cases/json_derived, which runs each statement over seeded edge values; no existing case changed. Docs: the Postgres page's JSON section.

Verified locally: the engine's native tests, every oracle case on Postgres 18.6, SQLite and MySQL 8.4 with --strict-coverage, the rebuilt packages/postgres/engine.wasm, nub run typecheck, the loader's tests with every lab installed, the package build and the docs site.

- jsonb_agg(t.*), json_agg(t.*) filter (...), jsonb_build_object('r', t.*)
  and to_jsonb(t.*) are the whole row, not the row's first column
- an array the statement builds keeps its null elements inside json
- a float or numeric inside json may be "NaN", "Infinity" or "-Infinity",
  and a row whose columns were not read is an object there
- two result columns of one name are an error (reylo14) at the second,
  and the row type keeps the last, as drivers do
- the oracle checks each value's kind inside a json document

Found by running SafeQL's test statements through the oracle.
- a column read from a subquery in from, a with query or a lateral
  subquery takes the shape of the json that item builds, nullable where
  an outer join can null the item
- a case whose one branch builds json and whose others are null takes
  that branch's shape, nullable
@vercel
vercel Bot commented Oct 22, 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 22, 2026 6:11pm UTC

Request Review

Copilot AI balanced review requested due to automatic review settings October 22, 2026 18:11
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.

8000

@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

derived_target decides which query a name reads without Postgres's scoping rules. A WITH query whose body reads a table of its own name crashes the engine, and three other cases produce shapes the database does not return. I reproduced all four with temporary shapes/probes_ahead unit tests at this commit.

Reviewed changes

This review covers commit 998697d, which carries JSON shapes through FROM items, WITH queries, LATERAL subqueries and CASE.

  • Columns of a FROM item: derived_target resolves x.col (or a bare col when FROM holds one item) to a target in a subquery or top-level WITH query that builds JSON. derived then walks that target in the item's own FROM/WHERE context and marks it nullable under an outer join.
  • CASE: when one branch builds JSON and every other branch is NULL (a missing ELSE counts as ELSE NULL), the column takes that branch's shape, nullable.
  • Naming: an unaliased column ref is named after its last field, and an unaliased CASE is named case.
  • Oracle and docs: adds oracle/cases/json_derived and a sentence on the Postgres docs page.

ℹ️ The diff also includes the base PR's commits

json-soundness has been rebased (now 0e78008, whose fix commit is a09a744), but this branch still sits on the old copies (566c930, 95de6c2). GitHub therefore shows the base PR's whole-row, reylo14 and non-finite-number changes as part of this PR. Rebasing onto the current json-soundness will reduce the diff to 998697d.

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

};
let body = match item.node.as_ref()? {
NodeEnum::RangeSubselect(r) => r.subquery.as_deref().and_then(as_select)?.clone(),
NodeEnum::RangeVar(rv) if rv.schemaname.is_empty() => self.ctes.iter().find(|(name, _)| *name == rv.relname)?.1.clone(),
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.

A RangeVar is resolved against self.ctes, which holds every top-level WITH query by name, wherever the reference stands. Postgres scopes these names, and this lookup gets the following wrong:

  • Crash: with t as (select jsonb_build_object('n', t.o) as o from t) select o from t. Inside the body, t is a table, since a non-recursive WITH query cannot read itself or a later sibling. Here it resolves back to the CTE, so derived and derived_target recurse forever and the engine aborts with a stack overflow. This runs from probes_ahead before any describe, so the statement does not even need to be valid.
  • Wrong shape (shadowing): a nested with x as (…) inside a subquery shadows a top-level x, but the outer x's shape is used.
  • Wrong shape (DML target): with users as (select jsonb_build_object('k', 1) as doc) insert into users (id) values (1) returning doc types the real users.doc column as { k: number }.
Technical details
# WITH-query lookup in `derived_target` ignores scope

## Affected sites
- engine/postgres/src/jsonshape.rs:410 — `self.ctes.iter().find(|(name, _)| *name == rv.relname)` matches any unqualified RangeVar in any context
- engine/postgres/src/jsonshape.rs:128-136 — `ctes` is a flat list of the top statement's WITH queries only
- engine/postgres/src/jsonshape.rs:426-441 — `derived` pushes a context without changing which CTE names are visible

## Reproductions (temp tests at 998697d)
- `probes_ahead("WITH t AS (SELECT jsonb_build_object('n', t.o) AS o FROM t) SELECT o FROM t")` → `thread 'reylo-engine' has overflowed its stack`, SIGABRT
- `WITH x AS (SELECT jsonb_build_object('a', 1) AS o) SELECT y.v FROM (WITH x AS (SELECT '{"b": "s"}'::jsonb AS o) SELECT jsonb_build_object('inner', x.o) AS v FROM x) y` → `v: { inner: { a: … } }`
- `WITH users AS (SELECT jsonb_build_object('k', 1) AS doc) INSERT INTO users (id) VALUES (1) RETURNING doc` → `doc: { k: … }`

## Required outcome
- A name resolves to a WITH query only where Postgres would resolve it to that query:
  - inside a WITH query's own body, only earlier siblings are visible (non-recursive WITH)
  - an inner query's own WITH shadows outer names
  - a DML statement's target relation is never a WITH query
- Otherwise the lookup returns `None`.
- Resolution can never recurse without bound.

## Suggested approach (optional)
Keep a stack of visible WITH-query scopes next to `stack`/`withs`, the way `Withs::with` already tracks names per level. Push the item body's `with_clause` in `derived` and in the SubLink arm. When walking a top-level WITH query's body, expose only the queries before it. In `top_context`, exclude the relation from `relation_from`/MERGE.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Right on all three. This PR was closed without merging and won't change; the fix is #53 (fix(postgres): scope the names json shapes read as postgres does). A non-recursive WITH query's body now sees only the queries before it, so its own name and a later one's are tables there, and nothing recurses. A subquery with a WITH of its own is not read. A DML statement whose WITH names the relation it writes gets no shapes. Your three statements, and variants of them, are in oracle/cases/json_scoping.

},
};
let body = match item.node.as_ref()? {
NodeEnum::RangeSubselect(r) => r.subquery.as_deref().and_then(as_select)?.clone(),
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.

The column-alias list of the FROM item is ignored here. (…) x(b, a) and w as y(b, a) rename the item's columns by position, but the target is still matched by its inner name (L416-419). As a result, select x.a from (select jsonb_build_object('k', 1) as a, '{}'::jsonb as b from users) x(b, a) is typed { k: number } while the row holds {} (reproduced; the same happens through a CTE reference). The WITH queries already bail on aliascolnames. Bailing the same way when r.alias or rv.alias has non-empty colnames, or matching by position, would keep this sound.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Right. This PR was closed without merging and won't change. Derived tables and WITH queries are now typed by #3 on main, which renames an item's columns by its column list, position by position; #53 adds your statement and the WITH variants (alias_list, cte_alias_list, cte_reference_alias_list in oracle/cases/json_scoping). The LATERAL half is #55, which renames a lateral subquery's columns by its alias's column list in the same way, and gives no shape where two columns share a name.

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

0