fix: reject nested aggregate functions (e.g. sum(sum(x))) during logical planning#23813
Conversation
`sum(sum(x))` was accepted by the planner and only failed at physical planning with a `NotImplemented` error that dumped the `Debug` formatting of the aggregate's `Signature` without mentioning nesting, the function or the column. Add `check_no_nested_aggregates`, called from `Aggregate::try_new`, which rejects an aggregate function call that contains another aggregate call in its arguments, `FILTER` or `ORDER BY`. The error names both expressions and carries a `Diagnostic` pointing back into the SQL. Aggregates used as arguments of a window function (`sum(sum(x)) OVER ()`) or computed in a subquery remain legal and are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@neilconway would you mind taking a look at this since we're trying to match postgres behavior 😉 ? |
There was a problem hiding this comment.
Pull request overview
This PR adds an explicit logical-planning validation to reject nested aggregate function calls (e.g. sum(sum(x))), ensuring such queries fail early with an actionable planning error (including a Diagnostic span/help) instead of failing later during physical planning with an unhelpful NotImplemented error.
Changes:
- Introduces
datafusion_expr::utils::check_no_nested_aggregatesto detect aggregates nested inside aggregate arguments /FILTER/ORDER BYand emit a targeted planning error + diagnostic. - Enforces the invariant centrally by calling the check from
Aggregate::try_new, covering both SQL-planner and non-SQL (LogicalPlanBuilder/ DataFrame) plan construction. - Adds broad test coverage (expr unit tests, logical plan builder test, SQL integration + diagnostic span/help assertions, and sqllogictest cases), including verification that the legal window-over-aggregate case remains unchanged.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| datafusion/expr/src/utils.rs | Adds check_no_nested_aggregates, diagnostic construction, and unit tests for legal/illegal nesting scenarios. |
| datafusion/expr/src/logical_plan/plan.rs | Calls check_no_nested_aggregates from Aggregate::try_new to reject nested aggregates during logical plan construction. |
| datafusion/expr/src/logical_plan/builder.rs | Adds a non-SQL path test ensuring LogicalPlanBuilder::aggregate rejects nested aggregates. |
| datafusion/sql/tests/sql_integration.rs | Adds SQL planner integration tests for nested aggregates and for the legal window-over-aggregate plan shape. |
| datafusion/sql/tests/cases/diagnostic.rs | Adds a diagnostic test asserting error message, span location, and help text for nested aggregates. |
| datafusion/sqllogictest/test_files/aggregate.slt | Adds sqllogictest coverage for nested-aggregate rejection and confirms legal scalar/window/subquery aggregate patterns remain valid. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
neilconway
left a comment
There was a problem hiding this comment.
Overall lgtm! Great test coverage, nice and clear implementation, and good PR commentary.
Seems like we could potentially check for and reject two additional related classes of illegal queries:
- Window functions nested inside aggregates (
SELECT sum(sum(x) OVER ()) FROM t) - Nested window functions
We can take that on as a separate PR if you prefer.
| format!( | ||
| "Compute '{inner}' in an inner query and aggregate its result, or use a window function such as '{outer} OVER ()'" | ||
| ), |
There was a problem hiding this comment.
Seems like this suggests illegal syntax if the problematic construct occurs in a FILTER or ORDER BY clause.
Both classes fail at physical planning today with the same unhelpful `NotImplemented` error as `sum(sum(x))`: SELECT sum(sum(x) OVER ()) FROM t -- window in aggregate SELECT sum(sum(x) OVER ()) OVER () FROM t -- nested window SELECT row_number() OVER (ORDER BY row_number() OVER ()) -- nested window Generalize the nesting check to a shared `check_nesting` helper and expose it as `check_aggregate_nesting` (no aggregate or window call inside an aggregate, called from `Aggregate::try_new`) and `check_window_nesting` (no window call inside a window call, called from `Window::try_new`). Error and diagnostic messages follow PostgreSQL's wording per case. An aggregate used as the argument of a window function (`sum(sum(x)) OVER ()`) stays legal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #23813 +/- ##
==========================================
+ Coverage 80.71% 80.75% +0.03%
==========================================
Files 1089 1089
Lines 368760 368968 +208
Branches 368760 368968 +208
==========================================
+ Hits 297647 297961 +314
+ Misses 53372 53250 -122
- Partials 17741 17757 +16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`check_aggregate_nesting` and `check_window_nesting` have no callers outside `datafusion-expr`: `Aggregate::try_new` and `Window::try_new` enforce the invariant for every way of building those nodes, so nothing downstream needs to run the checks itself. Keep them `pub(crate)` rather than adding public API that would have to be supported forever. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two checks were the same walk with different kind parameters, which needed a `FunctionCallKind` enum, an `illegal_inner_kinds` slice per call site and a separate error constructor. Replace all of it with a single `check_aggregate_and_window_nesting` plus `illegal_nesting_err`, which decides legality and builds the error from one match over the (outer, inner) expression pair — so the rules and their messages live in one place. Both `Aggregate::try_new` and `Window::try_new` call the same function; the `Jump` optimization is dropped because the guard already skips non-call nodes cheaply. Behavior and error messages are unchanged. Also add a `LogicalPlanBuilder::window` test, the non-SQL counterpart of the existing aggregate one, which mutation testing showed was the only path with no in-crate coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
@adriangb
Nice fix overall. Catching these invalid nesting cases during logical planning is a clear improvement, and the diagnostics and test coverage look good. I just have a couple of small suggestions that I think would make the change a bit more complete.
| /// logical plan is built rather than failing later with an error that does not | ||
| /// point back at the original SQL. | ||
| /// | ||
| /// Callers do not need to invoke this directly: [`Aggregate::try_new`] and |
There was a problem hiding this comment.
Nice improvement. One small thing: I think "every way of building those nodes" is a bit stronger than what the code guarantees today. Window::try_new_with_schema and Aggregate::try_new_with_schema don't perform this validation, and Window can also be constructed directly from its public fields.
There was a problem hiding this comment.
I've reworded the doc comment to be accurate about which paths run the check. I'd lean toward not validating in the others, wdyt?
| statement error DataFusion error: Error during planning: Window function calls cannot be nested | ||
| SELECT sum(sum(column2) OVER ()) OVER () FROM nested_agg_t; | ||
|
|
||
| statement error DataFusion error: Error during planning: Window function calls cannot be nested |
There was a problem hiding this comment.
The coverage here looks great. One additional case that might be worth adding is a nested window function in PARTITION BY, for example:
row_number() OVER (PARTITION BY row_number() OVER ())Expr::apply_children traverses PARTITION BY, but the current SQL tests exercise window arguments and ORDER BY. Adding a regression test for PARTITION BY would help protect that traversal path too.
…N BY Address review feedback: - The doc comment overstated the guarantee. Note that the `try_new_with_schema` constructors and building a `Window` from its public fields bypass the check, so a hand-built node needs the caller to run it. - Add a regression test for a window call nested in `PARTITION BY`, exercising that traversal path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review @kosiew and @neilconway ! |
The aggregate-in-aggregate help text suggested `'{outer} OVER ()'`, which
is legal only when the inner aggregate is a plain argument (`sum(sum(x))`).
Because the outer expr's Display includes the FILTER/ORDER BY clauses, the
suggestion rendered still-illegal syntax for those cases, e.g.
`sum(x) FILTER (WHERE sum(x) > 0) OVER ()`.
Drop the clause, leaving "Compute '{inner}' in an inner query and aggregate
its result", which is always valid and consistent with the other two
branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Which issue does this PR close?
sum(sum(x))) are accepted at logical planning and fail at physical planning with an unhelpful error #23812.Rationale for this change
Nested aggregate calls such as
sum(sum(x))were accepted by the planner and produced aLogicalPlan::Aggregatewhoseaggr_exprcontained an innerExpr::AggregateFunctionas afunction argument. That plan has no physical equivalent, so the query failed late, during
physical planning, with:
Two problems: the query should be rejected at planning time, and the error text dumps the
Debugformatting of a
Signature(including the full coercion table) without ever mentioning nesting,the offending function, or the offending column.
Two neighbouring classes of illegal nesting (raised in review) fail exactly the same way, so this
PR covers them too:
PostgreSQL rejects all three at parse/analysis time with
aggregate function calls cannot be nested,aggregate function calls cannot contain window function callsandwindow function calls cannot be nestedrespectively; this PR uses the same wording per case.What changes are included in this PR?
check_aggregate_and_window_nesting, which walks the expressions and, for everyaggregate or window call it finds, rejects an illegally nested call below it (in the arguments,
FILTER,PARTITION BYorORDER BY). Which pairs are illegal, and the message for each, livein a single match over the (outer, inner) pair.
Aggregate::try_newandWindow::try_newcall it, so both the SQL path and theDataFrame/LogicalPlanBuilderpath are covered, and the failure happens while the logical planis built.
Diagnostic(with a span pointing into the originalSQL and a help message), for example:
The legal cases from the issue are unaffected, as they do not nest one call inside another of a
restricted kind: a scalar function over an aggregate (
abs(sum(x))), a window function over anaggregate (
sum(sum(x)) OVER (), which plans asWindowAggroverAggregate), and an aggregateover an aggregate or window result computed in a subquery.
Are these changes tested?
Yes:
FILTER,ORDER BY, window-in-aggregate, window-in-window,and the legal window-over-aggregate case) in
datafusion/expr/src/utils.rs;LogicalPlanBuilder::aggregateandLogicalPlanBuilder::windowtests covering the non-SQL path;SELECT,GROUP BYandHAVING, for window-in-aggregate and nestedwindows, plus a test asserting the
sum(sum(x)) OVER ()plan is unchanged;aggregate.sltcovering every row of the table in the issue plus the windowcases.
Are there any user-facing changes?
Queries with these nestings now fail during planning with an actionable error instead of failing
during physical planning with a
NotImplementederror. No previously working query changesbehavior.
No new public API: the check is
pub(crate), sinceAggregate::try_newandWindow::try_newenforce the invariant for every way of building those nodes.
Changed lines are at 99.3% coverage (
cargo llvm-cov; the one uncovered line is a formattingartifact inside a passing test), and
cargo mutants --in-diffgenerates 11 mutants of which 4 areunviable and all 7 viable ones are killed.