diff --git a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs index 9b8b6fdb..2899366f 100644 --- a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs +++ b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs @@ -23,6 +23,7 @@ public class ExpressiveReplacer : ExpressionVisitor // calls instead of being inlined again — the runtime then dispatches normally on // .Compile()/.Invoke(). private readonly HashSet _expandingMembers = new(); + private readonly HashSet _inliningQueryables = new(ReferenceEqualityComparer.Instance); private static readonly ConditionalWeakTable> _compilerGeneratedClosureCache = new(); @@ -174,8 +175,20 @@ protected override Expression VisitNew(NewExpression node) return base.VisitNew(node); } + protected override Expression VisitConstant(ConstantExpression node) + => TryInlineQueryable(node.Value, node.Type, out var inlined) + ? inlined + : base.VisitConstant(node); + protected override Expression VisitMember(MemberExpression node) { + if (typeof(IQueryable).IsAssignableFrom(node.Type) + && TryEvaluateCapturedValue(node, out var capturedValue) + && TryInlineQueryable(capturedValue, node.Type, out var inlinedQueryable)) + { + return inlinedQueryable; + } + var nodeExpression = node.Expression switch { UnaryExpression { NodeType: ExpressionType.Convert, Type: { IsInterface: true } type, Operand: { } operand } when type.IsAssignableFrom(operand.Type) @@ -222,6 +235,64 @@ when type.IsAssignableFrom(operand.Type) return base.VisitMember(node); } + private bool TryInlineQueryable(object? value, Type nodeType, [NotNullWhen(true)] out Expression? inlined) + { + inlined = null; + + if (value is not IQueryable queryable + || queryable.Expression is ConstantExpression { Value: var root } && ReferenceEquals(root, queryable) + || !nodeType.IsAssignableFrom(queryable.Expression.Type) + || !_inliningQueryables.Add(queryable)) + { + return false; + } + + try + { + inlined = Visit(queryable.Expression); + return true; + } + finally + { + _inliningQueryables.Remove(queryable); + } + } + + private static bool TryEvaluateCapturedValue(Expression expression, out object? value) + { + value = null; + + switch (expression) + { + case ConstantExpression constant: + value = constant.Value; + return true; + + case MemberExpression { Member: FieldInfo or PropertyInfo { GetMethod: not null } } member: + object? instance = null; + if (member.Expression is not null + && (!TryEvaluateCapturedValue(member.Expression, out instance) || instance is null)) + { + return false; + } + + try + { + value = member.Member is FieldInfo field + ? field.GetValue(instance) + : ((PropertyInfo)member.Member).GetValue(instance); + return true; + } + catch + { + return false; + } + + default: + return false; + } + } + /// /// Expands a virtual/override [Expressive] member into a runtime type-test chain /// (e is Dog ? ((Dog)e).body : baseBody) so each value uses its runtime type's body; diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/CapturedVariableTestBase.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/CapturedVariableTestBase.cs index 5770377c..0ecab5b2 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/CapturedVariableTestBase.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/CapturedVariableTestBase.cs @@ -195,6 +195,46 @@ public async Task OrderBy_CapturedVariable_InKeySelector() Assert.AreEqual(2, results[1]); } + [TestMethod] + public async Task Where_CapturedQueryableWithExpressiveMember_InAny_TranslatesToSql() + { + var premiumOrders = Context.Orders.Where(o => o.GetGrade() == "Premium"); + + var results = await Context.Orders + .Where(o => premiumOrders.Any(p => p.Id == o.Id)) + .Select(o => o.Id) + .ToListAsync(); + + CollectionAssert.AreEquivalent(new[] { 1 }, results); + } + + [TestMethod] + public async Task Where_CapturedQueryableWithExpressiveMember_InContains_TranslatesToSql() + { + var premiumOrderIds = Context.Orders.Where(o => o.GetGrade() == "Premium").Select(o => o.Id); + + var results = await Context.Orders + .Where(o => premiumOrderIds.Contains(o.Id)) + .Select(o => o.Id) + .ToListAsync(); + + CollectionAssert.AreEquivalent(new[] { 1 }, results); + } + + [TestMethod] + public async Task Where_NestedCapturedQueryablesWithExpressiveMembers_TranslatesToSql() + { + var premiumOrders = Context.Orders.Where(o => o.GetGrade() == "Premium"); + var alicePremiumOrders = Context.Orders.Where(o => o.CustomerName == "Alice" && premiumOrders.Any(p => p.Id == o.Id)); + + var results = await Context.Orders + .Where(o => alicePremiumOrders.Any(r => r.Id == o.Id)) + .Select(o => o.Id) + .ToListAsync(); + + CollectionAssert.AreEquivalent(new[] { 1 }, results); + } + // Must be internal (not private): the generated interceptor references the type. internal class InstanceFieldCaptureHelper { diff --git a/tests/ExpressiveSharp.Tests/Services/ExpressiveReplacerTests.cs b/tests/ExpressiveSharp.Tests/Services/ExpressiveReplacerTests.cs index b14d7285..d030cb09 100644 --- a/tests/ExpressiveSharp.Tests/Services/ExpressiveReplacerTests.cs +++ b/tests/ExpressiveSharp.Tests/Services/ExpressiveReplacerTests.cs @@ -157,4 +157,76 @@ public LambdaExpression FindGeneratedExpression(MemberInfo expressiveMemberInfo, public LambdaExpression? FindExternalExpression(MemberInfo memberInfo) => _expressions.TryGetValue(memberInfo, out var expr) ? expr : null; } + + [TestMethod] + public void Replace_CapturedQueryable_InlinesAndExpandsExpressiveMembers() + { + var products = new[] + { + new Product { Id = 1, Price = 60, Quantity = 2 }, + new Product { Id = 2, Price = 10, Quantity = 1 }, + }; + var expensive = products.AsQueryable().Where(p => p.Total > 100); + Expression> isExpensive = p => expensive.Any(e => e.Id == p.Id); + + var result = (Expression>)new ExpressiveReplacer(new ExpressiveResolver()).Replace(isExpensive); + + var members = ReferencedMembers.Collect(result); + Assert.IsTrue(members.Contains(typeof(Product).GetProperty(nameof(Product.Price))!)); + Assert.IsFalse(members.Contains(typeof(Product).GetProperty(nameof(Product.Total))!)); + var compiled = result.Compile(); + Assert.IsTrue(compiled(products[0])); + Assert.IsFalse(compiled(products[1])); + } + + [TestMethod] + public void Replace_NestedCapturedQueryables_ExpandsExpressiveMembersAtEveryLevel() + { + var products = new[] { new Product { Id = 1, Price = 60, Quantity = 2 } }.AsQueryable(); + var expensive = products.Where(p => p.Total > 100); + var labelled = products.Where(p => p.Label() == "Product 1" && expensive.Any(e => e.Id == p.Id)); + Expression> anyLabelled = () => labelled.Any(); + + var result = new ExpressiveReplacer(new ExpressiveResolver()).Replace(anyLabelled); + + var members = ReferencedMembers.Collect(result); + Assert.IsTrue(members.Contains(typeof(Product).GetProperty(nameof(Product.Price))!)); + Assert.IsFalse(members.Contains(typeof(Product).GetProperty(nameof(Product.Total))!)); + Assert.IsFalse(members.Contains(typeof(Product).GetMethod(nameof(Product.Label))!)); + } + + [TestMethod] + public void Replace_CapturedRootEnumerableQuery_LeftUnchanged() + { + var products = new List().AsQueryable(); + Expression> anyProducts = () => products.Any(); + + var result = new ExpressiveReplacer(new ExpressiveResolver()).Replace(anyProducts); + + Assert.AreSame(anyProducts, result); + } + + private sealed class ReferencedMembers : ExpressionVisitor + { + private readonly HashSet _members = new(); + + public static HashSet Collect(Expression expression) + { + var collector = new ReferencedMembers(); + collector.Visit(expression); + return collector._members; + } + + protected override Expression VisitMember(MemberExpression node) + { + _members.Add(node.Member); + return base.VisitMember(node); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + _members.Add(node.Method); + return base.VisitMethodCall(node); + } + } }