Skip to content

Query: read nullable value-type GroupBy keys as nullable on the client - #39098

Open
QHarshil wants to merge 1 commit into
dotnet:mainfrom
QHarshil:query/groupby-null-key
Open

QHarshil wants to merge 1 commit into
dotnet:mainfrom
QHarshil:query/groupby-null-key

Conversation

@QHarshil

Copy link
Copy Markdown
Contributor

Fixes #33663

Summary

When groupings are assembled on the client, a NULL key of nullable value type throws. From #33663:

var group = context.Companies.GroupBy(x => new { x.CountryId }); // CountryId is int?
var groupRes = group.ToList();
System.InvalidOperationException: Nullable object must have a value.
   at Microsoft.EntityFrameworkCore.Query.Internal.GroupBySingleQueryingEnumerable`2.Enumerator.MoveNext()

This happens with a final GroupBy on 8.0, 9.0 and 10.0. On main it also happens for projections that stream a grouping's elements: #38963 moved them onto the same client-side grouping, so the issue's group.Select(x => new { x.Key, C = x.ToArray() }), which works on 10.0, now throws too. #38963 isn't on release/11.0, so without a fix this ships in 12.0.

The issue's code on each build (SQLite, one company with no country):

10.0.12 main this PR
group.ToList() throws throws 2 groups
group1.ToList() (new { x.CountryId, x.Id }) throws ArgumentNullException throws 3 groups
select2 (new { x.Key, C = x.ToArray() }) 2 groups throws 2 groups
select4 3 groups throws 3 groups

The Sum cases at the end of the issue already work on main. The generated SQL doesn't change for any query.

Implementation

For GroupBy(i => i.Group) over an int? column, the key translates to Convert(column, int?) over an int-typed SQL expression, and AddGroupByKeySelectorToProjection read the operand as int before converting it back. From the compiled query:

keySelector: (queryContext, dataReader) => (int?)(int)dataReader.IsDBNull(0) ? default(int?) : (int?)dataReader.GetInt32(0),
keyIdentifier: (queryContext, dataReader) => new object[]{ (object)(int)dataReader.IsDBNull(0) ? default(int?) : (int?)dataReader.GetInt32(0) },

Key column binding. The binding recorded for a key column is now the nullable read. The key selector still converts it to the column's type, so a key that is non-nullable in C# behaves as before. The key identifier boxes the nullable read as is; its value comparers already compare null to null. This matches how the identifier already reads columns that aren't part of the key.

Convert over a key column. A Convert to the nullable form of its SQL operand returns that nullable read, and a Convert to a reference type converts it. The second covers keys typed object, such as GroupBy(e => (object?)e.ReportsTo). A Convert with a user-defined conversion method is left as it was.

Scope

A key read as a nullable or reference type now gets the value EF projects for it. GroupBy(e => (uint?)e.Manager!.EmployeeID) over an optional navigation gives a NULL group, as Select(e => (uint?)e.Manager!.EmployeeID) gives null; on main it threw. A key that stays non-nullable, such as GroupBy(e => e.Manager!.EmployeeID), still throws "Nullable object must have a value", as projecting it does.

Not covered here:

Test plan

New tests, each over a NULL key:

Test Where
Final_GroupBy_nullable_value_type_key NorthwindGroupByQueryTestBase, FinalGroupBy
Final_GroupBy_nullable_cast_over_optional_navigation same
Final_GroupBy_anonymous_key_with_nullable_value_type same
Final_GroupBy_composite_key_with_nullable_value_type same
Final_GroupBy_nullable_value_type_key_as_object same
GroupBy_nullable_value_type_key_Select_ToList NorthwindGroupByQueryTestBase, GroupByWithoutAggregate
GroupBy_nullable_value_type_key_Select_element_projection same
GroupBy_nullable_value_type_key_with_element_selector_Select_ToList same
Final_GroupBy_nullable_value_type_key_with_split_Include NorthwindGroupByQueryRelationalTestBase
GroupBy_nullable_foreign_key_in_anonymous_key (the issue's code) AdHocMiscellaneousQueryRelationalTestBase, region 33663

The Northwind tests use Employee.ReportsTo, which is NULL for the one employee with no manager, and for the split query the highest EmployeeID over a customer's orders, which is NULL for customers with no orders. All 10 fail on main on SQL Server and SQLite (20 of 20 executions each) and pass with the change. InMemory doesn't support a final GroupBy, so its overrides assert the existing translation failure; the other tests pass there on both builds. No existing SQL baseline changes.

Reverting the key column binding, or the nullable Convert case, fails all 10. Reverting only the reference-type condition fails Final_GroupBy_nullable_value_type_key_as_object.

Test results

  • SQL Server 2025 (an x64 container on Apple Silicon): the full functional suite, 50,994 tests, 445 skipped. 14 failed in the batched run. Two are GroupBy_ResultSelector_Entire_Entity_Where, which fails the same way on main here: two LACOR orders share the latest OrderDate, so which one First() picks depends on the plan. The other 12 pass when their classes run on their own, on this branch and on main (connection-pool timeouts and a server crash under load).
  • SQLite functional: 38,406 tests, 286 skipped, 0 failed.
  • InMemory functional: 28,362 tests, 263 skipped, 0 failed.
  • EFCore.Tests 6,990, EFCore.Relational.Tests 1,500, EFCore.SqlServer.Tests 1,406, EFCore.Sqlite.Tests 896, EFCore.InMemory.Tests 39, EFCore.ApiBaseline.Tests 14: all passed.

To run the new tests:

./restore.sh && ./build.sh
source ./activate.sh
cd artifacts/bin/EFCore.Sqlite.FunctionalTests/Debug/net11.0
dotnet exec Microsoft.EntityFrameworkCore.Sqlite.FunctionalTests.dll --filter-method '*nullable_value_type*' --filter-method '*nullable_cast_over_optional_navigation*' --filter-method '*nullable_foreign_key_in_anonymous_key*'

  • 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

A grouping key of nullable value type translates to Convert(column, int?)
over an int-typed SQL expression. AddGroupByKeySelectorToProjection read
the operand as int before converting it back, so the key selector and the
key identifier threw "Nullable object must have a value" for the NULL group
whenever groupings were assembled on the client: a final GroupBy, and since
dotnet#38963 projections that stream a grouping's elements. A key converted to
object failed the same way.

- The projection binding recorded for a key column is now the nullable
  read. The key identifier boxes it as is, and its value comparers already
  handle null.
- A Convert to the nullable form of its SQL operand, or to a reference
  type, converts that nullable read.

Only the client-side key reading changes; the generated SQL is the same.
A key read as a nullable or reference type gets the value EF projects for
it, so a nullable cast over an optional navigation gives a NULL group. A key
that stays non-nullable still throws as before.

Fixes dotnet#33663

Signed-off-by: QHarshil <harshil_c@hotmail.com>
Copilot AI lite review requested due to automatic review settings September 26, 2026 05:36
@QHarshil
QHarshil requested a review from a team as a code owner September 26, 2026 05:36

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 review overview

🟢 Approval recommended

The reviewed changes have no unresolved blocking issues and include comprehensive regression coverage.

Review effort: Lite
Findings: None

What changed in this PR

Fixes client-side GroupBy failures when nullable value-type keys contain SQL NULL values.

Changes:

  • Preserves nullable reads for grouping keys and identifiers.
  • Handles nullable and reference-type conversions safely.
  • Adds provider-specific regression and compatibility tests.
File Summary
test/​EFCore.SqlServer.FunctionalTests/​Query/​NorthwindGroupByQuerySqlServerTest.cs Adds SQL Server GroupBy coverage.
test/​EFCore.SqlServer.FunctionalTests/​Query/​AdHocMiscellaneousQuerySqlServerTest.cs Adds SQL Server regression coverage.
test/​EFCore.Sqlite.FunctionalTests/​Query/​AdHocMiscellaneousQuerySqliteTest.cs Adds SQLite regression coverage.
test/​EFCore.Specification.Tests/​Query/​NorthwindGroupByQueryTestBase.cs Adds nullable-key GroupBy tests.
test/​EFCore.Relational.Specification.Tests/​Query/​NorthwindGroupByQueryRelationalTestBase.cs Adds split-query Include coverage.
test/​EFCore.Relational.Specification.Tests/​Query/​AdHocMiscellaneousQueryRelationalTestBase.cs Adds issue #33663 regression coverage.
test/​EFCore.InMemory.FunctionalTests/​Query/​NorthwindGroupByQueryInMemoryTest.cs Records expected InMemory translation failures.
src/​EFCore.Relational/​Query/​SqlExpressions/​SelectExpression.cs Implements null-safe GroupBy key projection and conversion handling.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

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.

GroupBy and sum issues

2 participants