Search code examples
c#linqentity-framework-coreentity-framework-core-3.0

Entity Framework Core nested expressions


With the recent release of Entity Framework Core 3.0, LINQ queries are no longer evaluated on the client by default. I'm a big fan of this change, as it revealed some potentially dangerous client-side evaluation in my project that I thought was translated to SQL; however, it also made some of the helper methods that I was using to avoid crazy chains of ternaries unusable.

Has anyone manged to nest LINQ expressions for use with Entity Framework Core 3.0? Here's an example of what I'm hoping to achieve:

[Fact]
public async Task Can_use_custom_expression()
{
    var dbContext = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>().UseInMemoryDatabase("Test").Options);
    dbContext.Users.Add(new ApplicationUser { FirstName = "Foo", LastName = "Bar" });
    dbContext.SaveChanges();

    string query = "Foo";

    Expression<Func<string, string, bool>> valueCheck = (value, expected) => !string.IsNullOrEmpty(value) && value.Contains(expected);

    var valueCheckFunc = valueCheck.Compile();

    Expression<Func<ApplicationUser, bool>> whereExpression = (u) => valueCheckFunc(u.FirstName, query);

    var user = await dbContext.Users
        .Where(whereExpression)
        .FirstOrDefaultAsync();

    Assert.NotNull(user);
}

When I run this example, I get the following exception:

Message: 
    System.InvalidOperationException : The LINQ expression 'Where<ApplicationUser>(
        source: DbSet<ApplicationUser>, 
        predicate: (a) => Invoke(__valueCheckFunc_0, a.FirstName, __query_1)
    )' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to either AsEnumerable(), AsAsyncEnumerable(), ToList(), or ToListAsync(). See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Like I said, I do not want to evaluate this expression client-side, but I would like to avoid having to chain a dozen or so !string.IsNullOrEmpty(x) && x.Contains(y)'s in a single expression. I'd love some tips on how to achieve this.


Solution

  • If you want your expressions to be translatable to Sql by EF you need to avoid calling delegates or methods (with some exceptions, of course). But what you want to achieve is doable by replacing the delegate invocation with its defining expression. For that you need a specialized ExpressionVisitor.

    The following visitor will traverse expressions replacing delegate references within its wrapping invocations by a lambda expression body:

    public class DelegateByLambda: ExpressionVisitor
    {
        LambdaExpression delegateReferenceExpression;
        LambdaExpression lambdaExpression;
        Stack<InvocationExpression> invocations;
        public DelegateByLambda(LambdaExpression delegateReferenceExpression, LambdaExpression lambdaExpression)
        {
            this.delegateReferenceExpression = delegateReferenceExpression;
            this.lambdaExpression = lambdaExpression;
            this.invocations = new Stack<InvocationExpression>();
        }
    
        protected override Expression VisitParameter(ParameterExpression node)
        {
            var paramIndex = lambdaExpression.Parameters.IndexOf(node);
            if (paramIndex >= 0)
            {
                InvocationExpression call = invocations.Peek();
                return base.Visit(call.Arguments[paramIndex]);
            }
            return base.VisitParameter(node);
        }
        protected override Expression VisitInvocation(InvocationExpression node)
        {
            if (node.Expression.ToString() == delegateReferenceExpression.Body.ToString())
            {
                invocations.Push(node);
                var result = base.Visit(lambdaExpression.Body);
                invocations.Pop();
                return result;
            }
            return base.VisitInvocation(node);
        }
    }
    

    This class has no protection against attempting to replace delegate invocations by lambdas with mismatching arguments (number and types) however, the following extension method will do the trick:

    public static class DelegateByLambdaExtensions
    {
        public static Expression<T> Replace<T, X>(this Expression<T> source, Expression<Func<X>> delegateReference, Expression<X> lambdaReference)
        {
            return new DelegateByLambda(delegateReference, lambdaReference).Visit(source) as Expression<T>;
        }
    }
    

    So, all you need to do in your code is to call the replace extension method on the expression you want to translate passing an expression returning the delegate and desired lambda expression for expansion. Your sample should look like this:

        Expression<Func<string, string, bool>> valueCheck = (value, expected) => !string.IsNullOrEmpty(value) && value.Contains(expected);
    
        var valueCheckFunc = valueCheck.Compile();
    
        Expression<Func<ApplicationUser, bool>> whereExpression = (u) => valueCheckFunc(u.FirstName, query);
        whereExpression = whereExpression.Replace(() => valueCheckFunc, valueCheck);
    
        var user = dbContext.Users
            .Where(whereExpression)
            .FirstOrDefault();
    
        Console.WriteLine(user != null ? $"Found {user.FirstName} {user.LastName}!" : "User not found!");
    

    A working sample can be found here. https://dotnetfiddle.net/Lun3LA