8000 fix(postgres): keep a table's not null in array_agg of its whole row by colinhacks · Pull Request #97 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(postgres): keep a table's not null in array_agg of its whole row - #97

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

colinhacks merged 1 commit into
mainfrom
fix/pg-array-agg-rows

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

array_agg(e) over a table's whole row typed every field of its element row as nullable, even though the table's NOT NULL holds for each of those rows. jsonb_agg(e) beside it already kept those constraints. Each element is a row the scan read, so it meets the table's constraints, the same as an array(select c from child c …) already does.

select e.kind, jsonb_agg(e order by e.id) as events, array_agg(e) as rows from events e group by e.kind
  • Before: rows: (events{ id: int4?, kind: text?, payload: json?, note: text? }!)[]
  • After: rows: (events{ id: int4, kind: 'click' | 'key', payload: json {…}, note: text? }!)[], the same element as events, with the CHECK-derived enum and JSON Schema

The rule

A top-level array_agg(e) or array_agg(e.*) holds stored rows of e's table when all of these hold:

  • e is an item of the SELECT's own FROM;
  • no table the statement reads has a column named e;
  • every FROM item is a plain table;
  • the column's type is an array of that table's row type;
  • array_agg is the built-in one.

It may be ordered, filtered or over a window. The column is marked whole_row, the same mark an ARRAY subquery of stored rows gets.

Outside JSON, the run time decodes a composite or an array of them as the text Postgres prints, typed string. So the difference shows in the type and, inside JSON (jsonb_build_object('rows', array_agg(e))), in each field's type, where the oracle checks the values.

An outer join nulls a whole row: its element stays nullable, and a row that is there keeps its fields' own nullability, as jsonb_agg(p) beside it shows. A row value the statement builds, such as array_agg(row(…)::events), keeps every field nullable.

Oracle (inference_precision)

  • whole_row_agg_grouped: rows: element goes from events{ id: int4?, kind: text?, payload: json?, note: text? }! to the events{ id: int4, kind: 'click' | 'key', payload: json {…}, note: text? }! beside it.
  • whole_row_outer_joined: rows: element goes from posts{ id: int4?, user_id: int4?, title: text?, published_at: timestamptz? }? to posts{ id: int4, user_id: int4?, title: text, published_at: timestamptz? }?.
  • New statements:
    • whole_row_array_agg_in_json and whole_row_array_agg_outer_in_json: array_agg(e) inside jsonb_build_object, where the oracle checks each field.
    • whole_row_array_agg_built: a built row stays all-nullable.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 22:56
@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 10:56pm 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.

✅ No new issues found.

Reviewed changes

This review covers the Postgres engine's new rule that a top-level array_agg of a table's whole row holds stored rows of that table, along with its oracle cases and the rebuilt artifact.

  • sublink_rows reads array_agg(e) / array_agg(e.*): a top-level FuncCall target over a whole row named by an item of the SELECT's own FROM is now recorded as an array of that table's rows. It is tagged as an aggregate through a new fourth tuple field. agg_star, WITHIN GROUP, multi-argument calls, named arguments and schemas other than pg_catalog are refused.
  • stored_sublinks checks trust: it now takes &Trust and drops the aggregate targets when array_agg is shadowed. Because array_agg is already in POSTGRES_GROUPED_NEVER_NULL, postgres_function_names() asks the catalog about it. The existing guards still apply on the top-level path and the probe path: plain-table FROM items only, no table named like a WITH query, unambiguous(name), and the column type must be an array of the table's row type.
  • Oracle inference_precision: the rows: elements in whole_row_agg_grouped and whole_row_outer_joined now carry the catalog's nullability, CHECK enums and JSON Schema. Three new statements cover a built row that stays all-nullable, plus the inner-join and outer-join versions inside jsonb_build_object, where the oracle checks each field's values.
  • packages/postgres/engine.wasm: rebuilt.

I checked these cases and found no problem with them:

  • Outer joins: a null-extended whole-row Var is NULL, so the element stays ?.
  • Grouping sets: functional dependency only uses Vars common to every set, so a grouped e can't be partly nulled.
  • Shadowing: a user-defined array_agg(events) that would win resolution is caught by the trust check.

The existing "Whole rows" rule in postgres.mdx already describes this behavior, so the docs don't need to change. I didn't run the engine tests locally because this environment had no prepared workspace. CI's engine and oracle jobs are the check.

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

@colinhacks
colinhacks merged commit f11ad17 into main Oct 23, 2026
6 checks passed
@colinhacks
colinhacks deleted the fix/pg-array-agg-rows branch October 23, 2026 23:04
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