Skip to content
Open
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
71 changes: 71 additions & 0 deletions src/ExpressiveSharp/Services/ExpressiveReplacer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemberInfo> _expandingMembers = new();
private readonly HashSet<IQueryable> _inliningQueryables = new(ReferenceEqualityComparer.Instance);

private static readonly ConditionalWeakTable<Type, StrongBox<bool>> _compilerGeneratedClosureCache = new();

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}

/// <summary>
/// Expands a virtual/override [Expressive] member into a runtime type-test chain
/// (<c>e is Dog ? ((Dog)e).body : baseBody</c>) so each value uses its runtime type's body;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
72 changes: 72 additions & 0 deletions tests/ExpressiveSharp.Tests/Services/ExpressiveReplacerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Func<Product, bool>> isExpensive = p => expensive.Any(e => e.Id == p.Id);

var result = (Expression<Func<Product, bool>>)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<Func<bool>> 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<Product>().AsQueryable();
Expression<Func<bool>> anyProducts = () => products.Any();

var result = new ExpressiveReplacer(new ExpressiveResolver()).Replace(anyProducts);

Assert.AreSame(anyProducts, result);
}

private sealed class ReferencedMembers : ExpressionVisitor
{
private readonly HashSet<MemberInfo> _members = new();

public static HashSet<MemberInfo> 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);
}
}
}
Loading