From 38619af4a06bbdf66902668c874a39508dc5d268 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:36:40 +0000 Subject: [PATCH 1/4] Initial plan From 0d80766a107f09ea5c176567aef1d0545db91f85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:08:15 +0000 Subject: [PATCH 2/4] Fix CustomQueryCompiler reflection compatibility with EFCore.BulkExtensions; add VendorTests project Agent-Logs-Url: https://github.com/EFNext/EntityFrameworkCore.Projectables/sessions/a64490fa-46e4-49f0-8bf9-d507f70ef85d Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- Directory.Packages.props | 1 + EntityFrameworkCore.Projectables.sln | 15 +++ .../Internal/CustomQueryCompiler.cs | 13 ++ .../EFCoreBulkExtensionsCompatibilityTests.cs | 124 ++++++++++++++++++ ...meworkCore.Projectables.VendorTests.csproj | 34 +++++ .../TestContext.cs | 62 +++++++++ 6 files changed, 249 insertions(+) create mode 100644 tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs create mode 100644 tests/EntityFrameworkCore.Projectables.VendorTests/EntityFrameworkCore.Projectables.VendorTests.csproj create mode 100644 tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ff81c4d0..a45f5ddb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/EntityFrameworkCore.Projectables.sln b/EntityFrameworkCore.Projectables.sln index c30754a9..e9d4236a 100644 --- a/EntityFrameworkCore.Projectables.sln +++ b/EntityFrameworkCore.Projectables.sln @@ -58,6 +58,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Project EndProject 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}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Projectables.VendorTests", "tests\EntityFrameworkCore.Projectables.VendorTests\EntityFrameworkCore.Projectables.VendorTests.csproj", "{DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -188,6 +190,18 @@ Global {C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}.Release|x64.Build.0 = Release|Any CPU {C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}.Release|x86.ActiveCfg = Release|Any CPU {C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476}.Release|x86.Build.0 = Release|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x64.Build.0 = Debug|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Debug|x86.Build.0 = Debug|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|Any CPU.Build.0 = Release|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x64.ActiveCfg = Release|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x64.Build.0 = Release|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x86.ActiveCfg = Release|Any CPU + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -204,6 +218,7 @@ Global {31596010-788E-434F-BF00-4EBC6E232822} = {C95A2C5D-4A3B-440C-A703-2D5892ABA7FE} {1890C6AF-37A4-40B0-BD0C-7FB18357891A} = {A43F1828-D9B6-40F7-82B6-CA0070843E2F} {C62B59E5-A32F-4CB5-ADB1-B3D03BBC8476} = {F5E4436F-87F2-46AB-A9EB-59B4BF21BF7A} + {DB9E2E17-1CCD-4ADD-B910-D80530C9AA25} = {F5E4436F-87F2-46AB-A9EB-59B4BF21BF7A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D17BD356-592C-4628-9D81-A04E24FF02F3} diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs index 01bd0d0f..f8a206e9 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs @@ -26,6 +26,18 @@ public sealed class CustomQueryCompiler : QueryCompiler readonly IQueryCompiler _decoratedQueryCompiler; readonly ProjectableExpressionReplacer _projectableExpressionReplacer; + // This field intentionally shadows the private field of the same name in QueryCompiler. + // Some third-party libraries (e.g. EFCore.BulkExtensions) discover the DbContext by + // calling obj.GetType().GetField("_queryContextFactory", BindingFlags.Instance | BindingFlags.NonPublic) + // on the IQueryCompiler instance. Because C# reflection does not surface private fields + // declared in a base class when searching a derived type, without this shadow field the + // lookup returns null and causes a TargetException ("Non-static method requires a target") + // in those libraries. Storing the same value here makes the field discoverable via + // reflection regardless of which type the caller starts from. +#pragma warning disable IDE0052 // Remove unread private members + private readonly IQueryContextFactory _queryContextFactory; +#pragma warning restore IDE0052 + public CustomQueryCompiler(IQueryCompiler decoratedQueryCompiler, IQueryContextFactory queryContextFactory, ICompiledQueryCache compiledQueryCache, @@ -44,6 +56,7 @@ public CustomQueryCompiler(IQueryCompiler decoratedQueryCompiler, evaluatableExpressionFilter, model) { + _queryContextFactory = queryContextFactory; _decoratedQueryCompiler = decoratedQueryCompiler; var trackingByDefault = (contextOptions.FindExtension()?.QueryTrackingBehavior ?? QueryTrackingBehavior.TrackAll) == QueryTrackingBehavior.TrackAll; diff --git a/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs b/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs new file mode 100644 index 00000000..d0a86bcb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs @@ -0,0 +1,124 @@ +using EFCore.BulkExtensions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Projectables.VendorTests; + +/// +/// Tests that verify Projectables is compatible with EFCore.BulkExtensions batch +/// delete/update operations. +/// +/// Background: EFCore.BulkExtensions' BatchUtil.GetDbContext discovers the +/// DbContext via reflection by accessing the IQueryCompiler instance stored inside +/// EntityQueryProvider and then reading its private _queryContextFactory field. +/// Because C# reflection does not surface private fields from base classes when +/// GetField is called on a derived type, without an explicit shadow field in +/// CustomQueryCompiler the lookup returns null and the next GetValue(null) +/// call throws a TargetException ("Non-static method requires a target"). +/// The shadow field added to CustomQueryCompiler fixes this. +/// +public class EFCoreBulkExtensionsCompatibilityTests : IDisposable +{ + readonly TestDbContext _context; + + public EFCoreBulkExtensionsCompatibilityTests() + { + _context = new TestDbContext(); + _context.Database.EnsureCreated(); + _context.SeedData(); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public void GetDbContext_WithProjectablesEnabled_DoesNotThrow() + { + // Arrange + var query = _context.Set().Where(o => o.IsCompleted); + + // Act – BatchUtil.GetDbContext is the method that was previously throwing + // "Non-static method requires a target" because _queryContextFactory was not + // discoverable via reflection on CustomQueryCompiler. + var exception = Record.Exception(() => BatchUtil.GetDbContext(query)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void GetDbContext_WithProjectablesEnabled_ReturnsCorrectContext() + { + // Arrange + var query = _context.Set().Where(o => o.IsCompleted); + + // Act + var dbContext = BatchUtil.GetDbContext(query); + + // Assert – must return the same DbContext, not null + Assert.NotNull(dbContext); + Assert.Same(_context, dbContext); + } + + [Fact] + public void GetDbContext_WithProjectableProperty_DoesNotThrow() + { + // Arrange – entity with a [Projectable] property so that CustomQueryCompiler is + // exercised with actual projectable expression expansion. + var query = _context.Set().Where(o => o.IsCompleted); + + // Act + var exception = Record.Exception(() => BatchUtil.GetDbContext(query)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public async Task BatchDeleteAsync_WithProjectablesEnabled_DoesNotThrowTargetException() + { + // Arrange + var query = _context.Set().Where(o => o.IsCompleted); + + // Act – previously this would throw TargetException with message + // "Non-static method requires a target" when Projectables 3.x was used. +#pragma warning disable CS0618 // BatchDeleteAsync is marked obsolete in favour of EF 7 ExecuteDeleteAsync, but we + // specifically need to test EFCore.BulkExtensions' own batch path. + var exception = await Record.ExceptionAsync( + () => query.BatchDeleteAsync(TestContext.Current.CancellationToken)); +#pragma warning restore CS0618 + + // Assert – a TargetException means the reflection-based DbContext discovery + // inside EFCore.BulkExtensions failed. All other exceptions (e.g. SQL syntax + // differences on SQLite) are acceptable because they come from actual SQL + // execution, not from the broken reflection chain. + Assert.False( + exception is System.Reflection.TargetException, + $"BatchDeleteAsync threw TargetException: {exception?.Message}"); + Assert.False( + exception?.Message?.Contains("Non-static method requires a target") == true, + $"BatchDeleteAsync threw 'Non-static method requires a target': {exception?.Message}"); + } + + [Fact] + public async Task BatchUpdateAsync_WithProjectablesEnabled_DoesNotThrowTargetException() + { + // Arrange + var query = _context.Set().Where(o => o.IsCompleted); + + // Act +#pragma warning disable CS0618 // BatchUpdateAsync is marked obsolete in favour of EF 7 ExecuteUpdateAsync + var exception = await Record.ExceptionAsync( + () => query.BatchUpdateAsync( + o => new Order { Total = o.Total * 2 }, + cancellationToken: TestContext.Current.CancellationToken)); +#pragma warning restore CS0618 + + // Assert – same as above: only TargetException is a regression. + Assert.False( + exception is System.Reflection.TargetException, + $"BatchUpdateAsync threw TargetException: {exception?.Message}"); + Assert.False( + exception?.Message?.Contains("Non-static method requires a target") == true, + $"BatchUpdateAsync threw 'Non-static method requires a target': {exception?.Message}"); + } +} diff --git a/tests/EntityFrameworkCore.Projectables.VendorTests/EntityFrameworkCore.Projectables.VendorTests.csproj b/tests/EntityFrameworkCore.Projectables.VendorTests/EntityFrameworkCore.Projectables.VendorTests.csproj new file mode 100644 index 00000000..0eebdfaf --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/EntityFrameworkCore.Projectables.VendorTests.csproj @@ -0,0 +1,34 @@ + + + + + net8.0 + false + enable + + 12.0 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs b/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs new file mode 100644 index 00000000..6238e089 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs @@ -0,0 +1,62 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.Projectables.VendorTests; + +/// Order entity used in vendor-compatibility tests. +public class Order +{ + public int Id { get; set; } + public string? CustomerName { get; set; } + public decimal Total { get; set; } + public bool IsCompleted { get; set; } + + /// + /// A computed projectable property. Having at least one [Projectable] writable + /// property on the entity ensures that CustomQueryCompiler is exercised + /// (it expands the projectable reference and potentially adds a Select wrapper). + /// + [Projectable] + public bool IsLargeOrder => Total > 100; +} + +public class TestDbContext : DbContext +{ + // Keep the connection open for the lifetime of the context so the in-memory + // SQLite database is not destroyed between operations. + readonly SqliteConnection _connection; + + public TestDbContext() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + } + + public DbSet Orders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlite(_connection); + optionsBuilder.UseProjectables(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + } + + public void SeedData() + { + Orders.AddRange( + new Order { CustomerName = "Alice", Total = 50m, IsCompleted = false }, + new Order { CustomerName = "Bob", Total = 150m, IsCompleted = true }, + new Order { CustomerName = "Charlie", Total = 200m, IsCompleted = true }); + SaveChanges(); + } + + public override void Dispose() + { + base.Dispose(); + _connection.Dispose(); + } +} From 68c6c6e65384e67fe2c1f86f5b8c2a9d7b4573bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:11:33 +0000 Subject: [PATCH 3/4] Address code review feedback: explicit private modifiers, helper method, remove duplicate test Agent-Logs-Url: https://github.com/EFNext/EntityFrameworkCore.Projectables/sessions/a64490fa-46e4-49f0-8bf9-d507f70ef85d Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../EFCoreBulkExtensionsCompatibilityTests.cs | 45 ++++++------------- .../TestContext.cs | 2 +- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs b/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs index d0a86bcb..c160db18 100644 --- a/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs @@ -19,7 +19,7 @@ namespace EntityFrameworkCore.Projectables.VendorTests; /// public class EFCoreBulkExtensionsCompatibilityTests : IDisposable { - readonly TestDbContext _context; + private readonly TestDbContext _context; public EFCoreBulkExtensionsCompatibilityTests() { @@ -59,20 +59,6 @@ public void GetDbContext_WithProjectablesEnabled_ReturnsCorrectContext() Assert.Same(_context, dbContext); } - [Fact] - public void GetDbContext_WithProjectableProperty_DoesNotThrow() - { - // Arrange – entity with a [Projectable] property so that CustomQueryCompiler is - // exercised with actual projectable expression expansion. - var query = _context.Set().Where(o => o.IsCompleted); - - // Act - var exception = Record.Exception(() => BatchUtil.GetDbContext(query)); - - // Assert - Assert.Null(exception); - } - [Fact] public async Task BatchDeleteAsync_WithProjectablesEnabled_DoesNotThrowTargetException() { @@ -87,16 +73,11 @@ public async Task BatchDeleteAsync_WithProjectablesEnabled_DoesNotThrowTargetExc () => query.BatchDeleteAsync(TestContext.Current.CancellationToken)); #pragma warning restore CS0618 - // Assert – a TargetException means the reflection-based DbContext discovery - // inside EFCore.BulkExtensions failed. All other exceptions (e.g. SQL syntax - // differences on SQLite) are acceptable because they come from actual SQL - // execution, not from the broken reflection chain. - Assert.False( - exception is System.Reflection.TargetException, - $"BatchDeleteAsync threw TargetException: {exception?.Message}"); - Assert.False( - exception?.Message?.Contains("Non-static method requires a target") == true, - $"BatchDeleteAsync threw 'Non-static method requires a target': {exception?.Message}"); + // A TargetException means the reflection-based DbContext discovery inside + // EFCore.BulkExtensions failed. Other exceptions (e.g. SQL syntax differences + // on SQLite) are acceptable because they come from actual SQL execution, not + // from the broken reflection chain. + AssertNoTargetException(exception, "BatchDeleteAsync"); } [Fact] @@ -113,12 +94,12 @@ public async Task BatchUpdateAsync_WithProjectablesEnabled_DoesNotThrowTargetExc cancellationToken: TestContext.Current.CancellationToken)); #pragma warning restore CS0618 - // Assert – same as above: only TargetException is a regression. - Assert.False( - exception is System.Reflection.TargetException, - $"BatchUpdateAsync threw TargetException: {exception?.Message}"); - Assert.False( - exception?.Message?.Contains("Non-static method requires a target") == true, - $"BatchUpdateAsync threw 'Non-static method requires a target': {exception?.Message}"); + AssertNoTargetException(exception, "BatchUpdateAsync"); } + + private static void AssertNoTargetException(Exception? exception, string operationName) + => Assert.False( + exception is System.Reflection.TargetException, + $"{operationName} threw TargetException (\"Non-static method requires a target\"). " + + $"This indicates that CustomQueryCompiler's _queryContextFactory shadow field is missing."); } diff --git a/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs b/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs index 6238e089..5747f029 100644 --- a/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs @@ -24,7 +24,7 @@ public class TestDbContext : DbContext { // Keep the connection open for the lifetime of the context so the in-memory // SQLite database is not destroyed between operations. - readonly SqliteConnection _connection; + private readonly SqliteConnection _connection; public TestDbContext() { From cd26a771d1273d65d26be676b0be2fcc84b6d493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Sat, 11 Apr 2026 11:55:42 +0200 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../EntityFrameworkCore.Projectables.VendorTests/TestContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs b/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs index 5747f029..c015e4d3 100644 --- a/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs @@ -12,7 +12,7 @@ public class Order public bool IsCompleted { get; set; } /// - /// A computed projectable property. Having at least one [Projectable] writable + /// A computed projectable property. Having at least one [Projectable] read-only /// property on the entity ensures that CustomQueryCompiler is exercised /// (it expands the projectable reference and potentially adds a Select wrapper). ///