Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
</ItemGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="EFCore.BulkExtensions" Version="8.0.4" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
Expand Down
15 changes: 15 additions & 0 deletions EntityFrameworkCore.Projectables.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,6 +56,7 @@ public CustomQueryCompiler(IQueryCompiler decoratedQueryCompiler,
evaluatableExpressionFilter,
model)
{
_queryContextFactory = queryContextFactory;
_decoratedQueryCompiler = decoratedQueryCompiler;
var trackingByDefault = (contextOptions.FindExtension<CoreOptionsExtension>()?.QueryTrackingBehavior ?? QueryTrackingBehavior.TrackAll) ==
QueryTrackingBehavior.TrackAll;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Xunit;

namespace EntityFrameworkCore.Projectables.VendorTests;

/// <summary>
/// Tests that verify Projectables is compatible with EFCore.BulkExtensions batch
/// delete/update operations.
///
/// Background: EFCore.BulkExtensions' <c>BatchUtil.GetDbContext</c> discovers the
/// DbContext via reflection by accessing the IQueryCompiler instance stored inside
/// EntityQueryProvider and then reading its private <c>_queryContextFactory</c> 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
/// <c>CustomQueryCompiler</c> the lookup returns null and the next GetValue(null)
/// call throws a <c>TargetException</c> ("Non-static method requires a target").
/// The shadow field added to <c>CustomQueryCompiler</c> fixes this.
/// </summary>
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<Order>().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<Order>().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<Order>().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<Order>().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.");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!-- EFCore.BulkExtensions 8.0.4 only supports net8.0, so restrict to a single TFM
and override the global TargetFrameworks set in Directory.Build.props. -->
<TargetFrameworks>net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<!-- Suppress the LangVersion warning: Directory.Build.props sets LangVersion=12.0 globally
which is fine for this project. -->
<LangVersion>12.0</LangVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="EFCore.BulkExtensions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\EntityFrameworkCore.Projectables.Generator\EntityFrameworkCore.Projectables.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\src\EntityFrameworkCore.Projectables\EntityFrameworkCore.Projectables.csproj" />
</ItemGroup>

</Project>
62 changes: 62 additions & 0 deletions tests/EntityFrameworkCore.Projectables.VendorTests/TestContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace EntityFrameworkCore.Projectables.VendorTests;

/// <summary>Order entity used in vendor-compatibility tests.</summary>
public class Order
{
public int Id { get; set; }
public string? CustomerName { get; set; }
public decimal Total { get; set; }
public bool IsCompleted { get; set; }

/// <summary>
/// A computed projectable property. Having at least one [Projectable] read-only
/// property on the entity ensures that <c>CustomQueryCompiler</c> is exercised
/// (it expands the projectable reference and potentially adds a Select wrapper).
/// </summary>
[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<Order> Orders => Set<Order>();

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite(_connection);
optionsBuilder.UseProjectables();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>();
}

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();
}
}
Loading