0

I'm creating some extension methods on IQueryable to make wildcard filtering easier. But I'm stumbling into a lot of exceptions when I try to filter a sub list. My example:

public class User {
  public string FirstName { get; set; }
  public string LastName { get; set; }
  public IReadOnlyList<Address> Addresses { get; set; }
  ...
}

public class Address {
  public string Street { get; set; }
  ...
}

My wildcard implementation speaks for itself, the method expects a list of values and supports StartsWith, EndsWith and Contains. I have a Filter method that looks like this:

public static IQueryable<T> Filter<T>(this IQueryable<T> query, Expression<Func<T, string>> property,
    IList<string> values)
{
    if (values == null || values.Count == 0)
        return query;

    Expression<Func<T, bool>> condition;
    if (values.Count == 1)
        condition = GetBooleanExpressionFromString(property, values.First()).Expand();
    else
        condition = GetBooleanExpressionFromStringList(property, values).Expand();

    return query.Where(condition);
}

And the expressions builders look like:

private static Expression<Func<T, bool>> GetBooleanExpressionFromStringList<T>(
            Expression<Func<T, string>> expression,
            IList<string> values)
{
    var predicate = PredicateBuilder.New<T>();
    foreach (var value in values)
    {
        var parsedValue = value.Replace("*", string.Empty);
        if (value.StartsWith("*") && value.EndsWith("*"))
            predicate.Or(x => expression.Invoke(x).Contains(parsedValue));

        else if (value.StartsWith("*"))
            predicate.Or(x => expression.Invoke(x).EndsWith(parsedValue));

        else if (value.EndsWith("*"))
            predicate.Or(x => expression.Invoke(x).StartsWith(parsedValue));

        else predicate.Or(x => expression.Invoke(x) == parsedValue);
    }

    return predicate;
}


public static Expression<Func<T, bool>> GetBooleanExpressionFromString<T>(
    Expression<Func<T, string>> expression,
    string value)
{
    var parsedValue = value.Replace("*", string.Empty);
    if (value.StartsWith("*") && value.EndsWith("*"))
        return x => expression.Invoke(x).Contains(parsedValue);

    if (value.StartsWith("*"))
        return x => expression.Invoke(x).EndsWith(parsedValue);

    if (value.EndsWith("*"))
        return x => expression.Invoke(x).StartsWith(parsedValue);

    return x => expression.Invoke(x) == parsedValue;
}

At the end I can use the Filter method like this:

var query = Session.Query<User>();
query = query.Filter(x => x.FirstName, new [] { "Foo*", "*Bar", "*test*" });
return query;

Now I want to make the same extension for the Street property of the Address list. In normal Linq it would compile to query.Where(x => x.Addresses.Any(y => y.Street.*wildcardstuff*)) and the Filter method would be called like query.Filter(x => x.Addresses, x => x.Street, values). But I'm keep getting NotSupported exceptions. The last thing I tried is this post from EF but its not that typed like i want it to be.

The last implementation I tried is this one:

public static IQueryable<T> FilterList<T, U>(this IQueryable<T> query, Expression<Func<T, IEnumerable<U>>> innerList, Expression<Func<U, string>> property,
    IList<string> values)
{
    if (values == null || values.Count == 0)
        return query;
    
    Expression<Func<U, bool>> condition;
    if (values.Count == 1)
        condition = GetBooleanExpressionFromString(property, values.First()).Expand();
    else
        condition = GetBooleanExpressionFromStringList(property, values).Expand();

    return query.Where(i => innerList.Invoke(i).Any(j => condition.Invoke(j)));
}

but got this exception: System.NotSupportedException: Cannot parse expression 'x => x.Addresses' as it has an unsupported type. Only query sources (that is, expressions that implement IEnumerable) and query operators can be parsed. I tried to make an extension on string with the wildcard logic but this throws also a NotSupported exception, anyone has an idea?

Stutje
  • 745
  • 6
  • 21

2 Answers2

1

I would make it more universal. Which may simplify creating such extensions.

query = query.FilterByWildcard(x => x.FirstName, new [] { "Foo*", "*Bar", "*test*" });

And realization:

public static class QueryExtensions
{
    public static IQueryable<T> FilterByWildcard<T>(this IQueryable<T> query, Expression<Func<T, string>> prop, IEnumerable<string> items)
    {
        return query.FilterBy(items, prop, s =>
        {
            var pattern = s.Trim('*');
            if (s.StartsWith("*"))
                if (s.EndsWith("*"))
                    return e => e.Contains(pattern);
                else
                    return e => e.StartsWith(pattern);
            else if (s.EndsWith("*"))
                return e => e.EndsWith(pattern);
            else
                return e => e == s;

        });
    }

    public static IQueryable<T> FilterBy<T, TProp, TItem>(this IQueryable<T> query,
        IEnumerable<TItem> items,
        Expression<Func<T, TProp>> prop,
        Func<TItem, Expression<Func<TProp, bool>>> operationSelector, bool isOr = true)
    {
        var param = prop.Parameters[0];
        Expression predicate = null;

        foreach (var item in items)
        {
            var operation = operationSelector(item);
            var body = ExpressionReplacer.GetBody(operation, prop.Body);

            if (predicate == null)
            {
                predicate = body;
            }
            else
            {
                predicate = Expression.MakeBinary(isOr ? ExpressionType.OrElse : ExpressionType.AndAlso, predicate,
                    body);
            }
        }

        if (predicate == null)
            return query.Where(e => 1 == 2);

        var lambda = Expression.Lambda<Func<T, bool>>(predicate, param);

        return query.Where(lambda);
    }

    class ExpressionReplacer : ExpressionVisitor
    {
        readonly IDictionary<Expression, Expression> _replaceMap;

        public ExpressionReplacer(IDictionary<Expression, Expression> replaceMap)
        {
            _replaceMap = replaceMap ?? throw new ArgumentNullException(nameof(replaceMap));
        }

        public override Expression Visit(Expression exp)
        {
            if (exp != null && _replaceMap.TryGetValue(exp, out var replacement))
                return replacement;
            return base.Visit(exp);
        }

        public static Expression Replace(Expression expr, Expression toReplace, Expression toExpr)
        {
            return new ExpressionReplacer(new Dictionary<Expression, Expression> {{toReplace, toExpr}}).Visit(expr);
        }

        public static Expression Replace(Expression expr, IDictionary<Expression, Expression> replaceMap)
        {
            return new ExpressionReplacer(replaceMap).Visit(expr);
        }
        
        public static Expression GetBody(LambdaExpression lambda, params Expression[] toReplace)
        {
            if (lambda.Parameters.Count != toReplace.Length)
                throw new InvalidOperationException();

            return new ExpressionReplacer(lambda.Parameters.Zip(toReplace)
                .ToDictionary(e => (Expression)e.First, e => e.Second)).Visit(lambda.Body);
        }
    }

}
Svyatoslav Danyliv
  • 21,911
  • 3
  • 16
  • 32
0

This answer is fixing my own method but Svyatoslav Danyliv's answer is more futureproof.

public static IQueryable<T> FilterList<T, U>(this IQueryable<T> query, Expression<Func<T, IEnumerable<U>>> innerList, Expression<Func<U, string>> property,
    IList<string> values)
{
    if (values == null || values.Count == 0)
        return query;
    
    Expression<Func<U, bool>> condition;
    if (values.Count == 1)
        condition = GetBooleanExpressionFromString(property, values.First());
    else
        condition = GetBooleanExpressionFromStringList(property, values);

    Expression<Func<T, bool>> finalCondition = t => innerList.Invoke(t).Any(j => condition.Invoke(j));

    return query.Where(finalCondition.Expand());
}

And then use it like this.

var query = Session.Query<User>();
query = query.FilterList(x => x.Addresses, y => y.Street, new [] { "Foo*", "*Bar", "*test*" });
return query;

Which is shorter and cleaner but restricted.

Stutje
  • 745
  • 6
  • 21