Lift aggregate arguments that mix an outer reference with a local column - #38855
Open
benedict-odonovan wants to merge 1 commit into
Open
Conversation
- Track, per aggregate invocation, whether the argument references a column of the SELECT the aggregate is evaluated in (local) and whether it references a column from further out (outer), and lift the argument when both are present. The postprocessor only lifted arguments containing a subquery, so these expressions reached the server unmodified and failed with "Multiple columns are specified in an aggregated expression containing an outer reference" - Rename SqlServerAggregateOverSubqueryPostprocessor to SqlServerAggregateArgumentPostprocessor, since a subquery in the argument is no longer the only thing that triggers a lift - Compare against the aggregating SELECT's own table aliases rather than _tableAliasesInScope: that set deliberately excludes those tables, since a lifted subquery reaches them through APPLY rather than CROSS JOIN - Leave an aggregate whose argument is purely an outer reference alone; SQL Server evaluates it in the outer query, which is how aggregates over an outer grouping are meant to translate - Restore the parent visitor state before building the lifted subquery, and capture _isCorrelatedSubquery for the OUTER APPLY/CROSS JOIN choice, so the state saved by an enclosing aggregate is no longer clobbered - Add specification tests for a Sum over an expression with an outer reference and for Sums over members of a single-result subquery, plus SQL Server baselines Fixes dotnet#38834
There was a problem hiding this comment.
Pull request overview
This PR extends the SQL Server aggregate-argument lifting postprocessor to also handle aggregate arguments that mix an outer reference with a local column (SQL Server error 8124), not just subqueries, by lifting the computed expression into an OUTER APPLY/CROSS JOIN. It also renames the postprocessor to reflect the broader responsibility and adds coverage for the new translation shape.
Changes:
- Rename
SqlServerAggregateOverSubqueryPostprocessortoSqlServerAggregateArgumentPostprocessorand expand lifting triggers to include mixed outer+local references. - Fix visitor state restoration ordering to avoid nested aggregates clobbering correlation state used to choose
OUTER APPLYvsCROSS JOIN. - Add two new relational spec tests and SQL Server baselines validating the new lifting behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| test/EFCore.SqlServer.FunctionalTests/Query/NorthwindAggregateOperatorsQuerySqlServerTest.cs | Adds SQL Server baselines for the two new aggregate/outer-reference test cases. |
| test/EFCore.Relational.Specification.Tests/Query/NorthwindAggregateOperatorsQueryRelationalTestBase.cs | Adds new spec tests reproducing SQL Server error 8124 shapes (with and without subqueries). |
| src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs | Switches to the renamed aggregate argument postprocessor. |
| src/EFCore.SqlServer/Query/Internal/SqlServerAggregateArgumentPostprocessor.cs | Implements mixed outer+local reference detection, lifting logic trigger, and fixes nested-aggregate visitor state handling. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+72
to
+74
| Products = d!.ProductID * c.CustomerID.Length, | ||
| Orders = d.OrderID * c.CustomerID.Length | ||
| } |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Fixes #38834
Summary
SqlServerAggregateArgumentPostprocessor(renamed fromSqlServerAggregateOverSubqueryPostprocessorin this PR) works around SQL Server's refusal to aggregate over a subquery by lifting the aggregate's argument out to anOUTER APPLY/CROSS JOINand aggregating the resulting column. SQL Server has a second restriction on aggregates which the postprocessor didn't cover:An aggregate argument that combines a column of the
SELECTthe aggregate is evaluated in with a column from further out runs into this. The postprocessor only lifted arguments in which a subquery was found, so such an expression reached the server unchanged and the query failed at execution.The reported shape sums, per customer, a value that multiplies a column read from a single-result subquery by a column on the outer entity:
This is a regression in 11.0 rc.1 relative to preview.6, but the bug is older than that and isn't in the postprocessor — what changed is the shape of the argument reaching it. Before #38502, a single-result subquery projecting two or more members was repeated once per member, so the aggregate argument still contained a
ScalarSubqueryExpressionand the existing subquery trigger fired, lifting the outer reference out along with it. #38502 replaces those repeated subqueries with a single join; with no subquery left in the argument, nothing triggers the lift and the outer reference stays inside the aggregate. That's also why the workaround in the issue — projecting a single member — keeps the query working.The same failure is reachable without any subquery at all, e.g.
o.OrderDetails.Sum(od => od.ProductID * o.OrderID), which is the second test added here.Implementation
The class is renamed from
SqlServerAggregateOverSubqueryPostprocessortoSqlServerAggregateArgumentPostprocessor, since a subquery in the argument is no longer the only thing that makes it lift. It's an internal-API type underQuery/Internal, andSqlServerQueryTranslationPostprocessoris its only reference.SqlServerAggregateArgumentPostprocessornow tracks two more facts per aggregate invocation, alongside the existing "argument contains a subquery":SELECTthe aggregate is evaluated inand lifts the argument when both are present, in addition to the existing subquery trigger. Everything downstream of the trigger — building the lifted subquery, choosing
OUTER APPLYvsCROSS JOIN, rewriting the aggregate to read the lifted projection — is reused unchanged.Both facts are recorded in the existing
ColumnExpressioncase, which already fires for exactly the columns of interest: those not in_tableAliasesInScope. Classification compares against the aggregatingSELECT's own table aliases (_currentSelect.Tables) rather than_tableAliasesInScope; that set deliberately excludes those tables, since a lifted subquery has to reach them throughAPPLYrather thanCROSS JOIN, so it can't tell "column of the aggregating SELECT" from "column of an enclosing one".An argument that is purely an outer reference is deliberately left alone: SQL Server accepts it and evaluates the aggregate in the outer query, which is how aggregates over an outer grouping are meant to translate. Only the mixed case is rejected by the server, and only there is lifting both necessary and unambiguous.
One pre-existing bug surfaced along the way: the parent visitor state was restored after the lifted subquery was built, so a nested aggregate clobbered the state saved by an enclosing one, and
_isCorrelatedSubquery— read to choose betweenOUTER APPLYandCROSS JOIN— could reflect the wrong invocation. The restore now happens before the lift, with the flag captured into a local first.Testing
Two new specification tests in
NorthwindAggregateOperatorsQueryRelationalTestBase, with SQL Server baselines:Sum_over_expression_with_outer_referenceSumover an expression multiplying a column of the aggregated collection by a column of the outer entity — the minimal form, no subquery involvedSum_over_members_of_single_result_subquery_with_outer_referenceSums over members of aFirstOrDefault()subquery, each combined with an outer columnBoth fail against
mainwith SQL Server error 8124 and pass with this change; both also run green on SQLite through the shared base.