8000 fix(postgres): type a json coalesce by the default it falls back to by colinhacks · Pull Request #10 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(postgres): type a json coalesce by the default it falls back to - #10

Merged
colinhacks merged 6 commits into
mainfrom
json-coalesce-default
Oct 22, 2026
Merged

colinhacks merged 6 commits into
mainfrom
json-coalesce-default

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

On Postgres, coalesce(x, d) of JSON a statement builds took x's shape whenever d was any non-null constant. That is wrong for an aggregate: over no rows jsonb_agg is NULL and the value is d, so select coalesce(jsonb_agg(id), '{}') as ids from posts where false was typed { ids: number[] } and returned {}. The same held for an object from a scalar subquery that can find no row, and for a value under a non-constant default.

Now coalesce(x, …) keeps x's shape only where x stands alone, where it is never NULL (jsonb_build_object, or an aggregate under a plain GROUP BY), or where it is an array defaulted to '[]', the one default that is always an array. Under any other default the whole coalesce is one value of unknown JSON, and it is non-null where a default is.

Statement Before After
select coalesce(jsonb_agg(id), '[]') as ids from posts where false ids: [int8] ids: [int8]
select coalesce(jsonb_agg(id), '{}') as ids from posts where false ids: [int8] ids: json
select coalesce((select jsonb_build_object('id', p.id) from posts p where p.user_id = u.id limit 1), '[]') as j from users u j: { id: int8 } j: json
select coalesce(app.json_agg(name), '[]') as j from users (a user's aggregate) j: json? j: json

The oracle did not catch this because its check of a Postgres document returned no contradiction where the value was of the wrong kind: an object where a list is typed, or a list where an object is. It fails that now, as the SQLite check already did, and a unit test covers it. With the stricter check, every oracle case still passes.

Tests:

  • oracle/cases/json_coalesce_defaults: 8 statements, sound over the seeded edge values.
  • oracle/cases/json_user_functions/qualified_aggregate.sql: re-blessed, non-null now.
  • Run locally: the Rust build and tests, the artifact fingerprint, the oracle with --strict-coverage, the loader's typecheck and tests, the package build, and the basic and JSON Schema labs.

Copilot AI balanced review requested due to automatic review settings October 22, 2026 16:43
@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 9:04pm UTC

Request Review

@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 new coalesce fallback fixes the unsound shapes. But it also makes sibling fields in the same jsonb_build_object lose their types when they were typed correctly before. Details and a tested fix are inline.

Reviewed changes

I reviewed the whole PR: the coalesce arm in the Postgres JSON shape builder, the stricter oracle document check, the new oracle case and the docs.

  • coalesce keeps x's shape only when that is sound: x is kept when it is the only argument, when it is a non-null object or array, or when it is an array defaulted to '[]' (is_empty_array). Otherwise the drafts are rolled back and the whole coalesce becomes one json leaf.
  • The oracle now checks the kind of a document: in json_contradiction, a value typed as an object that is not a JSON object, or typed as a list that is not a JSON array, now counts as a contradiction. A unit test covers this.
  • New case and re-blessed output: oracle/cases/json_coalesce_defaults adds 8 statements. qualified_aggregate changes from json? to json.
  • Docs: the "Aggregates over no rows" section now says that any other default gives unknown.

I ran the oracle against PG16 on a scratch case, built once from this PR and once from main. The PR's own cases and json_user_functions pass.

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

Comment thread engine/postgres/src/jsonshape.rs Resolved
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.

ℹ️ The fix for the shared probe is sound. One narrower case still loses its siblings' types — details inline.

Reviewed changes

I reviewed ea3e1a3, which gives an aggregating coalesce fallback a probe of its own.

  • A fallback that aggregates gets its own probe: when crate::infer::aggregates sees an aggregate in the coalesce, its leaf goes under a fresh context id over the same FROM and filter. Other fallbacks stay on the context's shared probe, so they use none of the MAX_PROBES budget.
  • New cases and a unit test: sibling_aggregate and grouped_filter cover the two statements from the earlier thread. a_coalesce_read_as_one_value_probes_an_aggregate_apart checks both probe layouts.

I ran the reylo-engine-postgres lib tests on this head. They pass.

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

Comment thread engine/postgres/src/jsonshape.rs Resolved
@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 Shape::Unknown arm fixes the shared probe regression, and I resolved that thread.

Reviewed changes

I reviewed 787d589, which leaves a coalesce over an x the build cannot explain as Unknown.

  • An unexplained x leaves the whole coalesce unexplained: a new (Shape::Unknown, _) arm comes before the fallback. It restores self.drafts and returns Unknown without leafing, so nothing reaches the context's shared probe. I traced every path where walk returns Unknown: overflow, a malformed constructor, a ROW_FNS call with a non-alias argument, a non-EXPR sublink, and leaf on a placeholder or an exhausted budget. In each one, main already returned Unknown, and at ea3e1a3 the fallback produced either the same Unknown or an unsound shared-probe leaf. So the arm loses no typing.
  • New case and unit test assertion: unexplained_beside_column records { name: text, titles: unknown? }. The unit test checks that the probe holds only u.name.

I ran the jsonshape lib tests on this head. All 26 pass.

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

coalesce(jsonb_agg(x), '{}') over no rows is {}, yet was typed as the
aggregate's array. An array keeps its shape only under '[]' now, and
any other default makes the coalesce one value of unknown JSON. The
oracle's check of a document now fails a value of the wrong kind.
A coalesce read as one value carried its aggregate into the probe of
its context, which has no GROUP BY, so a bare column beside it failed
that probe and every field read from it was unknown.
@colinhacks
colinhacks force-pushed the json-coalesce-default branch from 787d589 to bc1140d Compare October 22, 2026 21:03
@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 placeholder arm is sound, and the re-blessed with_nested_coalesced output is a correct tightening.

Reviewed changes

I reviewed 6a1f908, 7f190e7 and bc1140d, which came after 787d589.

  • A placeholder keeps its type under a default that cannot widen it: the new (Shape::Param(_), defaults) arm keeps Param only when every default is a NULL, an untyped string, an integer or a boolean constant. Anything else falls to leaf, which refuses an expression that reads a placeholder, so coalesce($1::int, 1.5) is unknown?. main typed it as int4 even though it is a numeric. Under the safe defaults the arm brings back main's typing, which 787d589 had lost to Unknown.
  • One empty array test, shared: jsonshape now calls crate::infer::is_empty_array. It accepts the same nodes as the removed copy: an Sval '[]', optionally cast to json or jsonb.
  • with_nested_coalesced re-blessed as non-null: coalesce(ps.posts, '[]') walks ps.posts to a Column, not an Array. Since 73a9521, that sends the whole coalesce to the probe as one leaf. There, item_reads sees an ItemRead::Defaulted, and postgres.rs makes it non-null only when the item's column is a Type::List. So [{ id, title }] without ? is sound. Before this commit, the old expected output was stale.

I ran cargo test -p reylo-engine-postgres --lib jsonshape on bc1140d. All 27 tests pass. I did not run the oracle.

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

@colinhacks
colinhacks merged commit d450564 into main Oct 22, 2026
4 checks passed
@colinhacks
colinhacks deleted the json-coalesce-default branch October 22, 2026 21:07
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