fix(unparser): unparse a stacked aggregate as a derived table - #192
fix(unparser): unparse a stacked aggregate as a derived table#192claudespice wants to merge 6 commits into
Conversation
A SELECT expresses a single grouping, but the `LogicalPlan::Aggregate` arm of `select_to_sql_recursively` recursed straight into its input whenever the select list was already built, so a second aggregate underneath was skipped and its `GROUP BY` never reached the emitted SQL. `single_distinct_to_groupby` produces exactly that shape for `count(DISTINCT c)`: an outer `count(alias1)` over an inner `Aggregate` grouping by `c AS alias1`. With the inner aggregate dropped, `SELECT count(DISTINCT "UserID") FROM hits` unparses to `SELECT count(alias1) FROM hits` — `alias1` does not exist on the base table, so a consumer pushing the optimized plan down to a remote engine gets a binder error, and where a column of that name does exist it counts every row rather than the distinct values. Track on the `SelectBuilder` whether an aggregate has been folded into the current SELECT, and emit a `derived_aggregate` table for the next one, as the `Sort`, `Limit`, `Distinct` and `Projection` arms already do. The existing TPC-H and ClickBench roundtrip suites unparse the plan as the SQL planner produces it, before `single_distinct_to_groupby` runs, which is why they never caught this; the new core test unparses the optimized plan and executes both statements.
|
This PR has been open and green for 5.6 days with no reviewer ever requested and no review activity. For context, recent merges in this repo landed in 15 minutes to ~2.7 days (#181–#189), so this is well outside the normal window. I don't have write access on this fork, so I can't add a reviewer or assignee myself — flagging it here instead. Two siblings are in the same state: #190 (physical-optimizer) and #191 (unparser). Nothing is blocking it technically: CI is green and it merges cleanly into |
|
@copilot review |
1 similar comment
|
@copilot review |
Both sides appended new tests to plan_to_sql.rs at the same location; kept both sets (stacked-aggregate tests from this branch, test_join_filter_* from base).
There was a problem hiding this comment.
Pull request overview
Fixes the SQL unparser so stacked LogicalPlan::Aggregate nodes are emitted as a derived table rather than accidentally skipping the inner aggregate’s GROUP BY (notably for the optimizer shape produced by single_distinct_to_groupby for COUNT(DISTINCT ...)), and adds regression coverage for both plan-to-SQL snapshots and optimized-plan roundtrips.
Changes:
- Track whether an aggregate has already been folded into the current
SELECT(SelectBuilder::aggregated), and derive the input when encountering a stacked aggregate. - Update the unparser’s
LogicalPlan::Aggregatehandling to emit aderived_aggregatesubquery when needed. - Add new tests covering stacked-aggregate unparsing and optimized-plan roundtrip execution for
COUNT(DISTINCT ...).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| datafusion/sql/src/unparser/plan.rs | Add derived-table emission for stacked aggregates during SELECT reconstruction. |
| datafusion/sql/src/unparser/ast.rs | Add SelectBuilder state to track whether an aggregate has been folded into the current SELECT. |
| datafusion/sql/tests/cases/plan_to_sql.rs | Add snapshot test coverage for stacked aggregates (including grouped and ungrouped shapes). |
| datafusion/core/tests/sql/unparser.rs | Add execution-based roundtrip tests for unparsing optimized plans containing COUNT(DISTINCT ...) rewrites. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ed table A stacked aggregate is unparsed as a derived table, so the SELECT above it reads from that derived table and no longer from the relation its expressions name. Only the projection and ORDER BY were swept for such a qualifier, and only at the top level, so `GROUP BY test.a`, a qualifier nested inside a grouping expression, and a `HAVING` built from a Filter above the aggregate all kept a qualifier that binds to nothing. DataFusion re-plans the result, but PostgreSQL answers 42703 and DuckDB a Binder Error, so a federated pushdown fails at the remote engine. Requalify the references onto the derived table at the point it is built, where the derived table is provably the SELECT's only relation: onto its alias when it has one, and otherwise to the bare column name. An expression holding a subquery is skipped whole. A correlated subquery names an enclosing query's relation with a qualifier that is indistinguishable by name from one addressing this SELECT's own relation, so rewriting inside it would silently change which column the subquery reads; a regression test pins that, and pins that requalification still runs on the clauses that hold no subquery.
…closes A join shares one SelectBuilder across both sides and records the join on it only after the left side is walked, so at the point the derived table is built nothing on the builder shows that this SELECT reads from more than one relation. Reducing a reference to a bare column name there made it ambiguous between the derived table and the other side, and rewriting by column name alone claimed the other side's identically-named column — `count(test.a)` became `COUNT(a)` and `GROUP BY other.a` became `GROUP BY a`, which groups by a different column. Give the derived table an alias for every dialect, so a reference has a name to resolve through, and rewrite a reference only when its qualifier names a relation the derived table encloses.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
datafusion/sql/src/unparser/rewrite.rs:580
requalify_column_onto_derived_tablecurrently treats all but the last identifier segment as the relation qualifier. This fails for nested field access liketest.c1.metadata.product.name(common in this codebase), where only the leadingtestis the relation and the remaining segments are column/field access. In stacked-aggregate derived-table cases, such references will not be rewritten and can remain out-of-scope/dangling in the outer SELECT.
Consider rewriting based on the longest prefix of idents[..i] (for any i < len) that matches derived_qualifiers, then replace just that prefix with the derived-table alias while preserving the remaining identifier path.
let qualifier = idents
.iter()
.take(idents.len() - 1)
.map(|ident| ident.value.clone())
.collect::<Vec<String>>()
.join(".");
if !derived_qualifiers.contains(&qualifier) {
return;
}
let Some(last) = idents.last() else {
unreachable!("CompoundIdentifier must have a last element");
};
*idents = vec![alias.clone(), last.clone()];
… do not collide A join walks both of its sides with one SelectBuilder, so both sides can carry a stacked aggregate and derive a table into the same FROM clause. Under a fixed alias the two were both named `derived_aggregate`: a duplicate table name, which most engines reject, and — the quieter half — each side requalifies its own column references onto its own alias, so the two sides' distinct columns collapsed onto one qualifier and the query grouped by whichever side was walked first. Number the alias per SELECT, following the `_unnest_N` precedent already on SelectBuilder, which is the object shared across a join and so the scope in which FROM-clause aliases have to be unique.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
datafusion/sql/src/unparser/rewrite.rs:581
requalify_column_onto_derived_tableassumes aCompoundIdentifieris always just<qualifier>.<column>and rewrites it to[alias, last]. However, the unparser also usesCompoundIdentifierfor nested field access (e.g.get_field(col("test.a"), "b")becomestest.a.binexpr.rs:get_field_to_sql), where identifiers beyond the first can be part of the column path rather than a relation qualifier. In those cases the current logic either won’t match (qualifier becomestest.a) or would drop theasegment, leaving danglingtestqualifiers or incorrect field access when a stacked aggregate becomes a derived table. Consider matching derived qualifiers against a prefix ofidentsand replacing only that matched prefix with the derived-table alias, preserving the remaining identifier chain (e.g.test.a.b->derived_aggregate_1.a.b).
let qualifier = idents
.iter()
.take(idents.len() - 1)
.map(|ident| ident.value.clone())
.collect::<Vec<String>>()
.join(".");
Summary (root cause)
A
SELECTexpresses a single grouping, but theLogicalPlan::Aggregatearm ofselect_to_sql_recursivelyrecursed straight into its input whenever the select list hadalready been built (
select.already_projected()), so a second aggregate stackedunderneath was skipped entirely — its
GROUP BYnever reached the emitted SQL. TheSort,Limit,DistinctandProjectionarms all guard the same situation by emittinga derived table;
Aggregatedid not.single_distinct_to_groupbyproduces exactly that shape forcount(DISTINCT c): an outercount(alias1)over an innerAggregategrouping byc AS alias1. Unparsing theoptimized plan for
SELECT count(DISTINCT "UserID") FROM hitstherefore emittedTwo consequences, both bad:
alias1does not exist on the base table, so a consumer that pushes the optimized plandown to a remote engine gets a binder error (
Referenced column "alias1" not found in FROM clause). This is the failure reported downstream in DuckDB agg pushdown: Clickbench/aliasing issues spiceai#8475 againstClickBench q5 on DuckDB.
DISTINCTissilently gone —
count(alias1)counts every row.Changes
SelectBuildertracks whether an aggregate has been folded into the current SELECT(
mark_aggregated()/already_aggregated()).Aggregatearm emits aderived_aggregatetable for a second aggregate instead ofskipping it, matching the existing
derived_sort/derived_limit/derived_distinct/derived_projectionhandling.After the fix:
Why the existing roundtrip suites missed it
datafusion/core/tests/sql/unparser.rsruns the TPC-H and ClickBench queries throughdf.logical_plan()— the plan as the SQL planner produces it, beforesingle_distinct_to_groupbyruns. The rewrite only appears in the optimized plan, whichis what a federation/pushdown consumer unparses. The new test closes that gap.
Test plan
cargo test -p datafusion-sql—stacked_aggregate_is_unparsed_as_a_derived_tablecovers the ungrouped
count(DISTINCT b)shape, the groupeda, count(DISTINCT b) ... GROUP BY ashape, and a lone aggregate (which must still foldinto its SELECT rather than become a derived table).
Result: 498 passed / 22 failed, versus 497 / 22 on a pristine checkout of this
base — the same 22 pre-existing failures, byte-identical output, plus the new test.
cargo test -p datafusion --test core_integration sql::—test_optimized_plan_roundtrip_count_distinctunparses the optimized plan forcount(DISTINCT)with and without aGROUP BY, then executes both the original and theunparsed SQL against the ClickBench fixture and compares the rows.
Result: 71 passed / 29 failed, versus 70 / 30 on the same base — the only delta
is the new test flipping from failing to passing.
plan.rsreverted, both new tests fail, and the core test failswith
column 'alias1' not foundand the emitted SQLSELECT count(alias1) AS c FROM hits_raw AS hits— i.e. it reproduces the reported bug.Downstream issue: spiceai/spiceai#8475
Follow-up: the derived table's enclosing SELECT kept qualifiers that bind to nothing
Copilot's review found a defect in the shape above, confirmed on both threads. Once the
inner aggregate becomes a derived table, the enclosing SELECT reads from that and no
longer from the relation its expressions still name, so those references bind to nothing.
Only the projection and
ORDER BYwere swept for such a qualifier, and only at the toplevel. Measuring each clause through this path:
GROUP BY test.aGROUP BY test.a— danglingGROUP BY test.a + 1GROUP BY, since the sweep only matches a top-levelSelectItem::UnnamedExpr(CompoundIdentifier)HAVING test.a > 1HAVING (test.a > 1)— danglingORDER BYDataFusion re-plans such SQL, but PostgreSQL answers 42703 and DuckDB a Binder Error, so a
federated pushdown fails at the remote engine — the same class of failure as the original
bug, on the SQL the fix for it emits.
Fix
The derived table now carries an alias for every dialect, and a reference is rewritten onto
that alias only when its qualifier names a relation the derived table encloses:
De-qualifying to a bare column name was the first attempt and is wrong under a join.
LogicalPlan::Joinpasses the sameSelectBuilderinto the left-side recursion and recordsthe join on it only after that recursion returns, so at the point the derived table is
built nothing on the builder shows that the SELECT reads from two relations. With a column
name shared by both sides it produced
—
count(test.a)reduced toCOUNT(a)andGROUP BY other.atoGROUP BY a, which isambiguous and groups by a different column than the plan. Worse than the unbindable
qualifier it replaced, hence the alias plus the qualifier test.
The clauses are reached through one
SelectBuilder::visit_expressions_in_clauses_mutratherthan new per-clause getters, so the builder's fields stay private.
Deliberate limitation
An expression holding a subquery is skipped whole. A correlated subquery names an enclosing
query's relation with a qualifier indistinguishable by name — in
HAVING test.a > (SELECT … WHERE other.c = test.a)both aretest.a— so rewriting insideone repointed the correlated reference at the derived table and silently changed which
column it read. Such an expression therefore keeps its own dangling qualifier, which is the
pre-existing output: a loud bind error at the remote engine rather than wrong rows.
Also unchanged: a join's
ONis attached after this SELECT's clauses and so is not swept —filed as spiceai/spiceai#12695, with the join test pinning the current output so the gap
stays visible.
Two derived aggregates on one join
A join walks both of its sides with one
SelectBuilder, so both sides can carry a stackedaggregate and derive a table into the same
FROMclause. Under a fixed alias both werenamed
derived_aggregate, which is a duplicate table name most engines reject — and,underneath that, each side requalifies its own references onto its own alias, so the two
sides' distinct columns collapsed onto one qualifier. Where the column name is shared that
binds successfully and groups by whichever side was walked first, i.e. wrong rows rather
than a loud error.
The alias is therefore numbered per SELECT (
derived_aggregate_1,derived_aggregate_2,…), following the
_unnest_Nprecedent already onSelectBuilder— the right scopeprecisely because it is the object shared across a join.
Test plan
cargo test -p datafusion-sql— 506 passed / 22 failed. The failing set and itsper-snapshot content are byte-identical to a pristine
spiceai-54, whose 22 failures arepre-existing.
cargo test -p datafusion --test core_integration sql::unparser— 3 passed, includingthe TPC-H and ClickBench roundtrips and
test_optimized_plan_roundtrip_count_distinct.cargo clippy -p datafusion-sql --all-targets— no new warnings.HAVING), adialect that requires a derived-table alias, the join case, the correlated subquery, and
stacked aggregates on both join sides — that last one uses a column name shared by both
sides deliberately, so a collapsed qualifier still binds and the test has to catch the
silent-wrong-side case rather than only a bind error.
GROUP BY/HAVINGfrom theswept clauses, matching only top-level expressions, dropping the qualifier guard, using a
bare name instead of the alias, leaving the derived table unaliased, and (control)
removing the subquery guard are each caught by the test written for them.
while leaving the requalification target numbered, and fixing the requalification target
while leaving the table alias numbered are all caught. Because the alias appears in every
snapshot those neuters fail the whole set, so the load-bearing check is separate and
direct: the new both-sides test was run against the pre-fix commit and fails there,
emitting the duplicate alias and the collapsed qualifier.