Mark Cooper
Mark Cooper

Reputation: 6894

How to create a reusable 'Contains' expression for EF Core

Problem

I need to execute a partial text search, alongside other filters, via a generic repository using expressions.

State of current code

I have a generic method that returns paged results from my database (via a common repository layer).

In the following working example;

public Task<PagedResult<Person>> GetPeopleAsync(PersonSearchParams searchParams,
    PagedRequest pagedRequest = null)
{
    ParameterExpression argParam = Expression.Parameter(typeof(Locum), "locum");

    // start with a "true" expression so we have an expression to "AndAlso" with
    var alwaysTrue = Expression.Constant(true);
    var expr = Expression.Equal(alwaysTrue, alwaysTrue);

    if (searchParams != null)
    {
        BinaryExpression propExpr;

        if (searchParams.DateOfBirth.HasValue)
        {
            propExpr = GetExpression(searchParams.DateStart,
                nameof(Incident.IncidentDate), 
                argParam, 
                ExpressionType.GreaterThanOrEqual);

            expr = Expression.AndAlso(expr, propExpr);
        }

        if (searchParams.DateOfDeath.HasValue)
        {
            propExpr = GetExpression(searchParams.DateEnd,
                nameof(Incident.IncidentDate), 
                argParam, 
                ExpressionType.LessThanOrEqual);

            expr = Expression.AndAlso(expr, propExpr);
        }

        if (searchParams.BranchId.HasValue && searchParams.BranchId.Value != 0)
        {
            propExpr = GetExpression(searchParams.BranchId, 
                nameof(Incident.BranchId), argParam);

            expr = Expression.AndAlso(expr, propExpr);
        }
    }

    var lambda = Expression.Lambda<Func<Locum, bool>>(expr, argParam);
    return _unitOfWork.Repository.GetAsync(filter: lambda, pagedRequest: pagedRequest);
}

This is using my static GetExpression method for Expression.Equal, Expression.GreaterThanOrEqual and Expression.LessThanOrEqual queries as follows;

private static BinaryExpression GetExpression<TValue>(TValue value,
    string propName, ParameterExpression argParam, ExpressionType? exprType = null)
{
    BinaryExpression propExpr;

    var prop = Expression.Property(argParam, propName);
    var valueConst = Expression.Constant(value, typeof(TValue));

    switch (exprType)
    {
        case ExpressionType.GreaterThanOrEqual:
            propExpr = Expression.GreaterThanOrEqual(prop, valueConst);
            break;
        case ExpressionType.LessThanOrEqual:
            propExpr = Expression.LessThanOrEqual(prop, valueConst);
            break;
        case ExpressionType.Equal:
        default:// assume equality
            propExpr = Expression.Equal(prop, valueConst);
            break;
    }
    return propExpr;
}

NOTE: this code is working correctly.

Problem

Using example from other SO answers I have tried the following;

Expressions

I have tried getting the contains via an Expression;

static Expression<Func<bool>> GetContainsExpression<T>(string propertyName, 
    string propertyValue)
{
    var parameterExp = Expression.Parameter(typeof(T), "type");
    var propertyExp = Expression.Property(parameterExp, propertyName);
    MethodInfo method = typeof(string).GetMethod("Contains", new[] { typeof(string) });
    var someValue = Expression.Constant(propertyValue, typeof(string));
    var containsMethodExp = Expression.Call(propertyExp, method, someValue);
    return Expression.Lambda<Func<bool>>(containsMethodExp);
}

This has to be converted to a BinaryExpression so it can be added to the expression tree using AndAlso. I've tried to compare the Expression with a true value, but this isn't working

if (searchParams.FirstName.IsNotNullOrWhiteSpace())
{
    var propExpr = GetContainsExpression<Locum>(nameof(Locum.Firstname), 
        searchParams.FirstName);

    var binExpr = Expression.MakeBinary(ExpressionType.Equal, propExpr, propExpr);
    expr = Expression.AndAlso(expr, binExpr);
}

MethodCallExpression

I also tried returning the MethodCallExpression (instead of the Lambda above), using the following;

static MethodCallExpression GetContainsMethodCallExpression<T>(string propertyName, 
    string propertyValue)
{
    var parameterExp = Expression.Parameter(typeof(T), "type");
    var propertyExp = Expression.Property(parameterExp, propertyName);
    MethodInfo method = typeof(string).GetMethod("Contains", new[] { typeof(string) });
    var someValue = Expression.Constant(propertyValue, typeof(string));
    var containsMethodExp = Expression.Call(propertyExp, method, someValue);

    return containsMethodExp;
}

I used this as follows;

if (searchParams.FirstName.IsNotNullOrWhiteSpace())
{
    var propExpr = GetContainsMethodCallExpression<Person>(nameof(Person.FirstName), 
        searchParams.FirstName);

    var binExpr = Expression.MakeBinary(ExpressionType.Equal, propExpr, alwaysTrue);
    expr = Expression.AndAlso(expr, binExpr);
}

Exceptions

These expression are passed to a generic method that pages information out of the database, and the exceptions are thrown during the first execution of the query when I Count the total matching number of record on the constructed query.

System.InvalidOperationException: 'The LINQ expression 'DbSet() .Where(p => True && p.FirstName.Contains("123") == True)' 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 'AsEnumerable ', 'AsAsyncEnumerable ', 'ToList ', or 'ToListAsync '. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.'

This exception is thrown on a Count method I am using in my paging code. This code is already working without any filters, and with the ExpressionType filters described at the top, so I haven't included this code as I don't believe it is relevant.

pagedResult.RowCount = query.Count();

Upvotes: 2

Views: 1095

Answers (1)

Ivan Stoev
Ivan Stoev

Reputation: 205759

This has to be converted to a BinaryExpression so it can be added to the expression tree using AndAlso

Negative. There is no requirement Expression.AndAlso (or Expression.OrElse) operands to be binary expressions (it would have been strange like requiring left or right operand of && or || to be always comparison operators). The only requirement is them to be bool returning expressions, hence call to string Contains is a perfectly valid operand expression.

So start by changing the type of the inner local variable from BinaryExpression to Expression:

if (searchParams != null)
{
    Expression propExpr;
    
    // ...
}

The same btw applies for the initial expression - you don't need true == true, simple Expression expr = Expression.Constant(true); would do the same.

Now you could emit method call to string.Contains in a separate method similar to the other that you've posted (passing the ParameterExpression and building property selector expression) or inline similar to:

if (searchParams.FirstName.IsNotNullOrWhiteSpace())
{
    var propExpr = Expression.Property(argParam, nameof(Person.FirstName));
    var valueExpr = Expression.Constant(searchParams.FirstName);
    var containsExpr = Expression.Call(
        propExpr, nameof(string.Contains), Type.EmptyTypes, valueExpr);
    expr = Expression.AndAlso(expr, containsExpr);
}

Upvotes: 2

Related Questions