Skip to content

Query: translate GroupBy element reads with ROW_NUMBER only - #39173

Open
QHarshil wants to merge 1 commit into
dotnet:mainfrom
QHarshil:query/groupby-element-row-number
Open

QHarshil wants to merge 1 commit into
dotnet:mainfrom
QHarshil:query/groupby-element-row-number

Conversation

@QHarshil

@QHarshil QHarshil commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #36380
Fixes #29240

GroupBy(k).Select(g => g.OrderBy(o).First()) translates to a GROUP BY joined back on the key to a ROW_NUMBER() subquery. That reads the source twice, and the = join loses the element of the NULL group: FirstOrDefault() returns null for it, and First() over a projected element throws "Sequence contains no elements" (#29240).

This PR translates it to the ROW_NUMBER() filter alone:

SELECT [o0].[OrderID], [o0].[CustomerID], [o0].[EmployeeID], [o0].[OrderDate]
FROM (
    SELECT [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate], ROW_NUMBER() OVER(PARTITION BY [o].[CustomerID] ORDER BY [o].[OrderID] DESC) AS [row]
    FROM [Orders] AS [o]
) AS [o0]
WHERE [o0].[row] <= 1

It applies when the projection uses the grouping only through g.Key and one First, FirstOrDefault, Last or LastOrDefault over the elements, optionally ordered and projected, so MaxBy and MinBy too. Every group has a first row, so the groups are the same. Operators after the read compose over it, so a Where on the element becomes a plain WHERE instead of a correlated TOP(1) subquery in HAVING.

Today's translation stays for a filter, Skip or ElementAt in the group, aggregates, two element reads, scalar reads like g.First().Name, GroupBy in a subquery or a correlated SelectMany, and elements with included navigations when more operators follow. Those still lose the NULL group.

Implementation

  • TryTranslateGroupingElementRead, next to TryTranslateGroupingElementProjection from Stream GroupBy elements instead of joining the source back to itself #38963, matches the shape and translates the orderings before changing the SelectExpression. Any other shape falls through.
  • The internal SelectExpression.ApplyFirstRowPerGroup drops the grouping and adds the partitioned ROW_NUMBER() filter, using the fallback orderings AddJoin uses.

Performance

EF's SQL before and after, 5% NULL keys, median of 5. SQL Server 2025 ran in an x64 container on Apple Silicon.

rows groups index query SQL Server before after SQLite before after
1M 1,000 no First() 211 ms 114 ms 1,103 ms 795 ms
1M 50,000 yes First() 174 ms 146 ms 1,232 ms 948 ms
1M 1,000 no First(), then Where 2,028 ms 140 ms 23,725 ms 839 ms
1M 50,000 yes First(), then Where 2,590 ms 130 ms 1,991 ms 1,661 ms

Non-NULL groups returned the same rows in every run.

Tests

  • 18 new tests: nullable keys (value type, reference, navigation, composite) with First, FirstOrDefault, LastOrDefault, MaxBy, a projected element, an element selector and the result-selector overload; a Where/OrderBy/Take after the read; GroupBy nullable column followed by First() over group returns null record #29240's code; owned types; a precompiled query; and four shapes that keep today's translation, whose baselines show the old SQL.
  • 11 of them fail on main, on SQL Server and SQLite, with a wrong NULL group, "Sequence contains no elements" or a NullReferenceException.
  • Group_by_json_scalar_Orderby_json_scalar_FirstOrDefault was skipped for JSON/Query: translate groupby followed by orderby json scalar + FirstOrDefault #29287 but passes on main, so I unskipped it.
  • SQL baselines change for 28 existing tests, all reads of a group's element.
  • SQL Server 2025 functional, 51,149 tests: 3 failed in the batched run and pass when rerun on their own, on this branch and on main. SQLite functional (38,512) and InMemory functional (28,396) pass, as do the unit test projects and EFCore.ApiBaseline.Tests.

  • 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

GroupBy(k).Select(g => g.OrderBy(o).First()) was translated as a
GROUP BY joined back on the key to a ROW_NUMBER() subquery. That reads
the source twice, and the join on the key loses the element of the group
whose key is NULL.

When a projection uses the grouping only through its key and one First,
FirstOrDefault, Last or LastOrDefault over the elements, optionally
ordered and projected (which covers MaxBy and MinBy), number the source
rows within each group and keep the first, with no GROUP BY and no join.
Other shapes keep today's translation.

Fixes dotnet#36380
Fixes dotnet#29240

Signed-off-by: QHarshil <harshil_c@hotmail.com>
Copilot AI balanced review requested due to automatic review settings October 5, 2026 19:42
@QHarshil
QHarshil requested a review from a team as a code owner October 5, 2026 19:42

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.

@QHarshil

QHarshil commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Reopened to rerun CI. The earlier jobs were cancelled before a runner picked them up.

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

It changes mutable core query-translation behavior across many compositional and provider-specific paths, warranting final human review.

0 open findings

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

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

Projects

None yet

2 participants