diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index e342a6e..3bff25a 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -22,14 +22,22 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor private IEntityType? _entityType; // Extract MethodInfo via expression trees (trim-safe; computed once per AppDomain) - private static readonly MethodInfo _select = + private readonly static MethodInfo _select = ((MethodCallExpression)((Expression, IQueryable>>) (q => q.Select(x => x))).Body).Method.GetGenericMethodDefinition(); - private static readonly MethodInfo _where = + private readonly static MethodInfo _where = ((MethodCallExpression)((Expression, IQueryable>>) (q => q.Where(x => true))).Body).Method.GetGenericMethodDefinition(); + // Static caches — keyed by CLR type, shared across all instances for the AppDomain lifetime. + // ConditionalWeakTable uses "ephemeron" semantics: the Type key is not kept alive by the + // cache entry, so types from collectible AssemblyLoadContexts can still be unloaded. + private readonly static ConditionalWeakTable> _compilerGeneratedClosureCache = new(); + private readonly static ConditionalWeakTable _projectablePropertiesCache = new(); + private readonly static ConditionalWeakTable _closedSelectCache = new(); + private readonly static ConditionalWeakTable _closedWhereCache = new(); + public ProjectableExpressionReplacer(IProjectionExpressionResolver projectionExpressionResolver, bool trackByDefault = false) { _trackingByDefault = trackByDefault; @@ -84,7 +92,6 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La // // case of a first() // return obj.MyMap(x => new Obj {}); // } - if (call.Method.ReturnType.IsAssignableTo(typeof(IQueryable))) { @@ -101,7 +108,8 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La // before the query become executed by EF (before the .First()), we rewrite the .First(where) // as .Where(where).Select(x => ...).First() - var where = Expression.Call(null, _where.MakeGenericMethod(_entityType.ClrType), call.Arguments); + var whereMethod = _closedWhereCache.GetValue(_entityType.ClrType, t => _where.MakeGenericMethod(t)); + var where = Expression.Call(null, whereMethod, call.Arguments); // The call instance is based on the wrong polymorphied method. var first = call.Method.DeclaringType?.GetMethods() .FirstOrDefault(x => x.Name == call.Method.Name && x.GetParameters().Length == 1); @@ -138,18 +146,27 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La protected override Expression VisitMethodCall(MethodCallExpression node) { // Replace MethodGroup arguments with their reflected expressions. - // Note that MethodCallExpression.Update returns the original Expression if argument values have not changed. - node = node.Update(node.Object, node.Arguments.Select(arg => arg switch { - UnaryExpression { - NodeType: ExpressionType.Convert, - Operand: MethodCallExpression { - NodeType: ExpressionType.Call, - Method: { Name: nameof(MethodInfo.CreateDelegate), DeclaringType.Name: nameof(MethodInfo) }, - Object: ConstantExpression { Value: MethodInfo methodInfo } - } - } => TryGetReflectedExpression(methodInfo, out var expressionArg) ? expressionArg : arg, - _ => arg - })); + // No-alloc fast-path: scan args without allocating; only copy the array and call + // Update() when a replacement is actually found (method-group arguments are rare). + Expression[]? updatedArgs = null; + for (var i = 0; i < node.Arguments.Count; i++) + { + if (node.Arguments[i] is UnaryExpression { + NodeType: ExpressionType.Convert, + Operand: MethodCallExpression { + NodeType: ExpressionType.Call, + Method: { Name: nameof(MethodInfo.CreateDelegate), DeclaringType.Name: nameof(MethodInfo) }, + Object: ConstantExpression { Value: MethodInfo capturedMethodInfo } + } + } && TryGetReflectedExpression(capturedMethodInfo, out var expressionArg)) + { + (updatedArgs ??= [.. node.Arguments])[i] = expressionArg; + } + } + if (updatedArgs is not null) + { + node = node.Update(node.Object, updatedArgs); + } // Get the overriding methodInfo based on te type of the received of this expression var methodInfo = node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method; @@ -172,7 +189,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) { for (var parameterIndex = 0; parameterIndex < reflectedExpression.Parameters.Count; parameterIndex++) { - var parameterExpession = reflectedExpression.Parameters[parameterIndex]; + var parameterExpression = reflectedExpression.Parameters[parameterIndex]; var mappedArgumentExpression = (parameterIndex, node.Object) switch { (0, not null) => node.Object, (_, not null) => node.Arguments[parameterIndex - 1], @@ -181,7 +198,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) if (mappedArgumentExpression is not null) { - _expressionArgumentReplacer.ParameterArgumentMapping.Add(parameterExpession, mappedArgumentExpression); + _expressionArgumentReplacer.ParameterArgumentMapping.Add(parameterExpression, mappedArgumentExpression); } } @@ -232,19 +249,35 @@ protected override Expression VisitMember(MemberExpression node) { // Evaluate captured variables in closures that contain EF queries to inline them into the main query if (node.Expression is ConstantExpression constant && - constant.Type.Attributes.HasFlag(TypeAttributes.NestedPrivate) && - Attribute.IsDefined(constant.Type, typeof(CompilerGeneratedAttribute), inherit: true)) + IsCompilerGeneratedClosure(constant.Type)) { try { - var value = Expression - .Lambda>(Expression.Convert(node, typeof(object))) - .Compile() - .Invoke(); + // Cheap type check first: only call GetValue() when the declared type + // could possibly hold an IQueryable at runtime. We use IEnumerable as + // the gate (rather than IQueryable) because a variable legitimately + // declared as IEnumerable may hold an EF Core IQueryable at + // runtime — both interfaces share the same assignability chain. + // FieldType / PropertyType are free property reads on already- + // materialised MemberInfo objects, so this check is cheap. + var memberType = node.Member switch { + FieldInfo field => field.FieldType, + PropertyInfo prop => prop.PropertyType, + _ => null + }; - if (value is IQueryable queryable && ReferenceEquals(queryable.Provider, _currentQueryProvider)) + if (memberType is not null && typeof(IEnumerable).IsAssignableFrom(memberType)) { - return Visit(queryable.Expression); + var value = node.Member switch { + FieldInfo field => field.GetValue(constant.Value), + PropertyInfo prop => prop.GetValue(constant.Value), + _ => null + }; + + if (value is IQueryable queryable && ReferenceEquals(queryable.Provider, _currentQueryProvider)) + { + return Visit(queryable.Expression); + } } } catch @@ -275,16 +308,10 @@ PropertyInfo property when nodeExpression is not null var updatedBody = _expressionArgumentReplacer.Visit(reflectedExpression.Body); _expressionArgumentReplacer.ParameterArgumentMapping.Clear(); - return base.Visit( - updatedBody - ); - } - else - { - return base.Visit( - reflectedExpression.Body - ); + return base.Visit(updatedBody); } + + return base.Visit(reflectedExpression.Body); } return base.VisitMember(node); @@ -303,12 +330,13 @@ protected override Expression VisitExtension(Expression node) private Expression _AddProjectableSelect(Expression node, IEntityType entityType) { - var projectableProperties = entityType.ClrType.GetProperties() - .Where(x => x.IsDefined(typeof(ProjectableAttribute), false)) - .Where(x => x.CanWrite) - .ToList(); + var projectableProperties = _projectablePropertiesCache.GetValue( + entityType.ClrType, + static t => t.GetProperties() + .Where(x => x.IsDefined(typeof(ProjectableAttribute), false) && x.CanWrite) + .ToArray()); - if (!projectableProperties.Any()) + if (projectableProperties.Length == 0) { return node; } @@ -327,7 +355,7 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType .Where(x => projectableProperties.All(y => x.Name != y.Name && x.Name != $"<{y.Name}>k__BackingField")); // Replace db.Entities to db.Entities.Select(x => new Entity { Property1 = x.Property1, Rewritted = rewrittedProperty }) - var select = _select.MakeGenericMethod(entityType.ClrType, entityType.ClrType); + var select = _closedSelectCache.GetValue(entityType.ClrType, t => _select.MakeGenericMethod(t, t)); var xParam = Expression.Parameter(entityType.ClrType); return Expression.Call( null, @@ -354,5 +382,12 @@ private Expression _GetAccessor(PropertyInfo property, ParameterExpression para) _expressionArgumentReplacer.ParameterArgumentMapping.Clear(); return base.Visit(updatedBody); } + + private static bool IsCompilerGeneratedClosure(Type type) => + // TypeAttributes.NestedPrivate is a cheap flag check that rules out most types before + // touching the attribute cache. + type.Attributes.HasFlag(TypeAttributes.NestedPrivate) && + _compilerGeneratedClosureCache.GetValue(type, static t => + new StrongBox(Attribute.IsDefined(t, typeof(CompilerGeneratedAttribute), inherit: true))).Value; } } diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.DotNet10_0.verified.txt new file mode 100644 index 0000000..39c1335 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.DotNet10_0.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.DotNet9_0.verified.txt new file mode 100644 index 0000000..39c1335 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.DotNet9_0.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.verified.txt new file mode 100644 index 0000000..39c1335 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaAny.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt new file mode 100644 index 0000000..c925721 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [e].[Id], ( + SELECT COUNT(*) + FROM [Entity] AS [e0] + WHERE [e0].[Id] * 2 > 4) AS [SubsetCount] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet9_0.verified.txt new file mode 100644 index 0000000..c925721 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet9_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [e].[Id], ( + SELECT COUNT(*) + FROM [Entity] AS [e0] + WHERE [e0].[Id] * 2 > 4) AS [SubsetCount] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.verified.txt new file mode 100644 index 0000000..c925721 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.verified.txt @@ -0,0 +1,5 @@ +SELECT [e].[Id], ( + SELECT COUNT(*) + FROM [Entity] AS [e0] + WHERE [e0].[Id] * 2 > 4) AS [SubsetCount] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.DotNet10_0.verified.txt new file mode 100644 index 0000000..39c1335 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.DotNet10_0.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.DotNet9_0.verified.txt new file mode 100644 index 0000000..39c1335 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.DotNet9_0.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.verified.txt new file mode 100644 index 0000000..39c1335 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryable_DeclaredAsIEnumerable_IsInlined.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.DotNet10_0.verified.txt new file mode 100644 index 0000000..3a1bd31 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +DECLARE @lowerBound int = 3; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= @lowerBound AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.DotNet9_0.verified.txt new file mode 100644 index 0000000..4e85b84 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.DotNet9_0.verified.txt @@ -0,0 +1,5 @@ +DECLARE @__lowerBound_0 int = 3; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= @__lowerBound_0 AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.verified.txt new file mode 100644 index 0000000..4e85b84 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIntField_UsedInProjectableMethod.verified.txt @@ -0,0 +1,5 @@ +DECLARE @__lowerBound_0 int = 3; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= @__lowerBound_0 AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.DotNet10_0.verified.txt new file mode 100644 index 0000000..26c6270 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +DECLARE @minCount int = 1; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] >= @minCount AND [e].[Id] <= 50) OR EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 10 AND [e0].[Id] <= 100 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.DotNet9_0.verified.txt new file mode 100644 index 0000000..74e2c9e --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.DotNet9_0.verified.txt @@ -0,0 +1,8 @@ +DECLARE @__minCount_0 int = 1; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] >= @__minCount_0 AND [e].[Id] <= 50) OR EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 10 AND [e0].[Id] <= 100 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.verified.txt new file mode 100644 index 0000000..74e2c9e --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly.verified.txt @@ -0,0 +1,8 @@ +DECLARE @__minCount_0 int = 1; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] >= @__minCount_0 AND [e].[Id] <= 50) OR EXISTS ( + SELECT 1 + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 10 AND [e0].[Id] <= 100 AND [e0].[Id] = [e].[Id]) \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.DotNet10_0.verified.txt new file mode 100644 index 0000000..c19557b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.DotNet10_0.verified.txt @@ -0,0 +1,6 @@ +DECLARE @lower int = 2; +DECLARE @upper int = 8; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= @lower AND [e].[Id] <= @upper \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.DotNet9_0.verified.txt new file mode 100644 index 0000000..2c21716 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.DotNet9_0.verified.txt @@ -0,0 +1,6 @@ +DECLARE @__lower_0 int = 2; +DECLARE @__upper_1 int = 8; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= @__lower_0 AND [e].[Id] <= @__upper_1 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.verified.txt new file mode 100644 index 0000000..2c21716 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedMultipleIntFields_UsedInProjectableMethod.verified.txt @@ -0,0 +1,6 @@ +DECLARE @__lower_0 int = 2; +DECLARE @__upper_1 int = 8; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= @__lower_0 AND [e].[Id] <= @__upper_1 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.DotNet10_0.verified.txt new file mode 100644 index 0000000..98cb901 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +DECLARE @targetName nvarchar(4000) = N'Alice'; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Name] = @targetName \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.DotNet9_0.verified.txt new file mode 100644 index 0000000..dc6bab4 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.DotNet9_0.verified.txt @@ -0,0 +1,5 @@ +DECLARE @__targetName_0 nvarchar(4000) = N'Alice'; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Name] = @__targetName_0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.verified.txt new file mode 100644 index 0000000..dc6bab4 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedStringField_UsedInProjectableMethod.verified.txt @@ -0,0 +1,5 @@ +DECLARE @__targetName_0 nvarchar(4000) = N'Alice'; + +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Name] = @__targetName_0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.cs new file mode 100644 index 0000000..c642bf5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.cs @@ -0,0 +1,205 @@ +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.Projectables.FunctionalTests; + +/// +/// Validates closure-variable handling in ProjectableExpressionReplacer.VisitMember. +/// +/// How the current implementation works: +/// When a accesses a member of a +/// compiler-generated closure object the replacer first checks the member's declared type +/// (FieldInfo.FieldType / PropertyInfo.PropertyType). Only when that declared type +/// is assignable to does it call GetValue() to read the runtime +/// value. If the value is an whose provider matches the current query +/// provider, the captured query's expression tree is inlined into the outer query. +/// +/// (not ) is used as the gate because a variable +/// declared as IEnumerable<T> may legally hold an EF Core IQueryable<T> +/// at runtime; using IQueryable alone would miss that case. +/// +/// For scalar captures (e.g., int, bool) the declared type is not +/// assignable to , so GetValue() is never called; the closure +/// member expression is passed through unchanged and EF Core resolves it as a normal query +/// parameter via its own parameter-extraction pipeline. +/// +/// Note on the PropertyInfo branch: standard C# compiler closures always use fields, not +/// properties, so the PropertyInfo prop => prop.GetValue(...) arm is a defensive path +/// that cannot be reached through ordinary C# lambdas. It is covered by a direct unit test in +/// ProjectableExpressionReplacerTests that constructs the expression tree manually. +/// +/// Scenarios covered: +/// 1. Closure capturing an int field – scalar, NOT via reflection; EF Core handles it as a parameter +/// 2. Closure capturing a string field – GetValue() is called (string implements IEnumerable<char>), +/// but the runtime value is not IQueryable so it falls through; EF Core handles it as a parameter +/// 3. Closure capturing two int fields – multiple scalars, NOT via reflection +/// 4. Closure capturing an IQueryable<Entity> field – FieldInfo.GetValue() path, inlined via .Any() +/// 5. Closure capturing an IQueryable<Entity> field – FieldInfo.GetValue() path, inlined in .Count() projection +/// 6. Closure capturing both an int field and an IQueryable<Entity> field – combined paths +/// 7. Closure capturing an IEnumerable<Entity> field holding an EF query – GetValue() is called +/// because IEnumerable<T> satisfies the type gate; runtime value is IQueryable, so the subquery is inlined +/// +[UsesVerify] +public class ClosureMemberAccessTests +{ + public record Entity + { + public int Id { get; set; } + public string? Name { get; set; } + + [Projectable] + public bool IsWithinRange(int min, int max) => Id >= min && Id <= max; + + [Projectable] + public bool HasName(string name) => Name == name; + + [Projectable] + public int Doubled => Id * 2; + } + + // ----------------------------------------------------------------------- + // 1. Closure captures a single int field – scalar, EF Core parameter path. + // Because int is not assignable to IQueryable, the declared-type check + // in VisitMember does NOT invoke GetValue(); the compiler-generated + // closure member expression falls through unchanged and EF Core's own + // ParameterExtractingExpressionVisitor turns it into a SQL parameter. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedIntField_UsedInProjectableMethod() + { + using var dbContext = new SampleDbContext(); + + var lowerBound = 3; + var query = dbContext.Set() + .Where(x => x.IsWithinRange(lowerBound, 10)); + + return Verifier.Verify(query.ToQueryString()); + } + + // ----------------------------------------------------------------------- + // 2. Closure captures a string field – EF Core parameter path. + // string implements IEnumerable, so the IEnumerable gate in + // VisitMember is satisfied and GetValue() IS called. However, the + // runtime value is a string (not IQueryable), so the provider check + // fails and the code falls through; EF Core handles it as a parameter. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedStringField_UsedInProjectableMethod() + { + using var dbContext = new SampleDbContext(); + + var targetName = "Alice"; + var query = dbContext.Set() + .Where(x => x.HasName(targetName)); + + return Verifier.Verify(query.ToQueryString()); + } + + // ----------------------------------------------------------------------- + // 3. Closure captures two int fields – multiple scalars, EF Core parameter path. + // Neither `lower` nor `upper` triggers GetValue() (int is not assignable to + // IQueryable); EF Core emits a separate SQL parameter for each captured int. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedMultipleIntFields_UsedInProjectableMethod() + { + using var dbContext = new SampleDbContext(); + + var lower = 2; + var upper = 8; + var query = dbContext.Set() + .Where(x => x.IsWithinRange(lower, upper)); + + return Verifier.Verify(query.ToQueryString()); + } + + // ----------------------------------------------------------------------- + // 4. Closure captures an IQueryable subquery (FieldInfo branch, IQueryable + // result → sub-expression inlining). + // When FieldInfo.GetValue() returns an IQueryable that shares the same + // provider, ProjectableExpressionReplacer inlines the subquery's + // expression tree rather than treating the variable as a parameter. + // The projectable [Projectable] method inside the subquery must also be + // expanded in the final SQL. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedIQueryableField_SubqueryInlinedViaAny() + { + using var dbContext = new SampleDbContext(); + + var validEntities = dbContext.Set() + .Where(x => x.IsWithinRange(1, 5)); + + var query = dbContext.Set() + .Where(x => validEntities.Any(s => s.Id == x.Id)); + + return Verifier.Verify(query.ToQueryString()); + } + + // ----------------------------------------------------------------------- + // 5. Closure captures an IQueryable subquery used in a Count() projection. + // The captured IQueryable (filtered via a [Projectable] property) is + // inlined as a correlated sub-select inside a SELECT projection. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedIQueryableField_SubqueryInlinedViaCount() + { + using var dbContext = new SampleDbContext(); + + var subset = dbContext.Set() + .Where(x => x.Doubled > 4); + + var query = dbContext.Set() + .Select(x => new { x.Id, SubsetCount = subset.Count() }); + + return Verifier.Verify(query.ToQueryString()); + } + + // ----------------------------------------------------------------------- + // 6. Closure captures both a value-type field AND an IQueryable. + // Exercises both branches in the same expression: the int is read as a + // FieldInfo value and turned into a SQL parameter; the IQueryable is + // also read as a FieldInfo value and inlined as a sub-expression. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedMixedFields_IntAndIQueryable_BothResolvedCorrectly() + { + using var dbContext = new SampleDbContext(); + + var minCount = 1; + var highIds = dbContext.Set() + .Where(x => x.IsWithinRange(10, 100)); + + var query = dbContext.Set() + .Where(x => x.IsWithinRange(minCount, 50) || highIds.Any(h => h.Id == x.Id)); + + return Verifier.Verify(query.ToQueryString()); + } + + // ----------------------------------------------------------------------- + // 7. Closure captures an IQueryable declared as IEnumerable. + // The IEnumerable gate in VisitMember is satisfied (IEnumerable is + // assignable to IEnumerable), so GetValue() IS called. The runtime + // value is the EF Core IQueryable, whose provider matches, so + // the subquery is inlined — identical result to scenario 4. + // ----------------------------------------------------------------------- + [Fact] + public Task CapturedIQueryable_DeclaredAsIEnumerable_IsInlined() + { + using var dbContext = new SampleDbContext(); + + // Declared as IEnumerable but assigned an EF Core query. + // With the IEnumerable gate the replacer calls GetValue(), recognises + // the runtime value as an IQueryable with a matching provider and + // inlines the subquery expression — no translation error. + IEnumerable subsetAsEnumerable = dbContext.Set() + .Where(x => x.IsWithinRange(1, 5)); + + var query = dbContext.Set() + .Where(x => subsetAsEnumerable.Any(s => s.Id == x.Id)); + + return Verifier.Verify(query.ToQueryString()); + } +} + + diff --git a/tests/EntityFrameworkCore.Projectables.Tests/Services/ProjectableExpressionReplacerTests.cs b/tests/EntityFrameworkCore.Projectables.Tests/Services/ProjectableExpressionReplacerTests.cs index a9ed5dd..9fe3ec9 100644 --- a/tests/EntityFrameworkCore.Projectables.Tests/Services/ProjectableExpressionReplacerTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Tests/Services/ProjectableExpressionReplacerTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; using EntityFrameworkCore.Projectables.Services; @@ -161,5 +162,51 @@ public void VisitMember_SimpleStaticMethodWithArguments() Assert.Equal(expected.ToString(), actual.ToString()); } + + /// + /// Exercises the PropertyInfo prop => prop.GetValue(...) branch inside the + /// closure-inlining guard of VisitMember. + /// + /// Standard C# compiler-generated closures always use fields, making the + /// PropertyInfo arm unreachable from ordinary lambdas. This test constructs + /// the expression tree manually — using a nested private [CompilerGenerated] + /// class whose member is a property — to ensure the branch is executed without + /// throwing and falls through correctly when no active + /// is set (i.e., no inlining occurs, the original expression is returned unchanged). + /// + [Fact] + public void VisitMember_CompilerGeneratedClosure_PropertyInfoBranch_FallsThroughWithoutInlining() + { + var closure = new FakeClosureWithIQueryableProperty + { + Items = new[] { new Entity { Id = 1 } }.AsQueryable() + }; + + var closureConst = Expression.Constant(closure); + var propertyInfo = typeof(FakeClosureWithIQueryableProperty) + .GetProperty(nameof(FakeClosureWithIQueryableProperty.Items))!; + var memberAccess = Expression.MakeMemberAccess(closureConst, propertyInfo); + + var resolver = new ProjectableExpressionResolverStub( + _ => throw new InvalidOperationException("Resolver should not be called for non-projectable members.") + ); + var subject = new ProjectableExpressionReplacer(resolver); + + // The replacer must not throw. Since there is no active IQueryProvider (no EF + // query root has been visited), the provider check fails and the expression is + // returned unchanged. + var actual = subject.Replace(memberAccess); + + Assert.Same(memberAccess, actual); + } + + // Simulates a compiler-generated closure whose member is a *property* (not a field). + // Real C# closures always generate fields; this class is only used to exercise the + // defensive PropertyInfo branch in ProjectableExpressionReplacer.VisitMember. + [CompilerGenerated] + private sealed class FakeClosureWithIQueryableProperty + { + public IQueryable? Items { get; set; } + } } }