Skip to content

Translate grouping aggregates as window functions behind an opt-in - #39240

Open
benedict-odonovan wants to merge 5 commits into
dotnet:mainfrom
benedict-odonovan:feature/12747-window-function-mvp
Open

benedict-odonovan wants to merge 5 commits into
dotnet:mainfrom
benedict-odonovan:feature/12747-window-function-mvp

Conversation

@benedict-odonovan

@benedict-odonovan benedict-odonovan commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Part of #12747

  • I've read the guidelines for contributing and seen the walkthrough
  • 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:

context.Employees
    .GroupBy(e => e.DepartmentName)
    .Select(g => new { Employees = g.ToList(), AverageSalary = g.Average(e => e.Salary) })

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 grouped
SELECT [e1].[DepartmentName], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], [e1].[c]
FROM (
    SELECT AVG(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

  1. 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.
  2. 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?
  3. 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

Copilot AI balanced review requested due to automatic review settings October 13, 2026 14:13
@benedict-odonovan
benedict-odonovan requested a review from a team as a code owner October 13, 2026 14:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generated precompiled-query code can surface EF9001 as an error, and the provider-specific LongCount path lacks coverage.

2 open findings
What changed in this PR

Adds opt-in translation of grouping aggregates into SQL window functions across the relational query pipeline.

Changes:

  • Adds WindowFunctionExpression, SQL generation, nullability handling, and provider veto support.
  • Adds experimental configuration and compiled-query cache-key integration.
  • Adds SQL Server and SQLite functional coverage.
File Description
WindowFunctionQuerySqlServerTest.cs Adds SQL Server baselines.
WindowFunctionQuerySqliteTest.cs Adds SQLite baselines.
WindowFunctionQueryRelationalTestBase.cs Adds relational behavior tests.
SqliteQueryableMethodTranslatingExpressionVisitor.cs Rejects unsupported SQLite aggregates.
SqlNullabilityProcessor.cs Handles window-function nullability.
WindowFunctionExpression.cs Defines the new SQL expression.
RelationalQueryCompilationContext.cs Exposes the translation option.
RelationalQueryableMethodTranslatingExpressionVisitor.cs Implements aggregate lifting.
RelationalCompiledQueryCacheKeyGenerator.cs Includes the option in cache keys.
QuerySqlGenerator.cs Generates OVER clauses.
RelationalOptionsExtension.cs Stores and logs the option.
RelationalDbContextOptionsBuilder.cs Adds the opt-in API.
EFCore.Relational.baseline.json Records experimental APIs.

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 13, 2026 14:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Client-side grouping can disagree with database partition semantics under collations, producing incorrect groupings and aggregate results.

3 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Add regression coverage for non-function aggregate window fallback

src/​EFCore.Relational/​Query/​RelationalQueryableMethodTranslatingExpressionVisitor.cs:943

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.

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 13, 2026 16:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Newly admitted predicate aggregates and the direct-grouping fallback lack focused regression coverage.

2 open findings
3 resolved since last review

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 13, 2026 16:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad query-pipeline and provider-facing API changes warrant final human architectural review.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

Ben O'Donovan added 5 commits October 17, 2026 10:54
- 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
Copilot AI balanced review requested due to automatic review settings October 17, 2026 19:40
@benedict-odonovan
benedict-odonovan force-pushed the feature/12747-window-function-mvp branch from 551641c to ceec5a2 Compare October 17, 2026 19:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants