8000 fix(engine): type json built over a correlated subquery by colinhacks · Pull Request #104 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(engine): type json built over a correlated subquery - #104

Merged
colinhacks merged 4 commits into
mainfrom
json-correlated
Oct 24, 2026
Merged

colinhacks merged 4 commits into
mainfrom
json-correlated

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

JSON built over rows a subquery reads, where that subquery refers to the query around it, lost every field's type: the "latest few children per parent" pattern, (select coalesce(json_agg(json_build_object(…)), '[]') from (select … from comment c where c.post_id = p.id order by … limit 2) c), was typed { id: unknown?, body: unknown? }[].

Cause. Each field is typed by a probe, a synthesized select of the field's expressions over the constructor's own from. A derived table there that reads p.id cannot be described alone, so the engine falls back to a wider probe that appends every enclosing from — after the derived table, which, not being lateral, could not see them either. Both probes failed and every field was unknown.

Fix (Postgres and MySQL).

  • The wider probe lists the enclosing froms first, outermost first, and makes every derived table inside the statement's own from lateral, so it reads the outer items as it read the outer query. A name the probe would find twice fails the probe rather than reading another item, so the fallback stays sound.
  • A join whose on reads the outer query cannot see an item beside it, so that from is read inside a lateral derived table of the probe's own, where the outer query is an outer query again.
  • MySQL: a scalar subquery's own with query that refers to the outer query goes inside that derived table too, where no enclosing from sees it (only where no level around has a with, since MySQL hides an outer one there).
  • Postgres: coalesce(x.col, '[]') over a column a lateral subquery builds is read as the shape it builds, as the docs already state; it was plain json.

SQLite has no such gap: its probes already type every form.

Measured (new cases oracle/cases/json_correlated, mysql_json_correlated, sqlite_json_correlated): on Postgres, 17 statements go from unknown fields or plain json to typed fields, among them json_agg/jsonb_agg, to_json/row_to_json/to_jsonb of the derived row, two nested levels, a correlated derived table inside a lateral join, and a join to another table; on MySQL, 9. Three existing MySQL cases gain types too (mysql_json_shapes/array_agg_ordered_subquery, mysql_subquery_repair/subquery_inside_cte, mysql_subquery_with/nested_correlated and wider_shadowed): each newly typed field is a not null column or a count(*).

Checks. Whole oracle with --strict-coverage on Postgres 18 and MySQL 8.4; the engine's tests; cargo build --workspace --all-targets with no warning; engine/build.sh (the Postgres and MySQL artifacts change; SQLite's does not); nub run typecheck and nub run test with both test databases, none skipped; nub run build.

Copilot AI balanced review requested due to automatic review settings October 24, 2026 00:39
@vercel
vercel Bot commented Oct 24, 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 24, 2026 1:55am 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

One 8000 MySQL path drops the statement's with from the new wrapped probe. When a table name is shadowed, that infers the wrong types. The fix is one line (inline).

Reviewed changes

I reviewed all 57 files: both engine crates, the three new oracle cases, the three re-blessed MySQL cases and the rebuilt artifacts.

  • Wider probe order and lateral. The wider from now lists the enclosing froms outermost first. Derived tables below the top level become lateral, so a correlated derived table reads the outer items it means. Any name the probe would find twice makes it fail, so this is sound.
  • Wrapped probe for a correlated on. A from with a join whose on reads the outer query is read inside LATERAL (…) reylo_probe (wrapped_text in both crates). On MySQL, a subquery's own with also goes inside that wrapper.
  • Postgres FILTER and coalesce. An aggregate's FILTER level no longer repeats its from in the wider probe. coalesce(x.col, '[]') and x.col::json over a column a lateral subquery builds now get the shape it builds. Defaults other than '[]' stay one value (build's CoalesceExpr arm).
  • Oracle cases. Adds json_correlated, mysql_json_correlated and sqlite_json_correlated. I checked the re-blessed mysql_json_shapes, mysql_subquery_repair and mysql_subquery_with lines against their setup schemas, and they are right.

Two things I checked that need no change. row_present keeps the from length, so slicing at Draft::inner cannot panic. Postgres returns Unknown for a subquery with its own with, so the MySQL issue has no Postgres counterpart.

ℹ️ No case covers an outer join inside the wrapped from

On both dialects, the wrapped probe's nullability now passes through a lateral derived table. Every new case with a correlated on (join_on_correlated) uses an inner join, so the oracle never sees a nullable column that has to survive the wrapper. A case like from comment c left join person a on a.id = c.author_id and a.id = p.author_id in json_correlated and mysql_json_correlated would cover it.

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

Comment thread engine/mysql/src/mysql_shape.rs Outdated
// hides an outer WITH inside a query that has its own.
let own = depth > 0 && self.stack[depth].with.is_some();
let outer = self.stack[..depth].iter().any(|l| l.with.is_some());
let wrap = (depth > 0 && !wider.is_empty() && !from.is_empty() && (from.iter().any(correlated_on) || (own && !outer))).then(|| Wrap {
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.

When own and outer both hold, a correlated on still builds a Wrap. Because own is true, it gets with: String::new(), so the wider probe carries no with at all. An enclosing from that reads one of the statement's with queries then reads a real table of that name instead. I reproduced this with a throwaway unit test. For with posts as (select id, email as title from users) select (with r as (select 1 as k) select json_arrayagg(json_object('t', p.title, 'n', u.name)) from comments c join users u on u.id = c.user_id and c.post_id = p.id, r) as j from posts p, the narrow probe fails (p is not in its from). The wider probe is SELECT reylo_probe.* FROM posts p, LATERAL (WITH r AS (SELECT 1 AS k) SELECT p.title AS a0, …) AS reylo_probe. That reads p.title from the real posts.title (text, not null), but the statement reads users.email (nullable). Before this PR the flat probe failed at the on, so this case was unknown and is now wrong.

The suggestion applies the guard the comment above describes to both reasons for wrapping. With it, this case falls back to the flat wider probe, which fails at the on as before.

Suggested change
let wrap = (depth > 0 && !wider.is_empty() && !from.is_empty() && (from.iter().any(correlated_on) || (own && !outer))).then(|| Wrap {
let wrap = (depth > 0 && !wider.is_empty() && !from.is_empty() && !(own && outer) && (own || from.iter().any(correlated_on))).then(|| Wrap {

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: wrapped, a subquery with a with of its own under a level with one lost the statement's with, which an enclosing from reads. Fixed in a81b425 with this guard: neither reason wraps that subquery now, so the case falls back to the flat probe, which fails at the on as before. The case is a unit test in a_correlated_from_is_read_after_the_queries_around_it.

@pullfrog
pullfrog Bot commented Oct 24, 2026
Copy link
Copy Markdown
Contributor

Your Claude Pro/Max subscription has hit its usage limit. It resets at Oct 26, 8am (UTC). Re-trigger after that, or connect an Anthropic API key, which Pullfrog falls back to automatically.

The provider's response
You've hit your weekly limit · resets Oct 26, 8am (UTC)

Connect a provider →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@pullfrog
pullfrog Bot commented Oct 24, 2026
Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model â 8000 †’

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@pullfrog
pullfrog Bot commented Oct 24, 2026
Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@pullfrog
pullfrog Bot commented Oct 24, 2026
Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@pullfrog
pullfrog Bot commented Oct 24, 2026
Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@colinhacks
colinhacks merged commit e817f03 into main Oct 24, 2026
10 of 11 checks passed
@colinhacks
colinhacks deleted the json-correlated branch October 24, 2026 01:59
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