Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create an Expression builder in .NET

I have written some code to allow filtering of products on our website, and I am getting a pretty bad code smell. The user can select 1-* of these filters which means I need to be specific with the WHERE clause.

I think I am looking for a way to build up a lambda expression, so for every filter I can 'modify' my WHERE clause - but I am not sure how to do this in .NET, and there must be a way.

Code in its current state (effectively hardcoded, not dynamic, would be a pain to add more filter options).

public static class AgeGroups
{
    public static Dictionary<string, int> Items = new Dictionary<string, int>(){
        { "Modern (Less than 10 years old)", 1 },
        { "Retro (10 - 20 years old)", 2 },
        { "Vintage(20 - 70 years old)", 3 },
        { "Antique(70+ years old)", 4 }
    };

    public static IQueryable<ProductDTO> FilterAgeByGroup(IQueryable<ProductDTO> query, List<string> filters)
    {
        var values = new List<int>();
        var currentYear = DateTime.UtcNow.Year;
        foreach (var key in filters)
        {
            var matchingValue = Items.TryGetValue(key, out int value);

            if (matchingValue)
            {
                values.Add(value);
            }
        }

        if (Utility.EqualsIgnoringOrder(values, new List<int> { 1 }))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 10);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 2 }))
        {
            query = query.Where(x => x.YearManufactured <= currentYear - 10 && x.YearManufactured >= currentYear - 20);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 3 }))
        {
            query = query.Where(x => x.YearManufactured <= currentYear - 20 && x.YearManufactured >= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 4 }))
        {
            query = query.Where(x => x.YearManufactured <= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 2}))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 20);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 3 }))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 10 || (x.YearManufactured <= currentYear - 20 && x.YearManufactured >= currentYear - 70));
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 4 }))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 10 ||  x.YearManufactured <= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 2, 3 }))
        {
            query = query.Where(x => x.YearManufactured <= currentYear - 10 && x.YearManufactured >= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 2, 4 }))
        {
            query = query.Where(x => (x.YearManufactured <= currentYear - 10 && x.YearManufactured >= currentYear - 20) 
                                     || x.YearManufactured <= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 2, 3 }))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 2, 4 }))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 20 || x.YearManufactured <= currentYear - 70);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 2, 3, 4}))
        {
            query = query.Where(x => x.YearManufactured <= currentYear - 10);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 3, 4}))
        {
            query = query.Where(x => x.YearManufactured >= currentYear - 10 || x.YearManufactured <= 20);
        }
        else if (Utility.EqualsIgnoringOrder(values, new List<int> { 1, 2, 3, 4 }))
        {
            // all
        }
        return query;
    }
}
like image 829
aspirant_sensei Avatar asked Feb 14 '19 19:02

aspirant_sensei


2 Answers

I've recently run into this issue myself. Through the help of another question on SO I found http://www.albahari.com/nutshell/predicatebuilder.aspx. Basically you want to build a predicate and pass that into the where clause of your query.

public static Expression<Func<T, bool>> Or<T>(this Expression<Func<T, bool>> where1, 
     Expression<Func<T, bool>> where2)
{
    InvocationExpression invocationExpression = Expression.Invoke(where2, 
         where1.Parameters.Cast<Expression>());
    return Expression.Lambda<Func<T, bool>>(Expression.OrElse(where1.Body, 
         invocationExpression), where1.Parameters);
}

public static IQueryable<ProductDTO> FilterAgeByGroup(IQueryable<ProductDTO> query,  
   List<string> filters, int currentYear)
{
    var values = new HashSet<int>();
    //Default value
    Expression<Func<ProductDTO, bool>> predicate = (ProductDTO) => false;

    foreach (var key in filters)
    {
        var matchingValue = Items.TryGetValue(key, out int value);

        if (matchingValue)
        {
            values.Add(value);
        }
    }

    if (values.Count == 0)
        return query;

    if (values.Contains(1))
    {
        predicate = predicate.Or(x => x.YearManufactured >= currentYear - 10);
    }

    if (values.Contains(2))
    {
        predicate = predicate.Or(x => x.YearManufactured <= currentYear - 10 && 
            x.YearManufactured >= currentYear - 20);
    }

    if (values.Contains(3))
    {
        predicate = predicate.Or(x => x.YearManufactured <= currentYear - 20 && 
            x.YearManufactured >= currentYear - 70);
    }

    if (values.Contains(4))
    {
        predicate = predicate.Or(x => x.YearManufactured <= currentYear - 70);
    }

    return query.Where(predicate);
}
like image 164
blander Avatar answered Sep 17 '22 21:09

blander


It looks like you faced combinatorial explosion. You may declare simple cases statically with modified Items collection:

static Dictionary<string, Expression<Func<int, int, bool>>> Items
   = new Dictionary<string, Expression<Func<int, int, bool>>>
{
    {
      "Modern (Less than 10 years old)",
      (yearManufactured, currentYear) => yearManufactured >= currentYear - 10
    },
    {
      "Retro (10 - 20 years old)",
      (yearManufactured, currentYear) => yearManufactured <= currentYear - 10 && yearManufactured >= currentYear - 20
    },
    {
      "Vintage(20 - 70 years old)",
      (yearManufactured, currentYear) => yearManufactured <= currentYear - 20 && yearManufactured >= currentYear - 70
    },
    {
      "Antique(70+ years old)",
      (yearManufactured, currentYear) => yearManufactured <= currentYear - 70
    }
};

Now you can dynamically combine your simple cases with Linq Expression OrElse. Try this code:

public static IQueryable<ProductDTO> FilterAgeByGroup(
  IQueryable<ProductDTO> query, List<string> filters)
{
    var conditions = new List<Expression>();
    foreach (var key in filters)
        if (Items.TryGetValue(key, out Expression<Func<int, int, bool>> value))
            conditions.Add(value);

    // return as is if there no conditions
    if (!conditions.Any())
        return query;

    var x = Expression.Parameter(typeof(ProductDTO), "x");
    var yearManufactured = Expression.PropertyOrField(x, "YearManufactured");
    var currentYear = Expression.Constant(DateTime.UtcNow.Year);
    var body = conditions.Aggregate(
        (Expression) Expression.Constant(false), // ignore item by default
        (c, n) => Expression.OrElse(c, Expression.Invoke(n, yearManufactured, currentYear)));

    var lambda = Expression.Lambda<Func<ProductDTO, bool>>(body, x);
    return query.Where(lambda);
}
like image 27
Aleks Andreev Avatar answered Sep 19 '22 21:09

Aleks Andreev