Skip to content

Commit fabb896

Browse files
authored
Merge pull request #200 from EFNext/copilot/update-entityframeworkcore-projectables
Fix EFCore.BulkExtensions compatibility: shadow `_queryContextFactory` in `CustomQueryCompiler`
2 parents 3b8f603 + cd26a77 commit fabb896

6 files changed

Lines changed: 230 additions & 0 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
</ItemGroup>
2323
<ItemGroup>
2424
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
25+
<PackageVersion Include="EFCore.BulkExtensions" Version="8.0.4" />
2526
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
2627
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
2728
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />

EntityFrameworkCore.Projectables.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Project
5858
EndProject
5959
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Projectables.CodeFixes.Tests", "tests\EntityFrameworkCore.Projectables.CodeFixes.Tests\EntityFrameworkCore.Projectables.CodeFixes.Tests.csproj", "{C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}"
6060
EndProject
61+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Projectables.VendorTests", "tests\EntityFrameworkCore.Projectables.VendorTests\EntityFrameworkCore.Projectables.VendorTests.csproj", "{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}"
62+
EndProject
6163
Global
6264
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6365
Debug|Any CPU = Debug|Any CPU
@@ -188,6 +190,18 @@ Global
188190
{C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}.Release|x64.Build.0 = Release|Any CPU
189191
{C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}.Release|x86.ActiveCfg = Release|Any CPU
190192
{C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}.Release|x86.Build.0 = Release|Any CPU
193+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
194+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|Any CPU.Build.0 = Debug|Any CPU
195+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x64.ActiveCfg = Debug|Any CPU
196+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x64.Build.0 = Debug|Any CPU
197+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x86.ActiveCfg = Debug|Any CPU
198+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x86.Build.0 = Debug|Any CPU
199+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|Any CPU.ActiveCfg = Release|Any CPU
200+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|Any CPU.Build.0 = Release|Any CPU
201+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x64.ActiveCfg = Release|Any CPU
202+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x64.Build.0 = Release|Any CPU
203+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x86.ActiveCfg = Release|Any CPU
204+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x86.Build.0 = Release|Any CPU
191205
EndGlobalSection
192206
GlobalSection(SolutionProperties) = preSolution
193207
HideSolutionNode = FALSE
@@ -204,6 +218,7 @@ Global
204218
{31596010-788E-434F-BF00-4EBC6E232822} = {C95A2C5D-4A3B-440C-A703-2D5892ABA7FE}
205219
{1890C6AF-37A4-40B0-BD0C-7FB18357891A} = {A43F1828-D9B6-40F7-82B6-CA0070843E2F}
206220
{C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476} = {F5E4436F-87F2-46AB-A9EB-59B4BF21BF7A}
221+
{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25} = {F5E4436F-87F2-46AB-A9EB-59B4BF21BF7A}
207222
EndGlobalSection
208223
GlobalSection(ExtensibilityGlobals) = postSolution
209224
SolutionGuid = {D17BD356-592C-4628-9D81-A04E24FF02F3}

src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ public sealed class CustomQueryCompiler : QueryCompiler
2626
readonly IQueryCompiler _decoratedQueryCompiler;
2727
readonly ProjectableExpressionReplacer _projectableExpressionReplacer;
2828

29+
// This field intentionally shadows the private field of the same name in QueryCompiler.
30+
// Some third-party libraries (e.g. EFCore.BulkExtensions) discover the DbContext by
31+
// calling obj.GetType().GetField("_queryContextFactory", BindingFlags.Instance | BindingFlags.NonPublic)
32+
// on the IQueryCompiler instance. Because C# reflection does not surface private fields
33+
// declared in a base class when searching a derived type, without this shadow field the
34+
// lookup returns null and causes a TargetException ("Non-static method requires a target")
35+
// in those libraries. Storing the same value here makes the field discoverable via
36+
// reflection regardless of which type the caller starts from.
37+
#pragma warning disable IDE0052 // Remove unread private members
38+
private readonly IQueryContextFactory _queryContextFactory;
39+
#pragma warning restore IDE0052
40+
2941
public CustomQueryCompiler(IQueryCompiler decoratedQueryCompiler,
3042
IQueryContextFactory queryContextFactory,
3143
ICompiledQueryCache compiledQueryCache,
@@ -44,6 +56,7 @@ public CustomQueryCompiler(IQueryCompiler decoratedQueryCompiler,
4456
evaluatableExpressionFilter,
4557
model)
4658
{
59+
_queryContextFactory = queryContextFactory;
4760
_decoratedQueryCompiler = decoratedQueryCompiler;
4861
var trackingByDefault = (contextOptions.FindExtension<CoreOptionsExtension>()?.QueryTrackingBehavior ?? QueryTrackingBehavior.TrackAll) ==
4962
QueryTrackingBehavior.TrackAll;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using EFCore.BulkExtensions;
2+
using Microsoft.EntityFrameworkCore;
3+
using Xunit;
4+
5+
namespace EntityFrameworkCore.Projectables.VendorTests;
6+
7+
/// <summary>
8+
/// Tests that verify Projectables is compatible with EFCore.BulkExtensions batch
9+
/// delete/update operations.
10+
///
11+
/// Background: EFCore.BulkExtensions' <c>BatchUtil.GetDbContext</c> discovers the
12+
/// DbContext via reflection by accessing the IQueryCompiler instance stored inside
13+
/// EntityQueryProvider and then reading its private <c>_queryContextFactory</c> field.
14+
/// Because C# reflection does not surface private fields from base classes when
15+
/// GetField is called on a derived type, without an explicit shadow field in
16+
/// <c>CustomQueryCompiler</c> the lookup returns null and the next GetValue(null)
17+
/// call throws a <c>TargetException</c> ("Non-static method requires a target").
18+
/// The shadow field added to <c>CustomQueryCompiler</c> fixes this.
19+
/// </summary>
20+
public class EFCoreBulkExtensionsCompatibilityTests : IDisposable
21+
{
22+
private readonly TestDbContext _context;
23+
24+
public EFCoreBulkExtensionsCompatibilityTests()
25+
{
26+
_context = new TestDbContext();
27+
_context.Database.EnsureCreated();
28+
_context.SeedData();
29+
}
30+
31+
public void Dispose() => _context.Dispose();
32+
33+
[Fact]
34+
public void GetDbContext_WithProjectablesEnabled_DoesNotThrow()
35+
{
36+
// Arrange
37+
var query = _context.Set<Order>().Where(o => o.IsCompleted);
38+
39+
// Act – BatchUtil.GetDbContext is the method that was previously throwing
40+
// "Non-static method requires a target" because _queryContextFactory was not
41+
// discoverable via reflection on CustomQueryCompiler.
42+
var exception = Record.Exception(() => BatchUtil.GetDbContext(query));
43+
44+
// Assert
45+
Assert.Null(exception);
46+
}
47+
48+
[Fact]
49+
public void GetDbContext_WithProjectablesEnabled_ReturnsCorrectContext()
50+
{
51+
// Arrange
52+
var query = _context.Set<Order>().Where(o => o.IsCompleted);
53+
54+
// Act
55+
var dbContext = BatchUtil.GetDbContext(query);
56+
57+
// Assert – must return the same DbContext, not null
58+
Assert.NotNull(dbContext);
59+
Assert.Same(_context, dbContext);
60+
}
61+
62+
[Fact]
63+
public async Task BatchDeleteAsync_WithProjectablesEnabled_DoesNotThrowTargetException()
64+
{
65+
// Arrange
66+
var query = _context.Set<Order>().Where(o => o.IsCompleted);
67+
68+
// Act – previously this would throw TargetException with message
69+
// "Non-static method requires a target" when Projectables 3.x was used.
70+
#pragma warning disable CS0618 // BatchDeleteAsync is marked obsolete in favour of EF 7 ExecuteDeleteAsync, but we
71+
// specifically need to test EFCore.BulkExtensions' own batch path.
72+
var exception = await Record.ExceptionAsync(
73+
() => query.BatchDeleteAsync(TestContext.Current.CancellationToken));
74+
#pragma warning restore CS0618
75+
76+
// A TargetException means the reflection-based DbContext discovery inside
77+
// EFCore.BulkExtensions failed. Other exceptions (e.g. SQL syntax differences
78+
// on SQLite) are acceptable because they come from actual SQL execution, not
79+
// from the broken reflection chain.
80+
AssertNoTargetException(exception, "BatchDeleteAsync");
81+
}
82+
83+
[Fact]
84+
public async Task BatchUpdateAsync_WithProjectablesEnabled_DoesNotThrowTargetException()
85+
{
86+
// Arrange
87+
var query = _context.Set<Order>().Where(o => o.IsCompleted);
88+
89+
// Act
90+
#pragma warning disable CS0618 // BatchUpdateAsync is marked obsolete in favour of EF 7 ExecuteUpdateAsync
91+
var exception = await Record.ExceptionAsync(
92+
() => query.BatchUpdateAsync(
93+
o => new Order { Total = o.Total * 2 },
94+
cancellationToken: TestContext.Current.CancellationToken));
95+
#pragma warning restore CS0618
96+
97+
AssertNoTargetException(exception, "BatchUpdateAsync");
98+
}
99+
100+
private static void AssertNoTargetException(Exception? exception, string operationName)
101+
=> Assert.False(
102+
exception is System.Reflection.TargetException,
103+
$"{operationName} threw TargetException (\"Non-static method requires a target\"). " +
104+
$"This indicates that CustomQueryCompiler's _queryContextFactory shadow field is missing.");
105+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<!-- EFCore.BulkExtensions 8.0.4 only supports net8.0, so restrict to a single TFM
5+
and override the global TargetFrameworks set in Directory.Build.props. -->
6+
<TargetFrameworks>net8.0</TargetFrameworks>
7+
<IsPackable>false</IsPackable>
8+
<Nullable>enable</Nullable>
9+
<!-- Suppress the LangVersion warning: Directory.Build.props sets LangVersion=12.0 globally
10+
which is fine for this project. -->
11+
<LangVersion>12.0</LangVersion>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="coverlet.collector">
16+
<PrivateAssets>all</PrivateAssets>
17+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
18+
</PackageReference>
19+
<PackageReference Include="EFCore.BulkExtensions" />
20+
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
21+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
22+
<PackageReference Include="xunit.v3" />
23+
<PackageReference Include="xunit.runner.visualstudio">
24+
<PrivateAssets>all</PrivateAssets>
25+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
26+
</PackageReference>
27+
</ItemGroup>
28+
29+
<ItemGroup>
30+
<ProjectReference Include="..\..\src\EntityFrameworkCore.Projectables.Generator\EntityFrameworkCore.Projectables.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
31+
<ProjectReference Include="..\..\src\EntityFrameworkCore.Projectables\EntityFrameworkCore.Projectables.csproj" />
32+
</ItemGroup>
33+
34+
</Project>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using Microsoft.Data.Sqlite;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace EntityFrameworkCore.Projectables.VendorTests;
5+
6+
/// <summary>Order entity used in vendor-compatibility tests.</summary>
7+
public class Order
8+
{
9+
public int Id { get; set; }
10+
public string? CustomerName { get; set; }
11+
public decimal Total { get; set; }
12+
public bool IsCompleted { get; set; }
13+
14+
/// <summary>
15+
/// A computed projectable property. Having at least one [Projectable] read-only
16+
/// property on the entity ensures that <c>CustomQueryCompiler</c> is exercised
17+
/// (it expands the projectable reference and potentially adds a Select wrapper).
18+
/// </summary>
19+
[Projectable]
20+
public bool IsLargeOrder => Total > 100;
21+
}
22+
23+
public class TestDbContext : DbContext
24+
{
25+
// Keep the connection open for the lifetime of the context so the in-memory
26+
// SQLite database is not destroyed between operations.
27+
private readonly SqliteConnection _connection;
28+
29+
public TestDbContext()
30+
{
31+
_connection = new SqliteConnection("DataSource=:memory:");
32+
_connection.Open();
33+
}
34+
35+
public DbSet<Order> Orders => Set<Order>();
36+
37+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
38+
{
39+
optionsBuilder.UseSqlite(_connection);
40+
optionsBuilder.UseProjectables();
41+
}
42+
43+
protected override void OnModelCreating(ModelBuilder modelBuilder)
44+
{
45+
modelBuilder.Entity<Order>();
46+
}
47+
48+
public void SeedData()
49+
{
50+
Orders.AddRange(
51+
new Order { CustomerName = "Alice", Total = 50m, IsCompleted = false },
52+
new Order { CustomerName = "Bob", Total = 150m, IsCompleted = true },
53+
new Order { CustomerName = "Charlie", Total = 200m, IsCompleted = true });
54+
SaveChanges();
55+
}
56+
57+
public override void Dispose()
58+
{
59+
base.Dispose();
60+
_connection.Dispose();
61+
}
62+
}

0 commit comments

Comments
 (0)