dotnet / dotnet/efcore

GroupBy with an element selector prevents aggregates over reference navigations from being lifted into a join

Open
#38,775 3 comments 0 reactions 0 assignees View on GitHub
area-groupby area-perf area-query customer-reported
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

### Bug description

In EF Core 11, `GroupBy(key).Select(g => g.Max(o => o.Nav.Property))` now translates to a single `SELECT` with a join (#27933, fixed by #38668). Writing the same query with a `GroupBy` element selector still produces a correlated sub-query per aggregate, as EF Core 10 did.

The two LINQ queries return the same results and look equivalent in LINQ, but the SQL is very different.

```csharp
db.Orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
g.Key,
Region = g.Max(o => o.Customer.Region),
Discount = g.Max(o => o.Customer.Discount),
Amount = g.Sum(o => o.Amount),
});
```

Produces:

```sql
SELECT [o].[CustomerId] AS [Key], MAX([c].[Region]) AS [Region], MAX([c].[Discount]) AS [Discount], COALESCE(SUM([o].[Amount]), 0.0) AS [Amount]
FROM [Orders] AS [o]
INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
GROUP BY [o].[CustomerId]
```

while

```csharp
db.Orders
.GroupBy(o => o.CustomerId, o => new { o.Customer.Region, o.Customer.Discount, o.Amount })
.Select(g => new
{
g.Key,
Region = g.Max(x => x.Region),
Discount = g.Max(x => x.Discount),
Amount = g.Sum(x => x.Amount),
});
```

produces:

```sql
SELECT [o].[CustomerId] AS [Key], (
SELECT MAX([c].[Region])
FROM [Orders] AS [o0]
INNER JOIN [Customers] AS [c] ON [o0].[CustomerId] = [c].[Id]
WHERE [o].[CustomerId] = [o0].[CustomerId]) AS [Region], (
SELECT MAX([c0].[Discount])
FROM [Orders] AS [o1]
INNER JOIN [Customers] AS [c0] ON [o1].[CustomerId] = [c0].[Id]
WHERE [o].[CustomerId] = [o1].[CustomerId]) AS [Discount], COALESCE(SUM([o].[Amount]), 0.0) AS [Amount]
FROM [Orders] AS [o]
GROUP BY [o].[CustomerId]
```

### Your code

```csharp
using Microsoft.EntityFrameworkCore;

using var db = new AppContext();

Print("1. aggregates over a reference navigation",
db.Orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
g.Key,
Region = g.Max(o => o.Customer.Region),
Discount = g.Max(o => o.Customer.Discount),
Amount = g.Sum(o => o.Amount),
}));

Print("2. the same aggregates, reached through a GroupBy element selector",
db.Orders
.GroupBy(o => o.CustomerId, o => new { o.Customer.Region, o.Customer.Discount, o.Amount })
.Select(g => new
{
g.Key,
Region = g.Max(x => x.Region),
Discount = g.Max(x => x.Discount),
Amount = g.Sum(x => x.Amount),
}));

static void Print(string title, IQueryable query)
{
var sql = query.ToQueryString();
var selects = System.Text.RegularExpressions.Regex.Matches(sql, @"\bSELECT\b").Count;
Console.WriteLine($"===== {title} [{selects} SELECT(s)] =====");
Console.WriteLine(sql);
Console.WriteLine();
}

public class Customer
{
public int Id { get; set; }
public string Region { get; set; } = null!;
public decimal Discount { get; set; }
}

public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public decimal Amount { get; set; }
public decimal? Freight { get; set; }
}

public class AppContext : DbContext
{
public DbSet Customers => Set();
public DbSet Orders => Set();

protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlServer("Server=localhost;Database=Repro;Trusted_Connection=True;TrustServerCertificate=True");
}
```

### Stack traces

```text

```

### Verbose output

```text

```

### EF Core version

11.0.0-rc.1.26410.101

### Database provider

Microsoft.EntityFrameworkCore.SqlServer

### Target framework

.NET 11

### Operating system

Windows 11

### IDE

_No response_

Contributor guide

Open the contributing guide

Research direction

Start with the two GroupBy LINQ queries and their ToQueryString output, comparing the join-based translation with the correlated subqueries produced for the element selector. Trace the GroupBy query translation entry points and add coverage for the SQL shape; done means reference-navigation aggregates use a single joined SELECT like the equivalent query.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, sql
Domain
database
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.