Search code examples
c#ef-core-3.1

EF Core query using String.Contains


We have a requirement to searches a given term within a comma-separated string. The query is built so that it ignores possible leading and trailing spaces in the comma-separated string. I came up with the following query which is running fine with EF 6.0

var trimmedTags = tags.Select(t => t.Trim()); // List of tags we need to look for    
return products.Where(p => trimmedTags.Any(t => ("," + p.Categories + ",").Contains("," + t + ",")) ||
                               trimmedTags.Any(t => ("," + p.Categories + ",").Contains(", " + t + ",")) ||
                               trimmedTags.Any(t => ("," + p.Categories + ",").Contains("," + t + " ,")) ||
                               trimmedTags.Any(t => ("," + p.Categories + ",").Contains(", " + t + " ,")));

This query is no longer running in EF Core 3.1 and throws the following error:

System.InvalidOperationException: 'The LINQ expression 'DbSet<Product>
    .Where(p => __trimmedTags_1
        .Any(t => ("," + p.Categories + ",").Contains("," + t + ",")) || __trimmedTags_1
        .Any(t => ("," + p.Categories + ",").Contains(", " + t + ",")) || __trimmedTags_1
        .Any(t => ("," + p.Categories + ",").Contains("," + t + " ,")) || __trimmedTags_1
        .Any(t => ("," + p.Categories + ",").Contains(", " + t + " ,")))' 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.'

My target table has millions of rows so client evaluation is unfortunately not an option. The EF Core team claims that string.Contains is supported but I can't figure out why my query is suddenly failing in EF Core.


Solution

  • A different variations of this question often appear on SO, and the problem is always one and the same - even at the most current version (5.x) EF Core does not support operators on in-memory collections other than simple Contains with primitive value (or Any that can be turned into Contains like x => memValues.Any(v => v == SomeExpr(x)), with == operator being the essential).

    The workaround is also one and the same - building dynamically expression - || (or) based for Any and && (and) based for All.

    This case requires ||, and is similar to How to simplify repetitive OR condition in Where(e => e.prop1.contains() || e.prop2.contains() || ...) but with value and field roles exchanged, so following is the helper method I would use:

    public static partial class QueryableExtensions
    {
        public static IQueryable<T> WhereAnyMatch<T, V>(this IQueryable<T> source, IEnumerable<V> values, Expression<Func<T, V, bool>> match)
        {
            var parameter = match.Parameters[0];
            var body = values
                // the easiest way to let EF Core use parameter in the SQL query rather than literal value
                .Select(value => ((Expression<Func<V>>)(() => value)).Body)
                .Select(value => Expression.Invoke(match, parameter, value))
                .Aggregate<Expression>(Expression.OrElse);
            var predicate = Expression.Lambda<Func<T, bool>>(body, parameter);
            return source.Where(predicate);
        }
    }
    

    Note that this works only for top level query expressions. If you need something like this for something which is part of a query expression tree (like collection navigation property), you'd need different type of helper function or some library which allows expression injection.

    Luckily that's not the case here, so the above helper method can be used directly by passing the trimmedTags and the condition for each tag value, e.g.

    return products.WhereAnyMatch(trimmedTags, (p, t) => 
        ("," + p.Categories + ",").Contains("," + t + ",") ||
        ("," + p.Categories + ",").Contains(", " + t + ",") ||
        ("," + p.Categories + ",").Contains("," + t + " ,") ||
        ("," + p.Categories + ",").Contains(", " + t + " ,"));