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..c160db18 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.VendorTests/EFCoreBulkExtensionsCompatibilityTests.cs @@ -0,0 +1,105 @@ +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 +{ + private 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 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 + + // 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] + 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 + + 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/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..c015e4d3 --- /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] read-only + /// 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. + private 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(); + } +}