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
Translate grouping aggregates as window functions behind an opt-in - #39240
I've posted a comment on an issue with a detailed description of how I am planning to contribute and got approval from a member of the team
The code builds and tests pass locally (also verified by our automated build checks)
Commit messages follow this format
Tests for the changes have been added (for bug fixes / features)
Code follows the same patterns and style as existing code in this repo
Summary
This is a first slice of window function support. It is deliberately narrow: it adds the building blocks a window function needs in the relational pipeline, and puts them to work on one LINQ shape that already exists and is already translated badly. It does not try to be the full feature. Everything is opt-in and marked experimental, so later slices can reshape it without breaking anyone.
It follows the LINQ-first direction in roji's comment on the issue: before adding new API, recognize the queries users already write that mean "an aggregate over a group, repeated on every row". The first such query is an aggregate projected alongside a grouping's elements:
Today that groups on the server and then joins the source back to itself to recover the rows it just grouped. #38963 already drops the GROUP BY when a projection only enumerates the elements; an aggregate alongside them is that same shape plus one column, and that column is exactly what OVER (PARTITION BY …) computes:
-- before: GROUP BY for the aggregate, then a join back to [Employees] for the rows it just groupedSELECT [e1].[DepartmentName], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], [e1].[c]
FROM (
SELECTAVG(CAST([e].[Salary] AS float)) AS [c], [e].[DepartmentName]
FROM [Employees] AS [e]
GROUP BY [e].[DepartmentName]
) AS [e1]
LEFT JOIN [Employees] AS [e0] ON [e1].[DepartmentName] = [e0].[DepartmentName]
ORDER BY [e1].[DepartmentName]
-- after: the rows are read once, and the aggregate is computed over each row's partition-- (the DENSE_RANK() column identifies each row's group, see #39242)SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName])
FROM [Employees] AS [e]
ORDER BY [e].[DepartmentName]
I chose this shape as the first slice for three reasons: it needs no new user-facing query API, its results are already pinned down by today's translation (so correctness is easy to check), and it exercises every part of the pipeline a window function touches: translation, nullability, SQL generation and materialization.
What this slice lays down
These are the pieces later slices are meant to build on:
WindowFunctionExpression: a SqlExpression holding a function, partitions and orderings. It supports printing, equality, quoting for precompiled queries and visiting children, so the rest of the pipeline handles it like any other node. The orderings slot is already there and already generated, though nothing in this slice fills it yet.
OVER generation in QuerySqlGenerator: a single GenerateOver helper, now also used by VisitRowNumber. SQL Server and SQLite need no generator overrides.
Nullability: SqlNullabilityProcessor.VisitWindowFunction computes a window function's nullability from its aggregate. It keeps any rewrite of the aggregate outside the OVER, since SQL attaches OVER to the function itself. More on this below.
A provider veto: IsWindowFunctionSupported lets a provider refuse to window a function it translates specially. SQLite uses it for its user-defined decimal aggregates, which it can't evaluate over a window.
An opt-in and experimental surface: UseWindowFunctionAggregates() on the relational options builder, off by default and part of the compiled query cache key. As requested on the issue, everything new is [Experimental(EF9001)]: 14 members in the API baseline. Generated precompiled-query files now suppress EF9001 as well as EF9100. When a query has too many nullable parameters for its SQL to be pregenerated, the generated code rebuilds the SQL tree, constructing WindowFunctionExpression directly, and without the suppression that code failed to compile.
What users get from it
With the option on, aggregates projected next to a grouping's elements become window functions:
Count, LongCount, Min, Max, Sum and Average, applied directly to the grouping, up to six per projection; Count and LongCount with a predicate become COUNT(CASE …) OVER (…), so the grouping still carries every row
several aggregates sharing a single scan, and an aggregate projected twice computed once
a Where before the GroupBy stays a plain WHERE
GroupBy(_ => 1) gives a window over the whole result set
SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], COUNT(*) OVER(PARTITION BY [e].[DepartmentName]), ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), MAX([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName])
FROM [Employees] AS [e]
ORDER BY [e].[DepartmentName]
Anything outside that keeps today's translation, unchanged and pinned by a test. That covers other aggregates, a Where or Distinct between the aggregate and the grouping, and an aggregate whose translation wraps the function (on SQL Server, Sum/Average over a float become a CAST around it). The grouping projected as itself alongside an aggregate, e.g. new { Group = g, Count = g.Count() }, has no translation today and still fails with the same error under the opt-in. A slice that declines never makes a query worse than it is today.
What it leaves for later slices
This is roughly how I see the remaining work splitting up. The order is up for discussion, and so is whether each piece belongs in EF at all:
Next step
What it needs on top of this slice
Running totals and rankings: ORDER BY inside OVER
a LINQ shape to recognize, or an explicit API; the node and generator already support orderings
Frame clauses (ROWS BETWEEN …)
a frame slot on WindowFunctionExpression, which is why the node is experimental
ROW_NUMBER() via Select((e, i) => …)
#24218; RowNumberExpression could be folded into WindowFunctionExpression along the way
Composing over the lifted projection, e.g. .OrderBy() after it
the pushdown work roji suggested as a second phase
An explicit EF.Functions.Over()-style API
complements the pattern matching rather than replacing it; covers what LINQ can't express
A bare OVER () for GroupBy(_ => 1), more than six aggregates, logging a fallback
small follow-ups once the direction is settled
Things worth reviewing closely
OVER binds to the aggregate, not to whatever wraps it.SqlNullabilityProcessor rewrites SUM into COALESCE(SUM(…), 0) so that an empty group sums to zero. Wrapping that whole expression gave COALESCE(…) OVER (…), which SQLite rejects outright. So VisitWindowFunction visits the aggregate's arguments but not the aggregate node itself, then puts the same COALESCE around the whole window function. It's still needed there: having the current row in the partition doesn't stop SUM returning null when every value is null. The consequence is that a provider's own VisitSqlFunction rewrites don't run inside a window. Today that only matters for SQLite's ef_sum, which IsWindowFunctionSupported already excludes. This is the contract I'd most like feedback on before later slices depend on it.
Window values follow the database's grouping. Since #39242, a lifted projection identifies its groups by DENSE_RANK() OVER(ORDER BY <grouping key>) rather than by comparing keys on the client, so groupings match the database's collation. That matters more once aggregates ride along. Under SQL Server's case-insensitive default, rows keyed "Engineering" and "engineering" form one partition, so every window value describes all of them. If the client split them into two groupings, each grouping would carry the whole partition's count. A test pins that every aggregate matches its grouping's elements in that case.
Window values are carried in their nullable form. The window values travel with each element in a ValueTuple, and the client reads them back from the first element of each materialized grouping. A projection is read back as nullable whatever the aggregate's declared type says, and converting a null into a non-nullable tuple field threw while materializing AVG(Bonus) over a partition of nulls. A test asserting the returned values caught it. A SQL baseline alone would have passed. The tuple's arity is also where the six-aggregate cap comes from.
Open questions
Should this slice need an opt-in at all? Nothing on the issue proposes one. The new SQL reads the source once instead of twice, but enabling it unconditionally changes SQL for queries that already work. It's easy to drop, or to flip on by default once the feature leaves experimental.
Silent fallback. The lift only fires when the projection is the query's final operator, a guard inherited from Stream GroupBy elements instead of joining the source back to itself #38963, so .GroupBy(…).Select(…).OrderBy(…) quietly goes back to the self-join. roji flagged this risk in an earlier comment. Is that acceptable for a first slice, given that the pushdown work in the table above is what lifts it?
One node or several? This slice adds WindowFunctionExpression alongside the existing RowNumberExpression rather than replacing it. Merging them now would touch more existing code. Doing it as part of Translate Select() with index using the ROW_NUMBER() window function #24218 seemed the better place, but I'm happy to do it here if preferred.
Testing
WindowFunctionQueryRelationalTestBase holds 19 specification tests (20 cases), run on SQL Server and SQLite with baselines for each. They split into four groups:
The translation: a single aggregate, multiple aggregates, LongCount (COUNT_BIG on SQL Server, rather than the COUNT the other tests cover), Count and LongCount with a predicate, a projected element, decimals, a repeated aggregate, a constant key, and a filter before the grouping.
The results:Average over a partition of nulls returns null, and Sum over one returns zero. The first fails without the nullable carrier.
Grouping identity: keys that differ only in case, next to an aggregate. The test asserts one grouping per group from a server-side GROUP BY, and that every aggregate matches its grouping's element count, which makes it independent of the provider's collation: SQL Server's default is case-insensitive and SQLite's is case-sensitive. The elements-only case is covered by Identify lifted GroupBy groupings by a database-computed rank #39242's own test.
The fallbacks: the same query with the option off, an aggregate without elements, elements without an aggregate, distinct and filtered aggregates, and a float sum, whose translation wraps SUM in a CAST on SQL Server. Each asserts that the existing GROUP BY translation is kept. A further test asserts that the grouping projected as itself alongside an aggregate fails translation with the same error with and without the opt-in. The distinct and filtered tests fail if the aggregate check stops rejecting those shapes; on SQL Server, the distinct one then hits "Use of DISTINCT is not allowed with the OVER clause".
AdHocPrecompiledQueryRelationalTestBase gains GroupBy_elements_and_aggregates_as_window_functions. It has four nullable parameters, one more than the precompiler pregenerates SQL for, so the SQL tree is built in the generated code. It asserts that the generated code constructs a WindowFunctionExpression and that the query returns the right values. It fails to compile without the EF9001 suppression.
Results:
New tests: 20/20 window function test cases, and the precompiled test, on SQL Server and SQLite
Full functional suites, rebased on current main: SQL Server 49,891 passed / 0 failed / 500 skipped; SQLite 38,511 passed / 0 failed / 285 skipped
EFCore.Design.Tests: 1,352 passed
EFCore.ApiBaseline.Tests green, with EFCore.Relational.baseline.json regenerated
This non-function fallback has no regression coverage: none of the new tests uses an aggregate whose translation wraps the function, such as SQL Server Sum/Average over float, even though this branch is required to avoid attaching OVER to the wrapper and the PR description says that fallback is pinned. Add a test that verifies this shape retains the existing GROUP BY/join translation.
- Add WindowFunctionExpression and translate aggregates projected
alongside a grouping's elements, e.g.
GroupBy(e => e.Dept).Select(g => new { Items = g.ToList(), Avg = g.Average(e => e.Salary) }),
as OVER (PARTITION BY <key>) over the ungrouped rows, instead of
grouping and joining the source back to itself
- Opt in with UseWindowFunctionAggregates(), off by default and part of
the compiled query cache key
- Mark the whole new surface [Experimental(EF9001)]
- Support Count/LongCount/Min/Max/Sum/Average applied directly to the
grouping, up to six per projection; anything else keeps the existing
GROUP BY translation
- Let providers veto an aggregate through IsWindowFunctionSupported;
SQLite vetoes its user-defined decimal aggregates
- Keep nullability rewrites outside OVER, so SUM gets its COALESCE around
the window function rather than inside it
- Carry window values in their nullable form, so an aggregate over a
partition of nulls materializes correctly
- Share OVER clause generation between window functions and ROW_NUMBER
Part of dotnet#12747
- Suppress EF9001 in generated precompiled-query files: a SQL tree quoted
into the generated code constructs the experimental
WindowFunctionExpression, which failed to compile
- Add a precompiled-query test whose SQL tree is quoted rather than
pregenerated, so the window function is constructed in generated code
- Add a LongCount test, which SQL Server translates to COUNT_BIG rather
than COUNT
Part of dotnet#12747
…egate
- Add Elements_and_aggregate_with_keys_differing_in_case: groupings, and
the window values they carry, follow the database's grouping, now that
lifted projections identify their groups by DENSE_RANK() (dotnet#39241)
- Add Elements_and_float_sum: an aggregate whose translation wraps the
function (a float sum is a CAST around SUM on SQL Server) keeps the
GROUP BY translation
- Update window function baselines for the group rank column
Part of dotnet#12747
- Drop the trailing OrderBy from Distinct_aggregate_is_not_lifted and
Filtered_aggregate_is_not_lifted: as a final operator after the
projection, it kept the lift from being attempted at all, so the tests
passed whether or not the aggregate check rejected these shapes
- Order the results on the client before asserting instead
Part of dotnet#12747
- Add Elements_and_predicate_counts: Count and LongCount with a predicate
are windowed as COUNT(CASE ...) OVER (...) (COUNT_BIG on SQL Server),
with the grouping still carrying every row
- Add Grouping_itself_and_aggregate_is_not_translated: projecting the
grouping itself alongside an aggregate fails translation the same way
with and without the opt-in
Part of dotnet#12747
The reason will be displayed to describe this comment to others. Learn more.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
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.
Part of #12747
Summary
This is a first slice of window function support. It is deliberately narrow: it adds the building blocks a window function needs in the relational pipeline, and puts them to work on one LINQ shape that already exists and is already translated badly. It does not try to be the full feature. Everything is opt-in and marked experimental, so later slices can reshape it without breaking anyone.
It follows the LINQ-first direction in roji's comment on the issue: before adding new API, recognize the queries users already write that mean "an aggregate over a group, repeated on every row". The first such query is an aggregate projected alongside a grouping's elements:
Today that groups on the server and then joins the source back to itself to recover the rows it just grouped. #38963 already drops the
GROUP BYwhen a projection only enumerates the elements; an aggregate alongside them is that same shape plus one column, and that column is exactly whatOVER (PARTITION BY …)computes:I chose this shape as the first slice for three reasons: it needs no new user-facing query API, its results are already pinned down by today's translation (so correctness is easy to check), and it exercises every part of the pipeline a window function touches: translation, nullability, SQL generation and materialization.
What this slice lays down
These are the pieces later slices are meant to build on:
WindowFunctionExpression: aSqlExpressionholding a function, partitions and orderings. It supports printing, equality, quoting for precompiled queries and visiting children, so the rest of the pipeline handles it like any other node. The orderings slot is already there and already generated, though nothing in this slice fills it yet.OVERgeneration inQuerySqlGenerator: a singleGenerateOverhelper, now also used byVisitRowNumber. SQL Server and SQLite need no generator overrides.SqlNullabilityProcessor.VisitWindowFunctioncomputes a window function's nullability from its aggregate. It keeps any rewrite of the aggregate outside theOVER, since SQL attachesOVERto the function itself. More on this below.IsWindowFunctionSupportedlets a provider refuse to window a function it translates specially. SQLite uses it for its user-defined decimal aggregates, which it can't evaluate over a window.UseWindowFunctionAggregates()on the relational options builder, off by default and part of the compiled query cache key. As requested on the issue, everything new is[Experimental(EF9001)]: 14 members in the API baseline. Generated precompiled-query files now suppress EF9001 as well as EF9100. When a query has too many nullable parameters for its SQL to be pregenerated, the generated code rebuilds the SQL tree, constructingWindowFunctionExpressiondirectly, and without the suppression that code failed to compile.What users get from it
With the option on, aggregates projected next to a grouping's elements become window functions:
Count,LongCount,Min,Max,SumandAverage, applied directly to the grouping, up to six per projection;CountandLongCountwith a predicate becomeCOUNT(CASE …) OVER (…), so the grouping still carries every rowWherebefore theGroupBystays a plainWHEREGroupBy(_ => 1)gives a window over the whole result setAnything outside that keeps today's translation, unchanged and pinned by a test. That covers other aggregates, a
WhereorDistinctbetween the aggregate and the grouping, and an aggregate whose translation wraps the function (on SQL Server,Sum/Averageover afloatbecome aCASTaround it). The grouping projected as itself alongside an aggregate, e.g.new { Group = g, Count = g.Count() }, has no translation today and still fails with the same error under the opt-in. A slice that declines never makes a query worse than it is today.What it leaves for later slices
This is roughly how I see the remaining work splitting up. The order is up for discussion, and so is whether each piece belongs in EF at all:
ORDER BYinsideOVERROWS BETWEEN …)WindowFunctionExpression, which is why the node is experimentalROW_NUMBER()viaSelect((e, i) => …)RowNumberExpressioncould be folded intoWindowFunctionExpressionalong the way.OrderBy()after itEF.Functions.Over()-style APIOVER ()forGroupBy(_ => 1), more than six aggregates, logging a fallbackThings worth reviewing closely
OVERbinds to the aggregate, not to whatever wraps it.SqlNullabilityProcessorrewritesSUMintoCOALESCE(SUM(…), 0)so that an empty group sums to zero. Wrapping that whole expression gaveCOALESCE(…) OVER (…), which SQLite rejects outright. SoVisitWindowFunctionvisits the aggregate's arguments but not the aggregate node itself, then puts the sameCOALESCEaround the whole window function. It's still needed there: having the current row in the partition doesn't stopSUMreturning null when every value is null. The consequence is that a provider's ownVisitSqlFunctionrewrites don't run inside a window. Today that only matters for SQLite'sef_sum, whichIsWindowFunctionSupportedalready excludes. This is the contract I'd most like feedback on before later slices depend on it.Window values follow the database's grouping. Since #39242, a lifted projection identifies its groups by
DENSE_RANK() OVER(ORDER BY <grouping key>)rather than by comparing keys on the client, so groupings match the database's collation. That matters more once aggregates ride along. Under SQL Server's case-insensitive default, rows keyed"Engineering"and"engineering"form one partition, so every window value describes all of them. If the client split them into two groupings, each grouping would carry the whole partition's count. A test pins that every aggregate matches its grouping's elements in that case.Window values are carried in their nullable form. The window values travel with each element in a
ValueTuple, and the client reads them back from the first element of each materialized grouping. A projection is read back as nullable whatever the aggregate's declared type says, and converting a null into a non-nullable tuple field threw while materializingAVG(Bonus)over a partition of nulls. A test asserting the returned values caught it. A SQL baseline alone would have passed. The tuple's arity is also where the six-aggregate cap comes from.Open questions
.GroupBy(…).Select(…).OrderBy(…)quietly goes back to the self-join. roji flagged this risk in an earlier comment. Is that acceptable for a first slice, given that the pushdown work in the table above is what lifts it?WindowFunctionExpressionalongside the existingRowNumberExpressionrather than replacing it. Merging them now would touch more existing code. Doing it as part of Translate Select() with index using the ROW_NUMBER() window function #24218 seemed the better place, but I'm happy to do it here if preferred.Testing
WindowFunctionQueryRelationalTestBaseholds 19 specification tests (20 cases), run on SQL Server and SQLite with baselines for each. They split into four groups:LongCount(COUNT_BIGon SQL Server, rather than theCOUNTthe other tests cover),CountandLongCountwith a predicate, a projected element, decimals, a repeated aggregate, a constant key, and a filter before the grouping.Averageover a partition of nulls returns null, andSumover one returns zero. The first fails without the nullable carrier.GROUP BY, and that every aggregate matches its grouping's element count, which makes it independent of the provider's collation: SQL Server's default is case-insensitive and SQLite's is case-sensitive. The elements-only case is covered by Identify lifted GroupBy groupings by a database-computed rank #39242's own test.SUMin aCASTon SQL Server. Each asserts that the existingGROUP BYtranslation is kept. A further test asserts that the grouping projected as itself alongside an aggregate fails translation with the same error with and without the opt-in. The distinct and filtered tests fail if the aggregate check stops rejecting those shapes; on SQL Server, the distinct one then hits "Use of DISTINCT is not allowed with the OVER clause".AdHocPrecompiledQueryRelationalTestBasegainsGroupBy_elements_and_aggregates_as_window_functions. It has four nullable parameters, one more than the precompiler pregenerates SQL for, so the SQL tree is built in the generated code. It asserts that the generated code constructs aWindowFunctionExpressionand that the query returns the right values. It fails to compile without the EF9001 suppression.Results:
main: SQL Server 49,891 passed / 0 failed / 500 skipped; SQLite 38,511 passed / 0 failed / 285 skippedEFCore.Design.Tests: 1,352 passedEFCore.ApiBaseline.Testsgreen, withEFCore.Relational.baseline.jsonregenerated