Query Objects with Repository Pattern Part 2


As promised in my previous post, I’m going to make our query object a little more flexible and dynamic.

First, this is what I really want to be able to do something like this:

var customers = repo.FindBy(
                new TopCustomersWithLowDiscountQuery()
                    .IncludePreferred()
                    .BelowDiscountThreshold(3)
                    .WithMoreSalesThan(500)
                    .As_Expression()
);

Better yet, maybe even something like this:

var customers = repo.FindBy(
        new TopCustomersWithLowDiscountQuery()
            .IncludePreferred()
            .BelowDiscountThreshold(3)
            .WithMoreSalesThan(500)
        .AndAlso(
            new DeliquentCustomersQuery()
                .WithDebtOver(9999))
            
);

</p>

Strongly typed query objects, completely testable outside of the repository, chain-able together with other like-typed query objects using AndAlso or OrElse.

Ok, now how do we do it?

Expression Tree Helper (Naive)

First, I started with an extension method class to make dealing with some of the Expression<Func<ENTITY, bool» expressions (which can get old to type) easier.  What I needed was the ability to take two expressions and AndAlso or OrElse them together.  AndAlso (&&) and OrElse (   ) are both binary expressions represented by the BinaryExpression class in System.Linq.Expressions.  You can combine two expressions together with any binary expression type by using the Expression.MakeBinary() method.  One problem though is that both Expressions start with a different parameter (i.e. the ‘c’ in (c=>c.AnnualSales > 999)).  So you can’t just join them together because, unfortunately, the Expression Tree compiler will get the ‘c’ parameters jumbled and it won’t work.

The way I found to deal with this problem is to basically wrap an ‘Invoke()’ expression around the other lambda using the first lambda’s parameter.  In C# parlance, it’s the difference between these two things:

c => c.AnnualSales > 99 && c.Discount < 4
    -- VS.--
c => c.AnnualSales > 99 && new Func<Customer, bool>(x=> x.Discount < 4)(c));

</p> </p> </p> </p> </p> </p> </p> </p> </p> </p>

See how the second one actually involves wrapping the other Lambda (.Discount < 4) with a function and then invokes it?  I’m not sure if that’s EXACTLY what goes on when you use Expression.Invoke(), but that’s what I like to tell myself when I’m working with Expression Trees. It also helps to keep me from ending up in the corner in the fetal position drooling and babbling incoherently which is, unfortunately, a frequent occurrence when dealing with Expression Trees.

You may notice that I put the condition “naive” on this section. This is because my expression tree helper is very naive and doesn’t account for a lot of the crazy things you can do with Expression Trees. This means that you will probably bump against its limitations and have problems. Sorry in advance for this, but I’m stretching the limits of my knowledge here and doing well to write coherently about it. If you have problems, let me know and maybe we can work it out together.

Anyhow, once you’ve invoked the other lambda, you can join them together with whatever binary expression you want, and then you have to re-wrap them in a Lambda again in order to continue working with it.  Without further ado, here’s my expression helper extension methods:

public static class ExpressionHelpers
{
    public static Expression<Func<T, bool>> AndAlso<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        return BinaryOnExpressions(left, ExpressionType.AndAlso, right);
    }

    public static Expression<Func<T, bool>> OrElse<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        return BinaryOnExpressions(left, ExpressionType.OrElse, right);
    }
        

    public static Expression<Func<T, bool>> BinaryOnExpressions<T>(
        this Expression<Func<T, bool>> left,
        ExpressionType binaryType,
        Expression<Func<T, bool>> right)
    {
        // Invoke that lambda with my parameter and give me the bool back, KKTHX
        var rightInvoke = Expression.Invoke(right, left.Parameters.Cast<Expression>());

        // make a binary expression between the results (i.e. AndAlso(&&), OrElse(||), etc)
        var binExpression = Expression.MakeBinary(binaryType, left.Body, rightInvoke);

        // Wrap it in a lambda and send it back
        return Expression.Lambda<Func<T, bool>>(binExpression, left.Parameters);
    }
}

 

With that out of the way, we can get on to the less complicated stuff which is chaining all these things together.  The next thing I did was to create a simple abstract base class for my query objects (I’m sure there’s a million better ways to do this, but to get things running, this was the simplest thing that worked for right now).

Query Base Class

The query base is quite simple, actually. It just shuffles around the expressions and provides some convenience methods for you to chain them together:

public abstract class QueryBase<ENTITY>
{
    private Expression<Func<ENTITY, bool>> _curExpression;

    public Expression<Func<ENTITY, bool>> AsExpression()
    {
        return _curExpression;
    }

    public Expression<Func<ENTITY, bool>> AndAlso(QueryBase<ENTITY> otherQuery)
    {
        AsExpression().AndAlso(otherQuery.AsExpression());
    }

    public Expression<Func<ENTITY, bool>> OrElse(QueryBase<ENTITY> otherQuery)
    {
        AsExpression().OrElse(otherQuery.AsExpression());
    }

    protected void addCriteria(Expression<Func<ENTITY, bool>> nextExpression)
    {
        _curExpression = (_curExpression == null)
                            ? nextExpression
                            : _curExpression.AndAlso(nextExpression);
    }
}

</p> </p> </p> </p> </p>

You can AND and OR two queries together (must be of the same type, for now).  Sub-classes can add their own expressions in a nice, easy-to-use lambda expression style.

Query Implementation

And now, on to one of the actual query classes. Remember our ridiculously named and implemented TopCustomersWithLowDiscountQuery from my last post?  Here it is in its more simplified form complete with Fluent-API bonus material:

public class TopCustomersWithLowDiscountQuery : QueryBase<Customer>
{
    public TopCustomersWithLowDiscountQuery IncludePreferred()
    {
        addCriteria(c => c.Preferred);
        return this;
    }

    public TopCustomersWithLowDiscountQuery BelowDiscountThreshold(decimal discountThresh)
    {
        addCriteria(c => c.Discount < discountThresh);
        return this;
    }

    public TopCustomersWithLowDiscountQuery WithMoreSalesThan(int salesThresh)
    {
        addCriteria(c => c.AnnualSales > salesThresh);
        return this;
    }
}

To use it, just chain the methods together. Consider this test case:

[Test]
public void low_discount_high_sales_customers_should_be_selected()
{
    _query = new TopCustomersWithLowDiscountQuery()
        .BelowDiscountThreshold(3)
        .WithMoreSalesThan(500);

    var high = 15m;
    var low = 1m;

    _customers.AddRange(new[]
    {
        new Customer {Id = 1, Discount = low, AnnualSales = 200},
        new Customer {Id = 2, Discount = low, AnnualSales = 800},
        new Customer {Id = 3, Discount = high, AnnualSales = 1000}
    });

    Results.Count().ShouldEqual(1);
    Results.ElementAt(0).Id.ShouldEqual(2);
}

 

If you want to chain them together, just use the AndAlso or OrElse methods.  Consider this other test case which uses OrElse:

[Test]
public void preferred_customers_that_are_also_bad()
{
    _query = new TopCustomersWithLowDiscountQuery()
        .IncludePreferred();

    var otherQuery = new DeliquentCustomersQuery()
        .WithPendingLitigation()
        .WithDebtOver(999);

    _customers.AddRange(new[]
    {
        new Customer {Id = 1, Preferred = true, PendingLitigation = false, OutstandingDebts = 4000},
        new Customer {Id = 2, Preferred = false},
        new Customer {Id = 3, Preferred = false,  PendingLitigation = true, OutstandingDebts = 4000}
    });

    var results = _customers
        .AsQueryable()
        .Where(_query.OrElse(otherQuery));

    results.Count().ShouldEqual(2);
    results.ElementAt(0).Id.ShouldEqual(1);
    results.ElementAt(1).Id.ShouldEqual(3);
}
Query Objects with the Repository Pattern