8000 fix(mysql): type a json aggregate over a derived table as non-null by colinhacks · Pull Request #21 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(mysql): type a json aggregate over a derived table as non-null - #21

Merged
colinhacks merged 1 commit into
mainfrom
mysql-derived-null
Oct 22, 2026
Merged

colinhacks merged 1 commit into
mainfrom
mysql-derived-null

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

On MySQL, a guarded JSON aggregate in a scalar subquery over a derived table was typed as possibly null, while the same query over a WITH query was not:

select (
  select coalesce(json_arrayagg(json_object('id', p.id, 'title', p.title)), json_array())
  from (select id, title from posts order by id desc limit 5) p
) as recent_posts

recent_posts was { id: number; title: string }[] | null; it is now { id: number; title: string }[].

A scalar subquery is exactly one row when it is an ungrouped aggregate of its own, and an aggregate is the subquery's own when it reads a column of the subquery's FROM. That check only recognized plain tables, so a column read through a derived table's alias proved nothing and the subquery fell back to nullable. The LIMIT inside the derived table played no part.

Now the check also counts:

  • a column qualified with a derived table's alias (or a JSON_TABLE's), including inside a parenthesized join;
  • a bare column a derived table selects, by its column list or the names its select list gives (a column or an alias; an item named by its text, or a wildcard, still proves nothing);
  • a bare column of a WITH query, read the same way, when the reference names the WITH query exactly as it is written.

An aggregate over only the outer query's columns still leaves the subquery nullable, and so does an unguarded aggregate.

Tests:

  • mysql_shape unit tests cover the derived-table form, the WITH form, no LIMIT, ifnull, a derived table in the outer FROM, bare columns, column lists, and the negative cases.
  • The new oracle case oracle/cases/mysql_derived_aggregates covers the same forms. Each statement runs on MySQL 8.4 over seeded values, and none is unchecked.
  • Two existing expected types tighten, and the oracle confirms both on MySQL 8.4: mysql_aggregate_levels/bare_own_derived and bare_own_with become int8 (a count over the subquery's own columns), and mysql_json_shapes/array_agg_ordered_subquery loses its outer ?.
  • packages/mysql/engine.wasm is rebuilt.

Copilot AI balanced review requested due to automatic review settings October 22, 2026 19:18
@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 7:35pm 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 new issues found.

Reviewed changes

I reviewed how Builder::owns decides that a scalar subquery's aggregate is its own when the subquery reads from a derived table, a JSON_TABLE, or a WITH query. I also checked the oracle expectations this changes. cargo test -p reylo-engine-mysql --lib mysql_shape passes locally (9 tests).

  • Derived-table and JSON_TABLE aliases count as qualifiers. owns now pushes these aliases into items. It also flattens unaliased NestedJoins, so a qualified read such as p.id through them proves ownership. A qualifier always resolves to the innermost FROM first, so this is sound.
  • Bare columns of derived tables and WITH queries. The new output_columns reads the alias column list if there is one. Otherwise it reads the first select's items that are a plain column or have an AS alias. It deliberately leaves out wildcards and items named by their expression's text. That under-approximates the column set, so the worst case is lost typing, never a wrong non-null. Using the left branch of a set operation matches how MySQL names a UNION's columns.
  • WITH-query lookup keeps the old case rule. A reference only gets the WITH query's columns when it names the query in exactly the same case. A name in another case still falls back to Source::Unknown, as the old ctes.contains(lowercase) check did.
  • Truth-value splitting is unaffected. The other caller, keeps_its_aggregates, compares against aggregates_in(q.to_string()). That count also includes aggregates inside derived tables, so it stays conservative there.
  • Oracle. The new case mysql_derived_aggregates covers the positive and negative forms. Three existing expectations tighten, and I checked each against its SQL: bare_own_derived, bare_own_with and array_agg_ordered_subquery. In eac 8000 h one the aggregate reads only the subquery's own derived-table or WITH-query columns.

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

A scalar subquery is one row when its aggregate reads columns of its own
FROM. A derived table's columns, by its alias or by the names its select
list gives, and a WITH query's bare columns now count, so a guarded
json_arrayagg over a derived table is non-null as over a WITH query.
@colinhacks
colinhacks merged commit 3173478 into main Oct 22, 2026
4 checks passed
@colinhacks
colinhacks deleted the mysql-derived-null branch October 22, 2026 19:36
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