You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(unparser): keep the ORDER BY and row bound a node above already set, instead of letting a nested sort or limit overwrite them (refs spiceai/spiceai#14733) - #263
The unparser folds every Sort and Limit of a plan into one QueryBuilder, which holds a single ORDER BY and a single row bound. A node unparsed after another had already set them overwrote them, so the emitted SQL carried the inner node's keys or bound under the outer node's position — the last writer won. The issue's shapes, run in DuckDB 1.4.4 over u(a, b) = (1,9) (2,8) (3,7) (4,6) (5,5) and t(a) = 1..10, before this change (spiceai-55 at cf084aa85):
plan
emitted SQL before
rows before
plan's rows
Sort[u.a, fetch 2] over Sort[u.b]
SELECT * FROM u ORDER BY u.b ASC NULLS FIRST LIMIT 2
(5,5) (4,6)
(1,9) (2,8)
Sort[t.a, fetch 3] over Join(t, Sort[u.a desc] over u)
… GROUP BY t.k ORDER BY (t.k + 1) DESC NULLS FIRST LIMIT 2
2 rows
1 row
What changes are included in this PR?
The query builder now says what it already carries, and each node folds in only where it would have folded under a node the walk absorbed itself:
QueryBuilder::has_order_by, literal_row_bound, merge_inner_row_bound (ast.rs): an inner literal bound merges with the one above through combine_limit, the way the optimizer merges stacked limits; an expression bound cannot merge.
Limit arm: under an existing ORDER BY the limit takes a scope of its own (derive_row_limited_scope, which now walks through a Sort when the enclosing query re-establishes the order); under an existing bound it merges, or takes a scope when either bound is an expression.
Sort arm: under an existing ORDER BY an unfetched sort is dropped (the outer order reorders it) and a fetched one takes a scope; a fetch under an existing bound merges. Inside a join input a sort's ORDER BY is dropped (a join leaves its output order unspecified) and a fetched sort becomes a derived table under the scan's name, as a join input's Limit does. A sort's keys resolve against the node under the unfetched sorts the walk drops and under a DISTINCT.
Projection arm: the Sort/Limit fold starts from the state the query already holds, and the grouped-predicate refusal in row_limit_needs_own_scope runs before that state stops the fold. A chain the fold leaves in a derived table is derived directly with every output named (name_scope_outputs), the clauses above it re-pointed at those outputs by bare name (t.k → k; t.payload.x → payload.x once the hidden schema confirms payload is a column of the hidden t), and, when the enclosing query bounds rows without ordering them, re-ordered by the chain's own sort keys (refused when the projection computes an output named like one of those keys, which would capture it). Two outputs that share a name under the dialect's identifier folding, a clause above holding a subquery, and an outer reference in the projection named like a derived output are refused. Re-establishing the chain's order is refused in two more cases: a sort key that cannot be evaluated a second time (random()), and an outer reference in a key that a computed alias would capture. A bare ORDER BY key is refused when a computed alias whose name folds to the key's name would capture it. For a sort above the projection, the alias the key itself reads, spelled the same, is exempt. For a sort absorbed from below the projection there is no exemption, because that key reads the derived table, not the alias.
rewrite_plan_for_sort_on_non_projected_fields: the fold has always hoisted a sort through an inner projection when the outer projection and the sort read exactly that projection's outputs (equal sets). It may now also drop inner outputs that nothing above reads (a strict subset), so the outer order stays at the top level. It does this only within a subset that is safe by construction (widened_fold_is_safe):
every output it keeps is a plain column named by that column, in both the inner and the outer schema;
the outer list reads each kept output once;
no inner output is volatile or holds a subquery.
Every other shape keeps the sort in its own scope and emits exactly what spiceai-55 emits. This replaces the per-shape guards of earlier rounds; the escalation comment on this PR explains why.
substitute each dropped output into the key bottom-up;
drop a key that the substitution turns into a literal;
decline the fold when a key reads a dropped output that cannot be repeated, when the outer list repeats such an output, or when a kept alias would capture a substituted column or outer reference.
On both paths the fold is also declined when two inner outputs share a display string, the spelling it matches outputs by (t."a.b" and an alias "t.a.b" both display as t.a.b). It is declined as well when an alias or plain column carries a name other than the one its schema declares (outputs_named_as_declared).
The capture checks compare names through Unparser::emitted_column_key, so they are case-folded. A derived projection now counts as a scope that strips qualifiers, as an aggregate over one already did.
Refusals where no faithful scope exists: a fetched sort under an expression bound, a limit under a sort over an aggregate, and a HAVING above a limited aggregate under a sort.
Shapes with one Sort/Limit, or stacked ones that already merged, emit the same SQL as before: the datafusion-sql suite's 22 pre-existing failures (Snowflake flatten, roundtrip_statement*, test_struct_expr, …) are unchanged in name and count. One existing snapshot changes: test_unparse_aggregate_with_non_literal_limit_over_sort_with_fetch gains an outer ORDER BY __agg_0 ASC NULLS FIRST before its LIMIT CAST(7 AS INTEGER), so the outer bound keeps the rows the inner sort ranked first.
Are these changes tested?
70 new tests in plan_to_sql.rs pin each shape above and every shape the reviews raised. Every round's tests were run red against the head before that round, as quoted in the commit messages and review threads. For the narrowing round, the final test file fails 13 tests beyond the pre-existing 22 when run against the ed3515dc5 sources: the 9 regression tests for this round's threads and adversarial findings, and 4 tests whose fold is now given up (listed in the PR conversation).
A mutation pass on the 7a0e80723 sources disabled each of eight fold and refusal guards on its own and re-ran the suite. Every one of them has a test that fails without it. The two refusals added after that commit are pinned by tests that fail on the commit before each one.
cargo test -p datafusion-sql --test sql_integration, compared by test name:
this branch at 724148148, which merges spiceai-55ddd5c8ce6: 898 passed, 22 failed;
spiceai-55 at ddd5c8ce6: 828 passed, 22 failed;
the 22 failures are the same set on both (test_struct_expr, snowflake_flatten_*, roundtrip_statement*, …).
Differential check against DuckDB 1.4.4 (Python duckdb): a probe binary unparses each shape with plan_to_sql, and the oracle runs the emitted SQL and a hand-written statement for the same plan over the same tables, comparing column count and rows (ordered where the plan orders). Before, on cf084aa85:
After, on this branch at d294df539, over the 33 shapes accumulated through the reviews (the issue's, the derived-aggregate forms, the fold's pruned/reordered/unnamed/volatile/shadowed/literal keys, the sort-over-sort and sort-over-DISTINCT stacks over a derived aggregate, the struct-field key, the case-folded names, the expression bound over a sorted chain):
RESULT: PASS (33/33 shapes agree with DuckDB 1.4.4)
(S6 reads random(), so the oracle checks its two rows are the two smallest u.a, ascending. A further shape, a fetched sort whose key reads a dropped random() output twice, is pinned by …dropped_volatile_output_keeps_the_sort_in_its_scope as emitting exactly what the base emits — the sort stays in its own scope; the repetition of random() inside that scope's key is spiceai/spiceai#14109's, fixed by #247 — and is kept out of the row comparison because that inner key is nondeterministic on both. The refused shapes count as agreeing: the oracle reports them as refusals, not rows.)
Are there any user-facing changes?
A federated query whose plan stacks a sort or limit under another now returns the rows and order the plan asks for from the remote engine, or is refused and answered locally, instead of returning the rows the inner node selects. A plan whose aggregate takes a derived table of its own no longer references a relation that derived table hides, names the outputs it reads through it, and keeps its order under an outer parameterised LIMIT.
Review gate
Adversarial review: skipped — codex exited 1 on the final head 724148148, and on bdd8d629b and 05cd0b2c7, with "Your workspace is out of credits". Under grokspice, codex is the only allowed engine. The two earlier heads of this narrowing round were reviewed:
codex job review-mvbma1d1-uijeaw on 7a0e80723 returned needs-attention with 1 finding. A bare key from a sort absorbed below the projection is captured by (0 - s) AS s: SQLite and DuckDB return -20 where the plan returns -10. Reproduced, and also present on spiceai-55. Fixed in 56442fc82.
codex job review-mvbmrj3w-21cd7u on 56442fc82 returned needs-attention with 1 finding. An outer reference in a re-established key is captured by a computed alias: DuckDB returns -3 where the plan returns -1. Reproduced and fixed in 05cd0b2c7.
05cd0b2c7 was read only by a read-only correctness sub-agent, which found no defects. It left two notes, both unverified, code inspection only: a qualified outer reference is over-refused (costs a pushdown, never wrong rows), and an outer reference nested inside a compound absorbed key is not checked against computed aliases.
Copilot's two threads on bdd8d629b (display-string collision; alias declared under another name) were reproduced red on bdd8d629b and fixed in 724148148 by declining the fold. One fold an existing test expected is given up and listed on its thread. /security-review was not re-run for 724148148, which adds two early returns and no emission.
/security-review: no findings. It was re-scoped to this worktree's diff, because the harness's default diff was a different checkout. The commit adds no new identifier or literal emission, the new indexing is bounded, the refusals are static strings, and errors make the fold fail closed.
/simplify (read-only sub-agent): 3 behavior-neutral proposals, applied in 05cd0b2c7. One emitted_column_name call per alias, HashMap imported, and ? inside exists in place of a hand-written error fallback. No guard is unreachable on both fold paths, so none was removed.
Earlier rounds: codex — four reviews ran, each returning needs-attention with findings that reproduced and are fixed here: review-mvakq4tb-dcjg8v on 40bca8df3 (an unnamed computed output dropped from the fold left ORDER BY "u.a + u.b"; a fetched sort over an unfetched sort over a derived aggregate expanded s to sum(v) outside the derived table — fixed in 5da169eb4), review-mval06t5-jupko2 on 5da169eb4 (a dropped random() output substituted at both references of the key — the fold is declined in 246bf0d35), review-mvalb534-g0yh8n on 246bf0d35 ((s + 1) AS s substituted without end, a stack overflow; a projected lit(1) substituted into ORDER BY 1, a column position — fixed in 4d1df092a), review-mvalis5g-s03gop on 4d1df092a (a surviving 0 - s AS s capturing the substituted s of a dropped s AS x — the fold is declined in a25f720bd). On the heads after a25f720bd the engine was skipped — codex exited 1: "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." (receipt head=d294df539…, attempt=codex, engine=none). An earlier partial run on 543fb7445 had left two hypotheses: a stale schema after pruning (then not reproduced, later reproduced by Copilot's reordered-alias shape and fixed) and the out-of-scope qualifier above a derived aggregate (reproduced as S12, fixed in 651373395).
Copilot's reviews (13 threads over four passes): twelve addressed — a HAVING left outside the derived chain, duplicate bare names after qualifier removal, an unaliased aggregate output at the derived boundary, the stale projection schema after a reordering fold (40bca8df3); an expression bound over a sorted chain now re-ordered outside, case-folded duplicate names, a struct-field key, an outer-reference capture, and a DISTINCT between sort and projection (43256a482, c075660ab); a second-pass substitution collision that a25f720bd already declines; and the field-path re-pointing narrowed in 0cda516fd so that the shared helper keeps its full-qualifier form (a sibling t.s.a under full qualification is untouched) and only the derived-chain scope drops a shorter prefix, with the hidden schema confirming the column; and a computed projection alias named like a re-established key ((0 - s) AS s above ORDER BY s), refused in d294df539. One thread stays open as a hypothesis: a projection alias named like the group column its key reads cannot be built (DataFusion rejects the schema as ambiguous), so no plan reaches the described capture. Note: 43256a482 was pushed with a test that did not compile (get_field unimported) because the gate read an empty failure list as clean; c075660ab fixes it, and the gate now stops when the suite prints no result line.
Fix pass b890188b6 (spiceemma, scoped to that commit only): Copilot's 07:23Z/07:32Z pass. Reproduced first, each as a new regression test that fails on d294df539: a scalar output named like the hidden relation kept ORDER BY (t.k + 1) outside the derived table; and (0 - s) AS "S" and an outer S got past the computed-alias and outer-reference capture checks. All three are fixed (both checks now compare through emitted_column_key, and the field-path root is kept only for a struct or map output, dictionary-encoded or not). The rewrite.rs case-folded surviving-alias shape did not reproduce: the fold is already declined. It is pinned by a test, and rewrite.rs is unchanged. Adversarial review: codex, job review-mvar4vo7-m7s0ok, scoped --base HEAD~1, returned needs-attention with one finding: dictionary structs and maps also take field access. The root check was widened to cover them. Its claim that this changes the emitted SQL did not reproduce: a dictionary-struct and a map probe emitted the same SQL before and after the widening. /security-review: the diff only adds refusals and name comparisons. It adds no input surface, quoting or I/O, so there are no findings. Local gate: cargo fmt --check passes. cargo clippy -p datafusion-sql --all-targets -D warnings (1.97.1) is clean. plan_to_sql gives 445 passed / 22 failed, and the 22 are the same set that fails on base spiceai-55 (409 passed / 22 failed). Unparser lib tests: 52 passed.
Found while probing, reproduced on d294df539 and not fixed here: a struct output t.payload AS t beside a grouping column t.x, with a fetched sort on get_field(t, 'x') above Limit → Projection[t, s], emits ... GROUP BY t.x, t.payload LIMIT 2) ORDER BY x DESC NULLS FIRST LIMIT 1. That sorts by the grouping column x, not by payload.x.
Residual, unverified: an outer reference in a clause above the projection (a filter's) is converted to SQL before this scope is built, so the capture check in the projection does not see it.
/security-review (read-only sub-agent over the diff): no quoting, panic or overflow findings (combine_limit saturates; LIMIT literals parse through usize::from_str). Two low, unverified, code inspection only, both pre-existing shapes this diff does not touch: peel_to_unnest_with_modifiers sets ORDER BY/LIMIT unconditionally on unnest_as_table_factor/lateral_flatten dialects (the same last-writer-wins class, for a Sort → Projection[unnest] → Sort → Unnest plan on BigQuery/Snowflake), and a fetched sort under an outer sort over a projected input derives under DERIVED_SORT_ALIAS, so an ORDER BY u.a emitted above names a qualifier the derived table hides (a remote binder error, not wrong rows).
/simplify (read-only sub-agent): 5 proposals, all applied — has_row_limit folded into the identical bounds_rows, the Limit arm's literal-merge match folded into one if let chain, bounded computed once, and two rustdoc/comment corrections; suite, clippy and the oracle re-run green afterwards.
Fix pass 169f91ceb + 8837afb9c (spiceemma, scoped to 9832ce2a2..8837afb9c): Copilot's three threads on 9832ce2. Each was reproduced first as a regression test that fails on 9832ce2, and checked against DuckDB. (1) Under full qualification, a key t.s.a (column of t.s) beside table t's column s was repointed to s.a, which DuckDB rejects. The full qualifier is now matched first, and a reference that is also a struct field path is refused. (2) An outer reference in an ancestor, absorbed or re-established sort key rendered as a bare s that the derived output s captured: DuckDB picked k = 1 where the plan wants k = 2. The Sort arm now records the outer references its keys read, and the capture check covers all three paths. (3) The sort fold's shadow guard ignored names declared through Projection::try_new_with_schema, so it emitted ORDER BY (a + b) (3 instead of 11). The guard now reads the folded projection's own schema. Adversarial review: codex exec --sandbox read-only on 9832ce2a2..169f91ceb returned four findings. Two reproduced and are fixed in 8837afb9c: an outer reference in a re-established order (DuckDB 2 vs 1), and the guard's names disagreeing with the schema for a repeated output (DuckDB 3 vs 11). One did not reproduce: the t / t.s / t.s.a field-path shape is already refused, and a test pins it. One is rejected as an over-refusal only: a qualified outer reference is refused like an unqualified one, because the repoint walk strips a qualifier that names a hidden relation (unverified, code inspection only). Local gate: plan_to_sql gives 456 passed / 22 failed, the same 22 by name as on 9832ce2 (448/22) and base. cargo fmt --check passes, and clippy 1.97.1 -D warnings is clean.
Fix pass 3d8e76946 (spiceemma, scoped to 8837afb9c..3d8e76946): Copilot's three threads on 8837afb. Each was reproduced first as a regression test that fails on 8837afb9c, and the first two were checked in SQLite. (1) When the fold's field names collided, it fell back to the outer schema, so the key y + 1 resolved by position to the second copy of t.a + 1. SQLite returned 2|2|101 where the plan wants 6|6|1. The fold is now declined. (2) An unqualified outer reference in a dropped output was substituted into the key, where a kept alias of the same name captured it. SQLite returned -5 where the plan wants -1. The shadow guard now declines this case. (3) Under full qualification, a flattened get_field_path(t.s, 'a', 'b') took the longest relation prefix t.s and emitted a.b, a field access on a scalar. A prefix is now taken only where the column's type has the rest of the path, which gives s.a.b. Adversarial review: codexadversarial-review --base 8837afb9c (receipt rc=0) returned needs-attention with one finding: get_field accepts a NULL base, so the path check should accept Null. That arm is added. Unverified: the described plan could not be built, because get_field on a NULL column unparses as a bare b, on base spiceai-55 as well. /security-review could not run (the session's working directory is not a git repository). A manual read of the diff found no input surface, I/O or new panics: the slice idents[prefix_len + 1..] is in bounds because prefix_len < idents.len(), and the outer-reference walk declines the fold on error. /simplify was not run separately, since the diff is three small guards. Local gate: cargo fmt passes, and clippy 1.97.1 --all-targets -D warnings is clean. sql_integration gives 865 passed / 22 failed, and the 22 are the same set by name as on 8837afb9c (which also fails the 3 new tests) and on base.
Fix pass 67bd2dc43 (spiceemma, scoped to 3d8e76946..67bd2dc43): Copilot's three threads on 3d8e769. Each was reproduced first as a regression test that fails on 3d8e76946. (1) Past a map, the path check accepted any remaining parts, so get_field_path(t.s, 'a', 'b', 'c') beside t.s.a: map<string,int> emitted a.b.c, a field of an int. A map part is now one key, and the rest of the path is checked against the value type, which gives s.a.b.c. (2) Two relation prefixes both read t.s.a.b as a valid path, and the longer one was stripped, giving a.b (t.s's output). This is now refused as ambiguous with the existing NotImplemented error. (3) A repeat declared under the name of a dropped inner output ([x, x] declared [z, y]) kept that output from being substituted, so the key y + 1 emitted ORDER BY ((t.a + 1) + 1). The fold now declines when a key reads such a borrowed name. Adversarial review: codexadversarial-review --base 3d8e76946 (receipt exit=0, verdict=needs-attention) returned one high finding: a declined fold leaves the ORDER BY in a derived table with nothing outside it. A probe with fetch = 3 showed that this shape predates the guard. Every decline reason produces it (the volatile-output and field-name-collision declines emit the same thing), and base emits it too, so it is tracked in unparser: a declined sort fold leaves the ORDER BY inside a derived table with nothing outside it #265 rather than fixed here. Its narrower point was valid: the guard fired even when the key did not read the borrowed name. The guard is narrowed, and …_still_folds_a_key_not_reading_it pins it. /security-review could not run (the session's working directory is not a git repository). A manual read of the diff found no input surface or I/O, and no new panics: the map arm uses .get(1). /simplify was not run separately, since the diff is three small guards. Local gate: cargo fmt --check passes, and clippy 1.97.1 --all-targets -D warnings is clean. plan_to_sql gives 463 passed / 22 failed, the same 22 by name as before the fix and on base. Unparser lib tests: 103 passed.
Fix pass ed3515dc5 (spiceemma, scoped to 67bd2dc43..ed3515dc5): Copilot's two threads on 67bd2dc. Both were reproduced first as regression tests that fail on 67bd2dc43.
(1) Declared-name collision.kept_names counted an unnamed kept expression's spelled name, so a dropped output declared under that spelling was never substituted: ORDER BY "t.a + t.b". kept_names now holds only explicit aliases and declared fields, which gives ORDER BY (t.c + 1).
(2) Qualified substitution under a derived aggregate. A qualified substitution in an aggregate scope over a derived projection lost its qualifier, so the bare ORDER BY k bound to the kept (0 - t.k) AS k. PostgreSQL 17 returned -2 where the plan wants -1. The fold is now declined there, and returns -1.
Adversarial review, first pass.codex job review-mvb63ll4-xbzuur on the first version (needs-attention) found that declining every qualified read also declined joins, whose derived-table fallback emits SELECT k, v, v and loses u.v. This reproduced. The guard now applies only where contains_projection_before_relation strips qualifiers, and test_a_qualified_substitution_over_a_join_still_folds pins the fold.
Adversarial review, second pass.codex job review-mvb6ayqa-mfrjco (needs-attention) found that a compound key x + 1 was also declined, and the fallback's ORDER BY (x + 1) fails on PostgreSQL 17 (column "x" does not exist). This reproduced, while the fold's ORDER BY (k + 1) returns the correct -1. The guard is now limited to bare keys, and a test pins the compound fold. Its other finding (a declined fold's derived ORDER BY with a multi-row fetch) is the pre-existing shape tracked in unparser: a declined sort fold leaves the ORDER BY inside a derived table with nothing outside it #265.
Unverified: engines that resolve SELECT aliases inside ORDER BY expressions (MySQL) may bind k + 1 to the alias. That behavior is unchanged from 67bd2dc43.
/security-review could not run (the session's working directory is not a git repository). A manual read found no input surface, I/O or new panics; contains_projection_before_relation only became pub(super).
…et (refs spiceai/spiceai#14733)
A QueryBuilder holds one ORDER BY and one row bound, and the Sort and
Limit arms wrote theirs onto it unconditionally, so the last node
unparsed won: a Sort under a Sort replaced the outer keys under the
outer fetch, a Limit under a Limit replaced the outer bound, a Limit
under a Sort landed as that sort's LIMIT, and an unfetched sort inside
a join input replaced the ORDER BY a sort above the join had set. The
Projection arm's fold started from an empty state and did the same.
The arms now read the builder first. A sort under an ORDER BY is
dropped when unfetched and derived when fetched; a limit under an
ORDER BY is derived, and under another literal limit merges with it
through combine_limit; a sort in a join input is dropped when unfetched
and derived under the scan's name when fetched; the fold seeds its
state from the builder, and a sort key above a projection whose chain
derives keeps naming the derived output. The sort-over-projection
rewrite hoists over an inner projection that exposes unread outputs.
… above a derived aggregate, and count every row bound (refs spiceai/spiceai#14733)
A sort key above a projection whose chain derives its aggregate was emitted
as the plan holds it, so a compound key over a computed alias bound the
alias to the derived table's column rather than to the SELECT list's
expression: `ORDER BY (s + 1)` beside `SELECT (0 - s) AS s` ordered by the
un-negated sum. The key is now unprojected through the projection with the
aggregate and windows withheld, so the projection's expression is
substituted and the aggregate output stays the derived table's column.
`has_row_limit` and `literal_row_bound` also count a `FETCH FIRST` and a
`LIMIT BY`, so a bound expressed that way is never merged over.
…table's outputs by bare name (refs spiceai/spiceai#14733)
A sort or limit between a projection and its aggregate moves the aggregate
into a derived table, which hides the relations its outputs are qualified
by. The passthrough items and the sort keys above it still named them the
way the plan does (`t.k AS k`, `ORDER BY (t.k + 1)`), which binds to
nothing outside the derived table. They now name the derived table's
outputs by their bare names, and a clause above holding a subquery, which
the visitors leave alone, is refused instead of emitted.
Also folds `has_row_limit` into the identical `bounds_rows`, merges the
literal-bound check in the Limit arm into one `if let` chain, and brings
the rustdoc of `derive_row_limited_scope` and the Sort arm's refusal
comment in line with the code.
…epeated name above it, and rebuild the folded projection's schema (refs spiceai/spiceai#14733)
Four shapes the review raised, each reproduced against DuckDB:
- A filter on an aggregate output above a limited aggregate under a sort
emitted `HAVING sum(v) > 10` outside the derived table the chain becomes.
The grouped-predicate refusal in `row_limit_needs_own_scope` now runs
before the order stops the fold, so the shape is refused.
- Two group columns that differ only by qualifier became `SELECT k, k` once
the derived table hid the qualifiers. Refused.
- An unaliased aggregate output read through the derived boundary
(`"sum(t.v)"`) is named by the engine on PostgreSQL. The derived table
now names every output by its logical name (`name_scope_outputs`) and is
derived directly, under the dialect's alias where one is required.
- The sort-fold rewrite replaced the inner projection's expressions but
kept its schema, so a key over a reordered computed alias resolved by
position to the wrong expression (`ORDER BY (u.b + 1) + 1` for `x + 1`
with `x = u.a + 1`). The schema is rebuilt in the replacement order.
…ed sort key, and resolve a sort's keys under the unfetched sorts it drops (refs spiceai/spiceai#14733)
The sort fold substituted only dropped aliases into the key, so a fetched
sort on an unnamed computed output the outer projection drops emitted
`ORDER BY "u.a + u.b"`, an identifier nothing defines. Every dropped
output is now substituted, named or not.
A fetched sort over an unfetched sort over a projection whose aggregate
takes a derived table resolved its keys against the inner sort rather
than the projection beneath it, and expanded `s` to `sum(v)` outside the
derived table that exposes only `s`. The walk drops that inner sort, so
the keys now resolve against the node under it.
…out of the projection fold (refs spiceai/spiceai#14733)
Folding the sort substitutes a dropped output's expression at every
reference the key makes to it. For an output that cannot be repeated, a
volatile expression or one holding a subquery, that changes the key:
`(r - r) * 100 + a` over `random() AS r` is `a` drawn once and a
difference of two draws when repeated, and the fetch keeps a different
row. Such a sort now keeps the scope it has, where the output is
computed once.
…tom-up, and drop a key the substitution turns into a literal (refs spiceai/spiceai#14733)
Substituting top-down walked into the replacement: `(s + 1) AS s` reads
the aggregate's own `s`, which was substituted again without end. The
walk is bottom-up now, so a replacement is never revisited.
A key reading a projected integer literal became that literal, which
`ORDER BY` reads as a column position. A constant orders nothing, so
such a key is dropped, and a sort left with no key is not folded.
…g alias would capture a substituted column (refs spiceai/spiceai#14733)
A dropped output's expression reads the scope below by bare column
names. Where such a name is also a surviving alias (`0 - s AS s` kept,
`s AS x` dropped, the key reading `x`), the substituted `s` is bound to
the alias by the unprojection that follows, and the fetch keeps the row
`0 - s` ranks first instead of the one `s` does. Such a sort now keeps
the scope it has.
…ype, refuse a key two relation prefixes both read, and decline the sort fold when a repeat takes another inner output's name (refs spiceai/spiceai#14733)
The widened fold can duplicate a retained volatile output. An outer Projection::try_new_with_schema([r, r], fields [first, second]) over Sort[u.a, fetch 1] → Projection[u.a, random() AS r, u.b] now passes the subset check and emits two independent random() AS r select items. Both plan outputs read the same computed r, so their values must be equal. The fallback field allows the repeated output through, while the repeatability guard checks only dropped outputs. Decline the fold when a non-repeatable inner output is repeated, and add a regression for this shape.
Stopping automated fix rounds on this PR. It needs a maintainer to decide how far the sort fold should reach.
What happened: today's runs pushed six rounds (8837afb9c → 3d8e76946 → 67bd2dc43 → ed3515dc5, plus earlier ones). Each round reproduced and fixed the open Copilot threads, and each new head then drew a fresh set of findings on other shapes the widened projection fold accepts. The latest review on ed3515dc5 has 6 open threads:
volatile sort keys evaluated in both the inner and outer ORDER BY (plan.rs:2931)
a retained volatile output repeated in the replacement projection (rewrite.rs:294)
renamed plain-column outputs, where expression and field selection disagree (rewrite.rs:336)
case-variant identifiers in the alias guard and the outer-reference guard (rewrite.rs:468, rewrite.rs:562)
a qualified substitution that loses its qualifier in a projection-only scope (rewrite.rs:468)
These 6 threads are unverified, code inspection only (Copilot's). This run did not reproduce them and left them unresolved.
The question for a maintainer: keep adding a guard for each shape, or narrow the fold to a subset that is safe by construction? One such subset: every retained output is a plain, non-renamed column, nothing is volatile, and names compare case-folded. Anything else keeps the nested sort. Narrowing would close these findings as a class rather than one at a time. It would also give up some of the folds this PR enables today.
…s, and refuse a volatile re-established key and a bare key a case-variant alias captures (refs spiceai/spiceai#14733)
The fold that drops inner outputs nobody above reads is now taken only in a
subset that is safe by construction: every kept output is a plain column
named by that column in the inner and outer schema, the outer list reads it
once, no renamed plain column is read above by its own name, and no inner
output is volatile or holds a subquery. Every other shape keeps the sort in
its own scope and emits what spiceai-55 emits.
On the fold over equal sets, a repeat of a non-repeatable output declines it,
the capture guards compare names through Unparser::emitted_column_key, and a
derived projection counts as a scope that strips qualifiers.
The order re-established above a sorted chain refuses a key that cannot be
evaluated a second time, and a bare ORDER BY key is refused where a computed
alias whose name folds to its own, other than the one it reads, captures it.
…ojection that a computed alias of its exact name captures (refs spiceai/spiceai#14733)
A sort below the projection reads the derived table's output, so ORDER BY s beside (0 - s) AS s binds to the alias and reverses the ranking the fetch keeps. The exact-spelling exemption now applies only to a sort above the projection, whose key reads the projection's outputs. Raised by the adversarial review of 7a0e807; spiceai-55 emits the same capture.
…a computed alias captures, and tidy the capture bookkeeping (refs spiceai/spiceai#14733)
A re-established ORDER BY renders outer_ref(r) as the bare r, which binds to the projection's (0 - t.k) AS r rather than the enclosing query; DuckDB returned -3 where the plan returns -1. The re-established keys' outer references now go through the same computed-alias capture check as their columns. Raised by the adversarial review of 56442fc.
Also: one dialect rewrite per computed alias, HashMap imported in plan.rs, and the outer-reference fold check lets exists() carry the key error into its existing fail-closed fallback (behavior-neutral).
spiceai-55 advanced to ddd5c8c (#261 union inputs; an unwrap-cast fix) and the PR became CONFLICTING. The only conflict was both sides appending tests to plan_to_sql.rs; resolved by taking spiceai-55's file, re-applying this branch's import and snapshot hunks, and appending this branch's tests after #261's. #261's struct-field test called get_field by its full path, which this branch's import makes an unnecessary qualification (denied); it now calls get_field.
Narrowing the sort fold. The user released the hold from the escalation comment above. This round pushed 7a0e80723, 56442fc82 and 05cd0b2c7, then merged spiceai-55 in bdd8d629b, because the base had advanced to ddd5c8ce6 (#261) and the PR had become CONFLICTING.
The decision. Stop adding a guard for each new shape. Make the widened fold safe by construction instead.
The fold has always hoisted a sort when the outer projection and the sort read exactly the inner projection's outputs (equal sets). This PR widened it to also drop inner outputs nothing above reads. That widening now runs only when all four hold:
every output it keeps is a plain column named by that column, in both the inner and the outer schema;
the outer list reads each kept output once;
no plain column the inner projection renames is read above under the column's own name;
no inner output is volatile or holds a subquery.
Any other shape keeps the sort in its own scope, and its SQL is byte-identical to spiceai-55. The capture guards that remain now compare names through Unparser::emitted_column_key, so they are case-folded.
No guard became dead, so none was removed. The equal-sets fold, which spiceai-55 already has, still reaches every one of them. I confirmed this with a mutation pass on the 7a0e80723 sources. Each row disables one guard; the right column lists the tests that then fail, beyond the 22 that already fail on the base.
a kept alias would capture a substitution (alias and outer reference)
four tests, including …_in_another_case_unqualified_by_a_derived_aggregate_scope_keeps_the_sort and …_keeps_the_declared_name_the_fold_gives_it_in_the_shadow_guard
Copilot's six threads on ed3515dc5. Each was reproduced on ed3515dc5, covered by a regression test, fixed in 7a0e80723, answered on its thread with the evidence, and resolved. The rows were run in SQLite 3.50.4 and DuckDB 1.4.4.
thread
ed3515dc5
bdd8d629b
a volatile key re-established above a sorted chain (plan.rs)
random() is drawn in both ORDER BYs; with deterministic draws the plan returns (10, 10) and the SQL returns (8, 8)
refused (NotImplemented)
a retained volatile output repeated
the two columns match in 0/20 runs on both engines
spiceai-55's SQL: DuckDB matches 20/20, SQLite 0/20, as SQLite already does on spiceai-55 (#267, item 3)
renamed plain-column outputs
returns 100; the plan returns 0
spiceai-55's SQL, which returns 1 because of a declared-name rendering gap already on the base (#267, item 1)
the alias guard compares case-sensitively
-2; the plan returns -1
-1 on both engines, and the equal-sets variant also gives -1
a qualifier lost in a projection-only scope
-2; the plan returns -1
-1 on both engines, and the equal-sets variant also gives -1
the outer-reference guard compares case-sensitively
-5; the plan returns -1
SQLite -1. DuckDB still returns -5, the same as on spiceai-55, because of its lateral alias binding (#267, item 2)
All six resolved; a re-query shows 0 of 39 review threads unresolved.
The bare-key capture from the earlier comment. It was not covered at ed3515dc5. Both plans unparsed to a bare ORDER BY s beside (0 - s) AS "S":
case 1: SQLite and DuckDB both returned -2|2; the plan returns -1|1;
case 2: SQLite returned -2|2 and DuckDB -1|1.
Since 7a0e80723, a bare key is refused when a computed alias whose name folds to the key's name would capture it. The one exception is the alias the key itself reads, spelled the same. The test is test_a_bare_key_a_computed_alias_in_another_case_would_capture_above_a_derived_aggregate_is_refused. Its control, a bare s over (0 - s) AS s, still unparses and returns the plan's -2|2.
Two findings from the adversarial review, both reproduced and fixed:
56442fc82. The exemption above is wrong for a sort absorbed from below the projection, whose key reads the derived table. ORDER BY s beside (0 - s) AS s returned -20 on both engines; the plan returns -10. spiceai-55 emits the same SQL. The absorbed case now gets no exemption.
05cd0b2c7. An outer reference in a re-established key was captured by a computed alias: DuckDB returned -3 and the plan returns -1. It now goes through the same capture check as the key's columns.
Folds given up. These existing PR tests now expect spiceai-55's SQL. Each plan keeps a computed or renamed output while dropping others. I checked each new expectation byte for byte against what spiceai-55 emits.
Was: SELECT (0 - k) AS k FROM (SELECT t.k, t.v FROM t) GROUP BY k ORDER BY (k + 1) ASC NULLS FIRST LIMIT 1
Now: SELECT k FROM (SELECT (0 - k) AS k, k AS x, sum(v) AS s FROM (SELECT t.k, t.v FROM t) GROUP BY k ORDER BY (x + 1) ASC NULLS FIRST LIMIT 1)
Both engines return -1, the plan's answer. In an earlier round PostgreSQL 17 rejected this SQL with column "x" does not exist: an error, not wrong rows. I did not re-run it here.
test_an_unnamed_output_declared_under_another_name_is_substituted_by_its_field. The original plan kept the computed x, with t.d declared unused; it emitted SELECT (t.a + t.b) FROM t ORDER BY (t.c + 1) ASC NULLS FIRST LIMIT 1.
That fold is given up. On a debug build the nested path then trips the base's own debug_assert in name_derived_scope_outputs, exactly as spiceai-55 does for the same plan.
The test now keeps t.d under its own name, so it still pins substitution by declared field inside the subset: SELECT t.d FROM t ORDER BY (t.c + 1) ASC NULLS FIRST LIMIT 1.
Tests against the base. I compared failures by test name.
sql_integration on spiceai-55 at ddd5c8ce6: 828 passed, 22 failed.
sql_integration on this branch at bdd8d629b: 896 passed, 22 failed. The 22 are the same set by name, e.g. test_struct_expr and snowflake_flatten_*.
The PR adds 68 tests. Run against the ed3515dc5 sources, the final test file fails 13 beyond the 22: the 9 regression tests for this round and the 4 given-up folds 1 to 4.
codex job review-mvbma1d1-uijeaw reviewed 7a0e80723 and review-mvbmrj3w-21cd7u reviewed 56442fc82. Each returned one finding, fixed as described above.
On 05cd0b2c7 and bdd8d629b the review was skipped: codex exited 1 with "Your workspace is out of credits".
A read-only correctness pass over 05cd0b2c7 found no defects. It left two notes, both unverified and from code inspection only: a qualified outer reference is over-refused (a lost pushdown, never wrong rows), and an outer reference nested inside a compound absorbed key is not checked against computed aliases.
/security-review: no findings.
/simplify: three behavior-neutral proposals, applied in 05cd0b2c7.
Not verified here.
PostgreSQL, which this host doesn't have.
Release builds. The given-up declared-rename plan (fold 5) was only run with debug assertions on.
… collide or whose names disagree with the declared schema (refs spiceai/spiceai#14733)
The fold matches outputs by their display string and looks a dropped output up by its own name. t."a.b" and an alias "t.a.b" display alike, so the alias went unsubstituted and ORDER BY ("t.a.b" + 1) was emitted; and an alias declared under another output's name ([(t.a + 1) AS x, (t.b + 1) AS y] declared [y, x]) had t.a + 1 substituted for the field x, which is t.b + 1. Both now keep the sort in its own scope. The declared-name check covers renamed plain columns too, so the narrower read-above rule it replaces is removed.
…leaves under it
A sort over `Projection -> Sort -> Projection` resolved its keys against the outer
projection alone, whose aggregate lookup stops at the inner projection. The
Projection arm then folds both projections into one SELECT with the aggregate and
drops the hoisted sort under this order, so `ORDER BY (s + 1)` over
`sum(t.v) AS s` was emitted bare and bound to a source column `s`. The keys now
resolve against the folded projection, its aggregate and its windows.
Merge conflict with spiceai-55 needs a maintainer decision.#246 and #247 merged at 03:31Z, and this PR is now DIRTY. I pushed b7f24d2d6, the fix for the latest Copilot thread (details on that thread), and did not push a merge of spiceai-55.
What I tried. In a scratch worktree I merged spiceai-550e01a882f into b7f24d2d6. There were 9 conflict hunks in ast.rs, utils.rs, plan.rs, rewrite.rs and plan_to_sql.rs:
The merged tree builds and is clippy-clean. Under RUST_MIN_STACK=16777216 (the default stack overflows in roundtrip_statement on this host), sql_integration gives 985 passed, 5 failed. Each failure is a test from one PR that pins the opposite behaviour to the other PR:
The decision this needs.#247 (merged) and this PR each scope a sort key that reads a volatile or dropped output, in the same Sort arm and the same fold, and they disagree on which shape gets which SQL. #246 and this PR both handle the qualifier of a field-path key in a derived scope. Settling the merge means deciding, per shape, whose behaviour stands. I don't want to guess that, because a wrong resolution here produces SQL that is valid but returns different rows.
One way through is to treat #247's volatile-key scoping as authoritative, since it is the one on spiceai-55, and drop or re-expect #263's overlapping guards. The field-path case then needs #263's repoint_onto_derived_chain checked against #246's is_field_path requalification.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Refs spiceai/spiceai#14733.
Rationale for this change
The unparser folds every
SortandLimitof a plan into oneQueryBuilder, which holds a singleORDER BYand a single row bound. A node unparsed after another had already set them overwrote them, so the emitted SQL carried the inner node's keys or bound under the outer node's position — the last writer won. The issue's shapes, run in DuckDB 1.4.4 overu(a, b)= (1,9) (2,8) (3,7) (4,6) (5,5) andt(a)= 1..10, before this change (spiceai-55atcf084aa85):Sort[u.a, fetch 2]overSort[u.b]SELECT * FROM u ORDER BY u.b ASC NULLS FIRST LIMIT 2Sort[t.a, fetch 3]overJoin(t, Sort[u.a desc] over u)… ORDER BY u.a DESC NULLS LAST LIMIT 3Limit(1)overLimit(2)SELECT * FROM u LIMIT 2Sort[u.a, fetch 1]overLimit(2)… ORDER BY u.a ASC NULLS FIRST LIMIT 2Projection[u.a] → Sort[(u.a + 1)] → Projection[u.a, u.b] → Sort[u.b] → Projection[u.a, u.b]SELECT a FROM (SELECT u.a, u.b FROM u ORDER BY u.b ASC NULLS FIRST)Sort[m] → Projection[m] → Sort[m desc] → Aggregate[max(u.a) AS m]… ORDER BY max(u.a) DESC NULLS LASTSort[(t.k + 1) desc, fetch 1] → Projection[s, t.k] → Limit(2) → Aggregate[t.k; sum(t.v) AS s]… GROUP BY t.k ORDER BY (t.k + 1) DESC NULLS FIRST LIMIT 2What changes are included in this PR?
The query builder now says what it already carries, and each node folds in only where it would have folded under a node the walk absorbed itself:
QueryBuilder::has_order_by,literal_row_bound,merge_inner_row_bound(ast.rs): an inner literal bound merges with the one above throughcombine_limit, the way the optimizer merges stacked limits; an expression bound cannot merge.Limitarm: under an existingORDER BYthe limit takes a scope of its own (derive_row_limited_scope, which now walks through aSortwhen the enclosing query re-establishes the order); under an existing bound it merges, or takes a scope when either bound is an expression.Sortarm: under an existingORDER BYan unfetched sort is dropped (the outer order reorders it) and a fetched one takes a scope; a fetch under an existing bound merges. Inside a join input a sort'sORDER BYis dropped (a join leaves its output order unspecified) and a fetched sort becomes a derived table under the scan's name, as a join input'sLimitdoes. A sort's keys resolve against the node under the unfetched sorts the walk drops and under aDISTINCT.Projectionarm: the Sort/Limit fold starts from the state the query already holds, and the grouped-predicate refusal inrow_limit_needs_own_scoperuns before that state stops the fold. A chain the fold leaves in a derived table is derived directly with every output named (name_scope_outputs), the clauses above it re-pointed at those outputs by bare name (t.k→k;t.payload.x→payload.xonce the hidden schema confirmspayloadis a column of the hiddent), and, when the enclosing query bounds rows without ordering them, re-ordered by the chain's own sort keys (refused when the projection computes an output named like one of those keys, which would capture it). Two outputs that share a name under the dialect's identifier folding, a clause above holding a subquery, and an outer reference in the projection named like a derived output are refused. Re-establishing the chain's order is refused in two more cases: a sort key that cannot be evaluated a second time (random()), and an outer reference in a key that a computed alias would capture. A bareORDER BYkey is refused when a computed alias whose name folds to the key's name would capture it. For a sort above the projection, the alias the key itself reads, spelled the same, is exempt. For a sort absorbed from below the projection there is no exemption, because that key reads the derived table, not the alias.rewrite_plan_for_sort_on_non_projected_fields: the fold has always hoisted a sort through an inner projection when the outer projection and the sort read exactly that projection's outputs (equal sets). It may now also drop inner outputs that nothing above reads (a strict subset), so the outer order stays at the top level. It does this only within a subset that is safe by construction (widened_fold_is_safe):Every other shape keeps the sort in its own scope and emits exactly what
spiceai-55emits. This replaces the per-shape guards of earlier rounds; the escalation comment on this PR explains why.Both paths now rebuild the inner projection's schema in the replacement order (fix(unparser): scope a sort key reading a projection output that cannot be repeated (refs spiceai/spiceai#14109) #247 carries the same hunk for its own case). Both also:
On both paths the fold is also declined when two inner outputs share a display string, the spelling it matches outputs by (
t."a.b"and an alias"t.a.b"both display ast.a.b). It is declined as well when an alias or plain column carries a name other than the one its schema declares (outputs_named_as_declared).The capture checks compare names through
Unparser::emitted_column_key, so they are case-folded. A derived projection now counts as a scope that strips qualifiers, as an aggregate over one already did.Refusals where no faithful scope exists: a fetched sort under an expression bound, a limit under a sort over an aggregate, and a
HAVINGabove a limited aggregate under a sort.Shapes with one
Sort/Limit, or stacked ones that already merged, emit the same SQL as before: thedatafusion-sqlsuite's 22 pre-existing failures (Snowflakeflatten,roundtrip_statement*,test_struct_expr, …) are unchanged in name and count. One existing snapshot changes:test_unparse_aggregate_with_non_literal_limit_over_sort_with_fetchgains an outerORDER BY __agg_0 ASC NULLS FIRSTbefore itsLIMIT CAST(7 AS INTEGER), so the outer bound keeps the rows the inner sort ranked first.Are these changes tested?
70 new tests in
plan_to_sql.rspin each shape above and every shape the reviews raised. Every round's tests were run red against the head before that round, as quoted in the commit messages and review threads. For the narrowing round, the final test file fails 13 tests beyond the pre-existing 22 when run against theed3515dc5sources: the 9 regression tests for this round's threads and adversarial findings, and 4 tests whose fold is now given up (listed in the PR conversation).A mutation pass on the
7a0e80723sources disabled each of eight fold and refusal guards on its own and re-ran the suite. Every one of them has a test that fails without it. The two refusals added after that commit are pinned by tests that fail on the commit before each one.cargo test -p datafusion-sql --test sql_integration, compared by test name:724148148, which mergesspiceai-55ddd5c8ce6: 898 passed, 22 failed;spiceai-55atddd5c8ce6: 828 passed, 22 failed;test_struct_expr,snowflake_flatten_*,roundtrip_statement*, …).cargo test -p datafusion-sql --lib(1.97.0): 103 passed.cargo clippy -p datafusion-sql --all-targets -- -D warnings: clean.cargo fmt --all -- --check: clean.The differential check from earlier rounds:
Differential check against DuckDB 1.4.4 (Python
duckdb): a probe binary unparses each shape withplan_to_sql, and the oracle runs the emitted SQL and a hand-written statement for the same plan over the same tables, comparing column count and rows (ordered where the plan orders). Before, oncf084aa85:After, on this branch at
d294df539, over the 33 shapes accumulated through the reviews (the issue's, the derived-aggregate forms, the fold's pruned/reordered/unnamed/volatile/shadowed/literal keys, the sort-over-sort and sort-over-DISTINCT stacks over a derived aggregate, the struct-field key, the case-folded names, the expression bound over a sorted chain):(S6 reads
random(), so the oracle checks its two rows are the two smallestu.a, ascending. A further shape, a fetched sort whose key reads a droppedrandom()output twice, is pinned by…dropped_volatile_output_keeps_the_sort_in_its_scopeas emitting exactly what the base emits — the sort stays in its own scope; the repetition ofrandom()inside that scope's key is spiceai/spiceai#14109's, fixed by #247 — and is kept out of the row comparison because that inner key is nondeterministic on both. The refused shapes count as agreeing: the oracle reports them as refusals, not rows.)Are there any user-facing changes?
A federated query whose plan stacks a sort or limit under another now returns the rows and order the plan asks for from the remote engine, or is refused and answered locally, instead of returning the rows the inner node selects. A plan whose aggregate takes a derived table of its own no longer references a relation that derived table hides, names the outputs it reads through it, and keeps its order under an outer parameterised
LIMIT.Review gate
Adversarial review: skipped —
codexexited 1 on the final head724148148, and onbdd8d629band05cd0b2c7, with "Your workspace is out of credits". Undergrokspice,codexis the only allowed engine. The two earlier heads of this narrowing round were reviewed:codexjobreview-mvbma1d1-uijeawon7a0e80723returned needs-attention with 1 finding. A bare key from a sort absorbed below the projection is captured by(0 - s) AS s: SQLite and DuckDB return -20 where the plan returns -10. Reproduced, and also present onspiceai-55. Fixed in56442fc82.codexjobreview-mvbmrj3w-21cd7uon56442fc82returned needs-attention with 1 finding. An outer reference in a re-established key is captured by a computed alias: DuckDB returns -3 where the plan returns -1. Reproduced and fixed in05cd0b2c7.05cd0b2c7was read only by a read-only correctness sub-agent, which found no defects. It left two notes, both unverified, code inspection only: a qualified outer reference is over-refused (costs a pushdown, never wrong rows), and an outer reference nested inside a compound absorbed key is not checked against computed aliases.Copilot's two threads on
bdd8d629b(display-string collision; alias declared under another name) were reproduced red onbdd8d629band fixed in724148148by declining the fold. One fold an existing test expected is given up and listed on its thread./security-reviewwas not re-run for724148148, which adds two early returns and no emission./security-review: no findings. It was re-scoped to this worktree's diff, because the harness's default diff was a different checkout. The commit adds no new identifier or literal emission, the new indexing is bounded, the refusals are static strings, and errors make the fold fail closed./simplify(read-only sub-agent): 3 behavior-neutral proposals, applied in05cd0b2c7. Oneemitted_column_namecall per alias,HashMapimported, and?insideexistsin place of a hand-written error fallback. No guard is unreachable on both fold paths, so none was removed.Pre-existing emissions found while verifying (
spiceai-55emits them too, and this PR does not change them): unparser: three wrong-result emissions spiceai-55 already has for sort shapes #263 declines #267.Earlier rounds:
codex— four reviews ran, each returning needs-attention with findings that reproduced and are fixed here:review-mvakq4tb-dcjg8von40bca8df3(an unnamed computed output dropped from the fold leftORDER BY "u.a + u.b"; a fetched sort over an unfetched sort over a derived aggregate expandedstosum(v)outside the derived table — fixed in5da169eb4),review-mval06t5-jupko2on5da169eb4(a droppedrandom()output substituted at both references of the key — the fold is declined in246bf0d35),review-mvalb534-g0yh8non246bf0d35((s + 1) AS ssubstituted without end, a stack overflow; a projectedlit(1)substituted intoORDER BY 1, a column position — fixed in4d1df092a),review-mvalis5g-s03gopon4d1df092a(a surviving0 - s AS scapturing the substitutedsof a droppeds AS x— the fold is declined ina25f720bd). On the heads aftera25f720bdthe engine was skipped —codexexited 1: "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." (receipthead=d294df539…,attempt=codex,engine=none). An earlier partial run on543fb7445had left two hypotheses: a stale schema after pruning (then not reproduced, later reproduced by Copilot's reordered-alias shape and fixed) and the out-of-scope qualifier above a derived aggregate (reproduced as S12, fixed in651373395).Copilot's reviews (13 threads over four passes): twelve addressed — a
HAVINGleft outside the derived chain, duplicate bare names after qualifier removal, an unaliased aggregate output at the derived boundary, the stale projection schema after a reordering fold (40bca8df3); an expression bound over a sorted chain now re-ordered outside, case-folded duplicate names, a struct-field key, an outer-reference capture, and aDISTINCTbetween sort and projection (43256a482,c075660ab); a second-pass substitution collision thata25f720bdalready declines; and the field-path re-pointing narrowed in0cda516fdso that the shared helper keeps its full-qualifier form (a siblingt.s.aunder full qualification is untouched) and only the derived-chain scope drops a shorter prefix, with the hidden schema confirming the column; and a computed projection alias named like a re-established key ((0 - s) AS saboveORDER BY s), refused ind294df539. One thread stays open as a hypothesis: a projection alias named like the group column its key reads cannot be built (DataFusion rejects the schema as ambiguous), so no plan reaches the described capture. Note:43256a482was pushed with a test that did not compile (get_fieldunimported) because the gate read an empty failure list as clean;c075660abfixes it, and the gate now stops when the suite prints no result line.Fix pass
b890188b6(spiceemma, scoped to that commit only): Copilot's 07:23Z/07:32Z pass. Reproduced first, each as a new regression test that fails ond294df539: a scalar output named like the hidden relation keptORDER BY (t.k + 1)outside the derived table; and(0 - s) AS "S"and an outerSgot past the computed-alias and outer-reference capture checks. All three are fixed (both checks now compare throughemitted_column_key, and the field-path root is kept only for a struct or map output, dictionary-encoded or not). Therewrite.rscase-folded surviving-alias shape did not reproduce: the fold is already declined. It is pinned by a test, andrewrite.rsis unchanged. Adversarial review:codex, jobreview-mvar4vo7-m7s0ok, scoped--base HEAD~1, returned needs-attention with one finding: dictionary structs and maps also take field access. The root check was widened to cover them. Its claim that this changes the emitted SQL did not reproduce: a dictionary-struct and a map probe emitted the same SQL before and after the widening./security-review: the diff only adds refusals and name comparisons. It adds no input surface, quoting or I/O, so there are no findings. Local gate:cargo fmt --checkpasses.cargo clippy -p datafusion-sql --all-targets -D warnings(1.97.1) is clean.plan_to_sqlgives 445 passed / 22 failed, and the 22 are the same set that fails on basespiceai-55(409 passed / 22 failed). Unparser lib tests: 52 passed.Found while probing, reproduced on
d294df539and not fixed here: a struct outputt.payload AS tbeside a grouping columnt.x, with a fetched sort onget_field(t, 'x')aboveLimit → Projection[t, s], emits... GROUP BY t.x, t.payload LIMIT 2) ORDER BY x DESC NULLS FIRST LIMIT 1. That sorts by the grouping columnx, not bypayload.x.Residual, unverified: an outer reference in a clause above the projection (a filter's) is converted to SQL before this scope is built, so the capture check in the projection does not see it.
/security-review(read-only sub-agent over the diff): no quoting, panic or overflow findings (combine_limitsaturates;LIMITliterals parse throughusize::from_str). Two low, unverified, code inspection only, both pre-existing shapes this diff does not touch:peel_to_unnest_with_modifierssetsORDER BY/LIMITunconditionally onunnest_as_table_factor/lateral_flattendialects (the same last-writer-wins class, for aSort → Projection[unnest] → Sort → Unnestplan on BigQuery/Snowflake), and a fetched sort under an outer sort over a projected input derives underDERIVED_SORT_ALIAS, so anORDER BY u.aemitted above names a qualifier the derived table hides (a remote binder error, not wrong rows)./simplify(read-only sub-agent): 5 proposals, all applied —has_row_limitfolded into the identicalbounds_rows, the Limit arm's literal-merge match folded into oneif letchain,boundedcomputed once, and two rustdoc/comment corrections; suite, clippy and the oracle re-run green afterwards.Fix pass
169f91ceb+8837afb9c(spiceemma, scoped to9832ce2a2..8837afb9c): Copilot's three threads on9832ce2. Each was reproduced first as a regression test that fails on9832ce2, and checked against DuckDB. (1) Under full qualification, a keyt.s.a(column oft.s) beside tablet's columnswas repointed tos.a, which DuckDB rejects. The full qualifier is now matched first, and a reference that is also a struct field path is refused. (2) An outer reference in an ancestor, absorbed or re-established sort key rendered as a baresthat the derived outputscaptured: DuckDB pickedk = 1where the plan wantsk = 2. The Sort arm now records the outer references its keys read, and the capture check covers all three paths. (3) The sort fold's shadow guard ignored names declared throughProjection::try_new_with_schema, so it emittedORDER BY (a + b)(3 instead of 11). The guard now reads the folded projection's own schema. Adversarial review:codex exec --sandbox read-onlyon9832ce2a2..169f91cebreturned four findings. Two reproduced and are fixed in8837afb9c: an outer reference in a re-established order (DuckDB 2 vs 1), and the guard's names disagreeing with the schema for a repeated output (DuckDB 3 vs 11). One did not reproduce: thet/t.s/t.s.afield-path shape is already refused, and a test pins it. One is rejected as an over-refusal only: a qualified outer reference is refused like an unqualified one, because the repoint walk strips a qualifier that names a hidden relation (unverified, code inspection only). Local gate:plan_to_sqlgives 456 passed / 22 failed, the same 22 by name as on9832ce2(448/22) and base.cargo fmt --checkpasses, and clippy 1.97.1-D warningsis clean.Fix pass
3d8e76946(spiceemma, scoped to8837afb9c..3d8e76946): Copilot's three threads on8837afb. Each was reproduced first as a regression test that fails on8837afb9c, and the first two were checked in SQLite. (1) When the fold's field names collided, it fell back to the outer schema, so the keyy + 1resolved by position to the second copy oft.a + 1. SQLite returned2|2|101where the plan wants6|6|1. The fold is now declined. (2) An unqualified outer reference in a dropped output was substituted into the key, where a kept alias of the same name captured it. SQLite returned-5where the plan wants-1. The shadow guard now declines this case. (3) Under full qualification, a flattenedget_field_path(t.s, 'a', 'b')took the longest relation prefixt.sand emitteda.b, a field access on a scalar. A prefix is now taken only where the column's type has the rest of the path, which givess.a.b. Adversarial review:codexadversarial-review --base 8837afb9c(receiptrc=0) returned needs-attention with one finding:get_fieldaccepts a NULL base, so the path check should acceptNull. That arm is added. Unverified: the described plan could not be built, becauseget_fieldon a NULL column unparses as a bareb, on basespiceai-55as well./security-reviewcould not run (the session's working directory is not a git repository). A manual read of the diff found no input surface, I/O or new panics: the sliceidents[prefix_len + 1..]is in bounds becauseprefix_len < idents.len(), and the outer-reference walk declines the fold on error./simplifywas not run separately, since the diff is three small guards. Local gate:cargo fmtpasses, and clippy 1.97.1--all-targets -D warningsis clean.sql_integrationgives 865 passed / 22 failed, and the 22 are the same set by name as on8837afb9c(which also fails the 3 new tests) and on base.Fix pass
67bd2dc43(spiceemma, scoped to3d8e76946..67bd2dc43): Copilot's three threads on3d8e769. Each was reproduced first as a regression test that fails on3d8e76946. (1) Past a map, the path check accepted any remaining parts, soget_field_path(t.s, 'a', 'b', 'c')besidet.s.a: map<string,int>emitteda.b.c, a field of an int. A map part is now one key, and the rest of the path is checked against the value type, which givess.a.b.c. (2) Two relation prefixes both readt.s.a.bas a valid path, and the longer one was stripped, givinga.b(t.s's output). This is now refused as ambiguous with the existingNotImplementederror. (3) A repeat declared under the name of a dropped inner output ([x, x]declared[z, y]) kept that output from being substituted, so the keyy + 1emittedORDER BY ((t.a + 1) + 1). The fold now declines when a key reads such a borrowed name. Adversarial review:codexadversarial-review --base 3d8e76946(receiptexit=0,verdict=needs-attention) returned one high finding: a declined fold leaves theORDER BYin a derived table with nothing outside it. A probe withfetch = 3showed that this shape predates the guard. Every decline reason produces it (the volatile-output and field-name-collision declines emit the same thing), and base emits it too, so it is tracked in unparser: a declined sort fold leaves the ORDER BY inside a derived table with nothing outside it #265 rather than fixed here. Its narrower point was valid: the guard fired even when the key did not read the borrowed name. The guard is narrowed, and…_still_folds_a_key_not_reading_itpins it./security-reviewcould not run (the session's working directory is not a git repository). A manual read of the diff found no input surface or I/O, and no new panics: the map arm uses.get(1)./simplifywas not run separately, since the diff is three small guards. Local gate:cargo fmt --checkpasses, and clippy 1.97.1--all-targets -D warningsis clean.plan_to_sqlgives 463 passed / 22 failed, the same 22 by name as before the fix and on base. Unparser lib tests: 103 passed.Fix pass
ed3515dc5(spiceemma, scoped to67bd2dc43..ed3515dc5): Copilot's two threads on67bd2dc. Both were reproduced first as regression tests that fail on67bd2dc43.kept_namescounted an unnamed kept expression's spelled name, so a dropped output declared under that spelling was never substituted:ORDER BY "t.a + t.b".kept_namesnow holds only explicit aliases and declared fields, which givesORDER BY (t.c + 1).ORDER BY kbound to the kept(0 - t.k) AS k. PostgreSQL 17 returned-2where the plan wants-1. The fold is now declined there, and returns-1.codexjobreview-mvb63ll4-xbzuuron the first version (needs-attention) found that declining every qualified read also declined joins, whose derived-table fallback emitsSELECT k, v, vand losesu.v. This reproduced. The guard now applies only wherecontains_projection_before_relationstrips qualifiers, andtest_a_qualified_substitution_over_a_join_still_foldspins the fold.codexjobreview-mvb6ayqa-mfrjco(needs-attention) found that a compound keyx + 1was also declined, and the fallback'sORDER BY (x + 1)fails on PostgreSQL 17 (column "x" does not exist). This reproduced, while the fold'sORDER BY (k + 1)returns the correct-1. The guard is now limited to bare keys, and a test pins the compound fold. Its other finding (a declined fold's derivedORDER BYwith a multi-row fetch) is the pre-existing shape tracked in unparser: a declined sort fold leaves the ORDER BY inside a derived table with nothing outside it #265.k + 1to the alias. That behavior is unchanged from67bd2dc43./security-reviewcould not run (the session's working directory is not a git repository). A manual read found no input surface, I/O or new panics;contains_projection_before_relationonly becamepub(super).cargo fmtpasses, and clippy 1.97.1--all-targets -D warningsis clean.plan_to_sqlgives 467 passed / 22 failed, the same 22 by name as on67bd2dc43(463/22). Unparser lib tests: 103 passed.Overlap: fix(unparser): keep a union input's own ORDER BY and LIMIT inside that input #261 (union inputs) has merged into
spiceai-55and is merged here (bdd8d629b); the only conflict was both sides appending tests. fix(unparser): scope a sort key reading a projection output that cannot be repeated (refs spiceai/spiceai#14109) #247 (fix/14109-sort-key-volatile-scope) carries the same schema-rebuild hunk inrewrite_plan_for_sort_on_non_projected_fields, so whichever lands second drops a duplicate hunk. fix(unparser): list every relation a join input reads when the input lists no columns of its own (refs spiceai/spiceai#14738) #264 edits theJoinandAggregatearms only. A declined fold whose fetch keeps more than one row still leaves itsORDER BYinside the derived table, as onspiceai-55(unparser: a declined sort fold leaves the ORDER BY inside a derived table with nothing outside it #265).