Skip to content

Commit 2f14a3a

Browse files
authored
Merge pull request #218 from EFNext/claude/github-issue-217-9odp2y
Fix duplicate joins when materializing writable projectables with includes
2 parents a23efdf + 67209c4 commit 2f14a3a

8 files changed

Lines changed: 182 additions & 13 deletions

‎src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs‎

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -341,15 +341,14 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType
341341
return node;
342342
}
343343

344-
var properties = entityType.GetProperties()
344+
var members = entityType.GetProperties()
345345
.Where(x => !x.IsShadowProperty())
346-
.Select(x => x.GetMemberInfo(false, false))
346+
.Cast<IPropertyBase>()
347347
.Concat(entityType.GetNavigations()
348-
.Where(x => !x.IsShadowProperty())
349-
.Select(x => x.GetMemberInfo(false, false)))
348+
.Where(x => !x.IsShadowProperty()))
350349
.Concat(entityType.GetSkipNavigations()
351-
.Where(x => !x.IsShadowProperty())
352-
.Select(x => x.GetMemberInfo(false, false)))
350+
.Where(x => !x.IsShadowProperty()))
351+
.Select(x => x.GetMemberInfo(false, false))
353352
// Remove projectable properties from the ef properties. Since properties returned here for auto
354353
// properties (like `public string Test {get;set;}`) are generated fields, we also need to take them into account.
355354
.Where(x => projectableProperties.All(y => x.Name != y.Name && x.Name != $"<{y.Name}>k__BackingField"));
@@ -364,7 +363,7 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType
364363
Expression.Lambda(
365364
Expression.MemberInit(
366365
Expression.New(entityType.ClrType),
367-
properties.Select(x => Expression.Bind(x, Expression.MakeMemberAccess(xParam, x)))
366+
members.Select(x => _MakeBinding(xParam, x))
368367
.Concat(projectableProperties
369368
.Select(x => Expression.Bind(x, _GetAccessor(x, xParam)))
370369
)
@@ -374,6 +373,59 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType
374373
);
375374
}
376375

376+
// Builds the member binding used to copy an EF-mapped member into the re-projected entity.
377+
//
378+
// EF's member metadata resolves auto-properties to their compiler-generated backing field.
379+
// Reading a mapped scalar or navigation through that backing field prevents EF's navigation
380+
// expansion from recognizing it as a plain column/navigation, so it instead materializes the
381+
// whole source entity — including any Include-ed collection navigations — once for every
382+
// rewritten member. That produces one duplicate JOIN per member (see issue #217).
383+
//
384+
// Reading through the CLR property lets EF treat scalars as columns and collapse a collection
385+
// navigation into a single join. When the property is also settable we bind to the property
386+
// itself so the generated projection keeps the natural member/column names instead of the
387+
// backing-field name.
388+
private static MemberAssignment _MakeBinding(ParameterExpression para, MemberInfo member)
389+
{
390+
var property = _ResolveClrProperty(member);
391+
if (property is not null && property.CanRead)
392+
{
393+
var read = Expression.MakeMemberAccess(para, property);
394+
return property.CanWrite
395+
? Expression.Bind(property, read)
396+
: Expression.Bind(member, read);
397+
}
398+
399+
return Expression.Bind(member, Expression.MakeMemberAccess(para, member));
400+
}
401+
402+
// Resolves the CLR property backing an EF member, whether EF handed us the property directly
403+
// or the compiler-generated "<Name>k__BackingField" for an auto-property.
404+
private static PropertyInfo? _ResolveClrProperty(MemberInfo member)
405+
{
406+
switch (member)
407+
{
408+
case PropertyInfo property:
409+
return property;
410+
case FieldInfo { Name: ['<', ..] } field when field.DeclaringType is { } declaringType:
411+
var closingBracket = field.Name.IndexOf('>');
412+
if (closingBracket > 1)
413+
{
414+
var propertyName = field.Name.Substring(1, closingBracket - 1);
415+
var property = declaringType.GetProperty(propertyName,
416+
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
417+
if (property is not null && property.PropertyType == field.FieldType)
418+
{
419+
return property;
420+
}
421+
}
422+
423+
return null;
424+
default:
425+
return null;
426+
}
427+
}
428+
377429
private Expression _GetAccessor(PropertyInfo property, ParameterExpression para)
378430
{
379431
var lambda = _resolver.FindGeneratedExpression(property);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel.DataAnnotations.Schema;
4+
using System.Linq;
5+
using Microsoft.EntityFrameworkCore;
6+
using Xunit;
7+
8+
namespace EntityFrameworkCore.Projectables.FunctionalTests
9+
{
10+
// https://github.com/EFNext/EntityFrameworkCore.Projectables/issues/217
11+
//
12+
// A writable [Projectable] property triggers a whole-entity re-projection so the property can be
13+
// populated during materialization. When the entity also has a collection navigation that is
14+
// Include-d, that re-projection previously read every other member through its backing field,
15+
// which forced EF to re-materialize the whole entity (and its Include-d collection) once per
16+
// member -- producing one duplicate JOIN for every rewritten member.
17+
public class Issue217Tests
18+
{
19+
public class Entity1
20+
{
21+
public virtual int Id { get; set; }
22+
public virtual string? Name1 { get; set; }
23+
24+
[NotMapped]
25+
[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore, UseMemberBody = nameof(Name1LenQuery))]
26+
public virtual int? Name1Len { get; set; }
27+
28+
protected virtual int? Name1LenQuery => Name1 == null ? (int?)null : Name1.Length;
29+
30+
public virtual List<Entity2> Entity2s { get; set; } = new();
31+
}
32+
33+
public class Entity2
34+
{
35+
public virtual int Id { get; set; }
36+
public virtual string? Name2 { get; set; }
37+
38+
public virtual int? Entity1Id { get; set; }
39+
public virtual Entity1? Entity1 { get; set; }
40+
}
41+
42+
public class DemoDbContext : DbContext
43+
{
44+
private readonly string _dataSource;
45+
46+
public DemoDbContext(string dataSource) => _dataSource = dataSource;
47+
48+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
49+
{
50+
optionsBuilder.UseSqlite($"Data Source={_dataSource}");
51+
optionsBuilder.UseProjectables();
52+
}
53+
54+
protected override void OnModelCreating(ModelBuilder modelBuilder)
55+
{
56+
modelBuilder.Entity<Entity1>();
57+
modelBuilder.Entity<Entity2>();
58+
}
59+
60+
public DbSet<Entity1> Entity1s => Set<Entity1>();
61+
public DbSet<Entity2> Entity2s => Set<Entity2>();
62+
}
63+
64+
[Fact]
65+
public void IncludingCollectionDoesNotProduceDuplicateJoins()
66+
{
67+
using var context = new DemoDbContext("issue217-sql.sqlite");
68+
69+
var sql = context.Entity1s
70+
.AsNoTrackingWithIdentityResolution()
71+
.Include(e1 => e1.Entity2s)
72+
.ToQueryString();
73+
74+
var joinCount = CountOccurrences(sql, "JOIN");
75+
76+
Assert.True(joinCount <= 1, $"Expected at most one JOIN but found {joinCount}.{Environment.NewLine}{sql}");
77+
}
78+
79+
[Fact]
80+
public void PopulatesWritablePropertyAndLoadsCollection()
81+
{
82+
using var context = new DemoDbContext("issue217-exec.sqlite");
83+
context.Database.EnsureDeleted();
84+
context.Database.EnsureCreated();
85+
86+
var entity = new Entity1 { Name1 = "hello" };
87+
entity.Entity2s.Add(new Entity2 { Name2 = "a" });
88+
entity.Entity2s.Add(new Entity2 { Name2 = "b" });
89+
context.Add(entity);
90+
context.SaveChanges();
91+
context.ChangeTracker.Clear();
92+
93+
var loaded = context.Entity1s
94+
.AsNoTrackingWithIdentityResolution()
95+
.Include(e1 => e1.Entity2s)
96+
.Single();
97+
98+
Assert.Equal(5, loaded.Name1Len);
99+
Assert.Equal(2, loaded.Entity2s.Count);
100+
101+
context.Database.EnsureDeleted();
102+
}
103+
104+
private static int CountOccurrences(string haystack, string needle)
105+
{
106+
var count = 0;
107+
var index = 0;
108+
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
109+
{
110+
count++;
111+
index += needle.Length;
112+
}
113+
114+
return count;
115+
}
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SELECT [e].[Id], [e].[Id] * 5
1+
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
22
FROM [Entity] AS [e]
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SELECT [e].[Id], [e].[Id] * 5
1+
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
22
FROM [Entity] AS [e]
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SELECT [e].[Id], [e].[Id] * 5
1+
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
22
FROM [Entity] AS [e]
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SELECT [e].[Id], [e].[Id] * 5
1+
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
22
FROM [Entity] AS [e]
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SELECT [e].[Id], [e].[Id] * 5
1+
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
22
FROM [Entity] AS [e]
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SELECT [e].[Id], [e].[Id] * 5
1+
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
22
FROM [Entity] AS [e]

0 commit comments

Comments
 (0)