0

Is it possible to dynamically build an IQueryable/Linq Expression with filtering criteria based on a NESTED/Child List Object's Property.

I have not included all code here - particularly the code around Pagination but I hope there is sufficient detail. Things to note is my use of EFCore5 and Automapper ProjectTo extension method.

For Example:


    public class PersonModel
    {
        public int Id { get; set; }
        public PersonName Name { get; set; }
        public List<Pet> Pets { get; set; }
    }

    [Owned]
    public class PersonName
    {
        public string Surname { get; set; }
        public string GivenNames { get; set; }
    }


    public class Pet
    {
        public string Name { get; set; }
        public string TypeOfAnimal { get; set; }
    }

Here is my WebApi Controller.

     [HttpGet(Name = nameof(GetAllPersons))]
     public async Task<ActionResult<IEnumerable<PersonDTO>>> GetAllPersons(
            [FromQuery] QueryStringParameters parameters)
        {
            IQueryable<Person> persons = _context.Persons;

            parameters.FilterClauses.ForEach(filter =>
                persons = persons.Where(filter.name, filter.op, filter.val));
            // Note the use of 'Where' Extension Method.

            var dTOs = persons
                .ProjectTo<PersonDTO>(_mapper.ConfigurationProvider);;

            var pagedPersons = PaginatedList<PersonDTO>
                .CreateAsync(dTOs, parameters);

            return Ok(await pagedPersons);

        }

To query for all people with a Name.GivenNames property equal to "John" I would issue a GET call such as;

http://127.0.0.1/api/v1.0/?Filter=Name.GivenNames,==,John

This works perfectly fine.

However I would like to query for all people with a Pet with a Name property equal to "Scruffy" I would issue a GET call such as;

http://127.0.0.1/api/v1.0/?Filter=Pets.Name,==,Scruffy

Somewhat expectedly it throws the following exception on the line of code in BuildPredicate Function. This is because "Pets" is Type is a "List"... not a "Pet"

 var left = propertyName.Split... 

 Instance property 'Pet:Name' is not defined for type 
 System.Collections.Generic.List`1[Person]' (Parameter 'propertyName')

Here are the Extension Methods.


public static class ExpressionExtensions
    {

        public static IQueryable<T> Where<T>(this IQueryable<T> source, string propertyName, string comparison, string value)
        {
            return source.Where(BuildPredicate<T>(propertyName, comparison, value));
        }
    }

         public static Expression<Func<T, bool>> BuildPredicate<T>(string propertyName, string comparison, string value)
        {
            var parameter = Expression.Parameter(typeof(T), "x");
            var left = propertyName.Split('.').Aggregate((Expression)parameter, Expression.Property);
            var body = MakeComparison(left, comparison, value);
            return Expression.Lambda<Func<T, bool>>(body, parameter);
        }

        private static Expression MakeComparison(Expression left, string comparison, string value)
        {
            switch (comparison)
            {
                case "==":
                    return MakeBinary(ExpressionType.Equal, left, value);
                case "!=":
                    return MakeBinary(ExpressionType.NotEqual, left, value);
                case ">":
                    return MakeBinary(ExpressionType.GreaterThan, left, value);
                case ">=":
                    return MakeBinary(ExpressionType.GreaterThanOrEqual, left, value);
                case "<":
                    return MakeBinary(ExpressionType.LessThan, left, value);
                case "<=":
                    return MakeBinary(ExpressionType.LessThanOrEqual, left, value);
                case "Contains":
                case "StartsWith":
                case "EndsWith":
                    return Expression.Call(MakeString(left), comparison, Type.EmptyTypes, Expression.Constant(value, typeof(string)));
                default:
                    throw new NotSupportedException($"Invalid comparison operator '{comparison}'.");
            }
        }

        private static Expression MakeString(Expression source)
        {
            return source.Type == typeof(string) ? source : Expression.Call(source, "ToString", Type.EmptyTypes);
        }

        private static Expression MakeBinary(ExpressionType type, Expression left, string value)
        {
            object typedValue = value;
            if (left.Type != typeof(string))
            {
                if (string.IsNullOrEmpty(value))
                {
                    typedValue = null;
                    if (Nullable.GetUnderlyingType(left.Type) == null)
                        left = Expression.Convert(left, typeof(Nullable<>).MakeGenericType(left.Type));
                }
                else
                {
                    var valueType = Nullable.GetUnderlyingType(left.Type) ?? left.Type;                  
                    typedValue = valueType.IsEnum ? Enum.Parse(valueType, value) :
                        valueType == typeof(Guid) ? Guid.Parse(value) :
                        valueType == typeof(DateTimeOffset) ? DateTimeOffset.ParseExact(value, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal) :
                        Convert.ChangeType(value, valueType);                    
                }
            }
            var right = Expression.Constant(typedValue, left.Type);
            return Expression.MakeBinary(type, left, right);
        }



Is there anyway to adapt this code to detect that if one of the nested properties is a LIST that it builds an 'Inner Predicate' to do a query on the child collection? ie: Enumerable.Any() ?

1

1 Answer 1

1

Working with raw expression tree's, it sometimes helps to start with an example, let the C# compiler have a go at it, and work backwards. eg;

Expression<Func<Person,bool>> expr = p => p.Pets.Any(t => t.Foo == "blah");

Though the compiler does take a shortcut in IL to specify type members that can't be decompiled.

The trick here is to make your method recursive. Instead of assuming that you can get each property;

var left = propertyName.Split('.').Aggregate((Expression)parameter, Expression.Property);

If you find a collection property in the list, you need to call BuildPredicate<Pet> with the remaining property string. Then use the return value as the argument to call .Pets.Any(...).

Perhaps something like;

public static Expression<Func<T, bool>> BuildPredicate<T>(string propertyName, string comparison, string value)
    => (Expression<Func<T, bool>>)BuildPredicate(typeof(T), propertyName.Split('.'), comparison, value);

public static LambdaExpression BuildPredicate(Type t, Span<string> propertyNames, string comparison, string value)
{
    var parameter= Expression.Parameter(t, "x");
    var p = (Expression)parameter;
    for(var i=0; i<propertyNames.Length; i++)
    {
        var method = p.Type.GetMethods().FirstOrDefault(m => m.Name == "GetEnumerator" && m.ReturnType.IsGenericType);
        if (method != null)
        {
            BuildPredicate(method.ReturnType.GetGenericArguments()[0], propertyNames.Slice(i), comparison, value);
            // TODO ...
        }
        else
            p = Expression.Property(p, propertyNames[i]);
    }
    // TODO ...
    return Expression.Lambda(body, parameter);
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.