diff --git a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs
index 3ab54d7..e08d831 100644
--- a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs
+++ b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs
@@ -22,6 +22,9 @@ public class ExpressionResolverBenchmark
private static readonly MemberInfo _methodWithParamMember =
typeof(TestEntity).GetMethod(nameof(TestEntity.IdPlusDelta), new[] { typeof(int) })!;
+
+ private static readonly MemberInfo _copyConstructorMember =
+ typeof(TestEntity).GetConstructor(new[] { typeof(TestEntity) })!;
private readonly ProjectionExpressionResolver _resolver = new();
@@ -39,6 +42,10 @@ public class ExpressionResolverBenchmark
public LambdaExpression? ResolveMethodWithParam_Registry()
=> _resolver.FindGeneratedExpression(_methodWithParamMember);
+ [Benchmark]
+ public LambdaExpression? ResolveCopyConstructor_Registry()
+ => _resolver.FindGeneratedExpression(_copyConstructorMember);
+
// ── Reflection path ───────────────────────────────────────────────────
[Benchmark]
@@ -52,5 +59,9 @@ public class ExpressionResolverBenchmark
[Benchmark]
public LambdaExpression? ResolveMethodWithParam_Reflection()
=> ProjectionExpressionResolver.FindGeneratedExpressionViaReflection(_methodWithParamMember);
+
+ [Benchmark]
+ public LambdaExpression? ResolveCopyConstructor_Reflection()
+ => ProjectionExpressionResolver.FindGeneratedExpressionViaReflection(_copyConstructorMember);
}
}
diff --git a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs
index 68a04d8..635c8ac 100644
--- a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs
+++ b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs
@@ -8,6 +8,10 @@ namespace EntityFrameworkCore.Projectables.Benchmarks.Helpers
{
public class TestEntity
{
+ public TestEntity()
+ {
+ }
+
public int Id { get; set; }
[Projectable]
@@ -18,5 +22,11 @@ public class TestEntity
[Projectable]
public int IdPlusDelta(int delta) => Id + delta;
+
+ [Projectable]
+ public TestEntity(TestEntity other)
+ {
+ Id = other.Id;
+ }
}
}
diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs
index d00daad..59d7240 100644
--- a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs
+++ b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs
@@ -199,43 +199,52 @@ private static bool ParameterTypesMatch(
}
///
- /// Sentinel stored in to represent
+ /// Sentinel stored in to represent
/// "no generated type found for this member", distinguishing it from a not-yet-populated entry.
/// does not allow null values, so a sentinel is required.
///
- private readonly static Func _reflectionNotFoundSentinel = static () => null!;
+ private readonly static LambdaExpression _reflectionNullSentinel =
+ Expression.Lambda(Expression.Empty());
///
- /// Caches a pre-compiled Func<LambdaExpression> delegate per
- /// so that Assembly.GetType, GetMethod, MakeGenericType, and
- /// MakeGenericMethod are only paid once per member. All subsequent calls execute
- /// native JIT-compiled code with zero reflection overhead.
+ /// Caches the fully-resolved per
+ /// for the reflection-based slow path.
+ /// On the first call per member the reflection work (Assembly.GetType, GetMethod,
+ /// MakeGenericType, MakeGenericMethod) is performed once and the resulting
+ /// expression tree is stored here; subsequent calls return the cached reference directly,
+ /// eliminating expression-tree re-construction on every access.
+ /// This is especially important for constructors whose object-initializer trees are
+ /// significantly more expensive to build than simple method-body trees.
///
- private readonly static ConcurrentDictionary> _reflectionFactoryCache = new();
+ private readonly static ConcurrentDictionary _reflectionCache = new();
///
/// Resolves the for a [Projectable] member using the
/// reflection-based slow path only, bypassing the static registry.
- /// Useful for benchmarking and for members not yet in the registry (e.g. open-generic types).
+ /// The result is cached after the first call, so subsequent calls return the cached expression
+ /// without any reflection or expression-tree construction overhead.
+ /// Useful for members not yet in the registry (e.g. open-generic types).
///
public static LambdaExpression? FindGeneratedExpressionViaReflection(MemberInfo projectableMemberInfo)
{
- var factory = _reflectionFactoryCache.GetOrAdd(projectableMemberInfo, static mi => BuildReflectionFactory(mi));
- return ReferenceEquals(factory, _reflectionNotFoundSentinel) ? null : factory.Invoke();
+ var result = _reflectionCache.GetOrAdd(projectableMemberInfo,
+ static mi => BuildReflectionExpression(mi) ?? _reflectionNullSentinel);
+ return ReferenceEquals(result, _reflectionNullSentinel) ? null : result;
}
///
- /// Performs the one-time reflection work for a member and returns a compiled native delegate
- /// (or if no generated type exists).
+ /// Performs the one-time reflection work for a member: locates the generated expression
+ /// accessor (inline or external-class path), invokes it, and returns the resulting
+ /// . Returns null if no generated type is found.
///
- /// We use Expression.Lambda<TDelegate>(...).Compile() rather than
- /// Delegate.CreateDelegate because the generated Expression() factory method
- /// returns Expression<TDelegate> (a subtype of ), and
- /// CreateDelegate requires an exact return-type match in most runtime environments.
- /// The expression-tree wrapper handles the covariant cast cleanly and compiles to native code.
+ /// Using MethodInfo.Invoke rather than a compiled delegate is appropriate here because
+ /// the result is cached in — the invocation cost is paid only
+ /// on cache misses, and subsequent EF Core queries reuse the cached expression. Under
+ /// contention the value factory may be invoked more than once, but only a single expression
+ /// instance is ultimately stored per member.
///
///
- private static Func BuildReflectionFactory(MemberInfo projectableMemberInfo)
+ private static LambdaExpression? BuildReflectionExpression(MemberInfo projectableMemberInfo)
{
var declaringType = projectableMemberInfo.DeclaringType
?? throw new InvalidOperationException("Expected a valid type here");
@@ -295,7 +304,7 @@ private static Func BuildReflectionFactory(MemberInfo projecta
if (expressionFactoryType is null)
{
- return _reflectionNotFoundSentinel;
+ return null;
}
if (expressionFactoryType.IsGenericTypeDefinition)
@@ -307,7 +316,7 @@ private static Func BuildReflectionFactory(MemberInfo projecta
if (expressionFactoryMethod is null)
{
- return _reflectionNotFoundSentinel;
+ return null;
}
if (projectableMemberInfo is MethodInfo mi && mi.GetGenericArguments() is { Length: > 0 } methodGenericArgs)
@@ -315,12 +324,7 @@ private static Func BuildReflectionFactory(MemberInfo projecta
expressionFactoryMethod = expressionFactoryMethod.MakeGenericMethod(methodGenericArgs);
}
- // Compile a native delegate: () => (LambdaExpression)GeneratedClass.Expression()
- // Expression.Call + Convert handles the covariant return type (Expression → LambdaExpression).
- // The one-time Compile() cost is amortized; all subsequent calls are direct native-code invocations.
- var call = Expression.Call(expressionFactoryMethod);
- var cast = Expression.Convert(call, typeof(LambdaExpression));
- return Expression.Lambda>(cast).Compile();
+ return expressionFactoryMethod.Invoke(null, null) as LambdaExpression;
}
///