dotnet / dotnet/efcore

Don't repeat joins when using multiple Selects in a custom projection on the same entity

Open
#33,608 2 comments 1 reaction 0 assignees View on GitHub
area-perf area-query customer-reported
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

### Current situation

Currently I have an EF Query built like this:

```csharp
var query = GetOffersQuery(identities, loggedUser)
.AsNoTracking();

var dtoQuery = query.Select(x => new OfferDTO
{
Status = x.Status,
CalculationDate = x.CalculationDate,
CreatedDate = x.CreatedDate,
ProductNames = x.Components.Select(c => c.ProductName),
Quotations = x.Components.Select(c => c.Header.SalesDoc),
BatteryTypes = x.Components.Select(c => c.BatteryType),
ApproverName = x.Approver!.DisplayName,
CustomerName = x.Customer.Name,
Id = x.Id,
SalesmanName = x.Salesman!.DisplayName,
UploaderName = x.Uploader!.DisplayName,
OpportunityNumber = x.OpportunityNumber,
CountryCode = x.CountryCode
})
.AsSingleQuery();
```

Notice that `ProductNames`, `Quotations` and `BatteryTypes` all refers to the `Components` entity. The `Components` entity is alread `Include`d inside `GetOffersQuery`.

This results in the following SQL Server query being generated:

```sql
SELECT [t].[Status], [t].[CalculationDate], [t].[CreatedDate], [t].[Id], [u].[CountryCode], [u].[UserPrincipalName], [u0].[CountryCode], [u0].[UserPrincipalName], [u1].[CountryCode], [u1].[UserPrincipalName], [o1].[ProductName], [o1].[Id], [o2].[Header_SalesDoc], [o2].[Id], [o3].[BatteryType], [o3].[Id], [u].[DisplayName], [t].[Customer_Name], [u0].[DisplayName], [u1].[DisplayName], [t].[OpportunityNumber], [t].[CountryCode]
FROM (
SELECT TOP(@__p_1) [o].[Id], [o].[ApprovedBy], [o].[CalculationDate], [o].[CountryCode], [o].[CreatedBy], [o].[CreatedDate], [o].[OpportunityNumber], [o].[SalesEmployee], [o].[Status], [o].[Customer_Name]
FROM [OfferHeader] AS [o]
WHERE ([o].[CountryCode] IN (...) OR EXISTS (
SELECT 1
FROM [OfferGrantedUsers] AS [o0]
WHERE [o0].[OfferId] = [o].[Id] AND [o0].[UserPrincipalName] = @__loggedUser_0)) AND [o].[Deleted] = CAST(0 AS bit)
ORDER BY [o].[CalculationDate] DESC, [o].[Id] DESC
) AS [t]
LEFT JOIN [UserHierarchyRoles] AS [u] ON [t].[CountryCode] = [u].[CountryCode] AND [t].[ApprovedBy] = [u].[UserPrincipalName]
INNER JOIN [UserHierarchyRoles] AS [u0] ON [t].[CountryCode] = [u0].[CountryCode] AND [t].[SalesEmployee] = [u0].[UserPrincipalName]
INNER JOIN [UserHierarchyRoles] AS [u1] ON [t].[CountryCode] = [u1].[CountryCode] AND [t].[CreatedBy] = [u1].[UserPrincipalName]
LEFT JOIN [OfferComponent] AS [o1] ON [t].[Id] = [o1].[OfferId]
LEFT JOIN [OfferComponent] AS [o2] ON [t].[Id] = [o2].[OfferId]
LEFT JOIN [OfferComponent] AS [o3] ON [t].[Id] = [o3].[OfferId]
ORDER BY [t].[CalculationDate] DESC, [t].[Id] DESC, [u].[CountryCode], [u].[UserPrincipalName], [u0].[CountryCode], [u0].[UserPrincipalName], [u1].[CountryCode], [u1].[UserPrincipalName], [o1].[Id], [o2].[Id]
```

Notice that `OfferComponent` table gets added once for every `Select`. In fact, if I remove one or more `Select`s inside the projection, the number of joins adjust accordingly.

### Expected result

I would expect EF (or SqlClient) to only join the table once, since all 3 Selects are based on the same entity. The query I expect is the following:

```sql
SELECT [t].[Status], [t].[CalculationDate], [t].[CreatedDate], [t].[Id], [u].[CountryCode], [u].[UserPrincipalName], [u0].[CountryCode], [u0].[UserPrincipalName], [u1].[CountryCode], [u1].[UserPrincipalName], [o1].[ProductName], [o1].[Id], [o1].[Header_SalesDoc], [o1].[BatteryType], [u].[DisplayName], [t].[Customer_Name], [u0].[DisplayName], [u1].[DisplayName], [t].[OpportunityNumber], [t].[CountryCode]
FROM (
SELECT TOP(@__p_1) [o].[Id], [o].[ApprovedBy], [o].[CalculationDate], [o].[CountryCode], [o].[CreatedBy], [o].[CreatedDate], [o].[OpportunityNumber], [o].[SalesEmployee], [o].[Status], [o].[Customer_Name]
FROM [OfferHeader] AS [o]
WHERE ([o].[CountryCode] IN (...) OR EXISTS (
SELECT 1
FROM [OfferGrantedUsers] AS [o0]
WHERE [o0].[OfferId] = [o].[Id] AND [o0].[UserPrincipalName] = @__loggedUser_0)) AND [o].[Deleted] = CAST(0 AS bit)
ORDER BY [o].[CalculationDate] DESC, [o].[Id] DESC
) AS [t]
LEFT JOIN [UserHierarchyRoles] AS [u] ON [t].[CountryCode] = [u].[CountryCode] AND [t].[ApprovedBy] = [u].[UserPrincipalName]
INNER JOIN [UserHierarchyRoles] AS [u0] ON [t].[CountryCode] = [u0].[CountryCode] AND [t].[SalesEmployee] = [u0].[UserPrincipalName]
INNER JOIN [UserHierarchyRoles] AS [u1] ON [t].[CountryCode] = [u1].[CountryCode] AND [t].[CreatedBy] = [u1].[UserPrincipalName]
LEFT JOIN [OfferComponent] AS [o1] ON [t].[Id] = [o1].[OfferId]
ORDER BY [t].[CalculationDate] DESC, [t].[Id] DESC, [u].[CountryCode], [u].[UserPrincipalName], [u0].[CountryCode], [u0].[UserPrincipalName], [u1].[CountryCode], [u1].[UserPrincipalName], [o1].[Id]
```

This can benefit by reducing the number of reduntant joins.

I know that one way to fix it would be to project the 3 properties as a single DTO with 3 properties, to enumerate `Components` once, but this changes the output object definition (and in my case the objective of the DTO projection is exactly to have a flat object)

All tests have been done on:
- EF Core 8.0.4
- Microsoft.Data.SqlClient 5.1.5

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.