Search code examples
c#entity-frameworklinqlambdaexpression-trees

Generate Multi-Parameter LINQ Search Queries with Run-time Specified Return Type


Having spent a long time solving this problem, I wanted to share the solution.

Background

I maintain a large web application with the primary function of managing orders. It is an MVC over C# application using EF6 for data.

There are LOTS of search screens. The search screens all have multiple parameters and return different object types.

The Problem

Every search screen had:

  • A ViewModel with the search parameters
  • A Controller method to handle the Search event
  • A method to pull the correct data for that screen
  • A method to apply all the search filters to the dataset
  • A method to convert the results into a NEW results ViewModel
  • The Results ViewModel

This adds up quickly. We have about 14 different search screens, which means about 84 models & methods to handle these searches.

My Goal

I wanted to be able to create a class, analogous to the current search parameter ViewModel, that would inherit from a base SearchQuery class such that my Controller could simply trigger the search to run to populate a Results field of the same object.

An Example of My Ideal State (Because It's a Bear To Explain)

Take the following class structure:

public class Order
{
    public int TxNumber;
    public Customer OrderCustomer;
    public DateTime TxDate;
}

public class Customer
{
    public string Name;
    public Address CustomerAddress;
}

public class Address
{
    public int StreetNumber;
    public string StreetName;
    public int ZipCode;
}

Let's assume I have lots of those records in a queryable format--an EF DBContext object, an XML object, whatever--and I want to search them. First, I create a derived class specific to my ResultType(in this case, Order).

public class OrderSearchFilter : SearchQuery
{
    //this type specifies that I want my query result to be List<Order>
    public OrderSearchFilter() : base(typeof(Order)) { }

    [LinkedField("TxDate")]
    [Comparison(ExpressionType.GreaterThanOrEqual)]
    public DateTime? TransactionDateFrom { get; set; }

    [LinkedField("TxDate")]
    [Comparison(ExpressionType.LessThanOrEqual)]
    public DateTime? TransactionDateTo { get; set; }

    [LinkedField("")]
    [Comparison(ExpressionType.Equal)]
    public int? TxNumber { get; set; }

    [LinkedField("Order.OrderCustomer.Name")]
    [Comparison(ExpressionType.Equal)]
    public string CustomerName { get; set; }

    [LinkedField("Order.OrderCustomer.CustomerAddress.ZipCode")]
    [Comparison(ExpressionType.Equal)]
    public int? CustomerZip { get; set; }
}

I use attributes to specify what field/property of the target ResultType any given search field is linked to, as well as the comparison type (== < > <= >= !=). A blank LinkedField means that the name of the search field is the same as the name of the target object field.

With this configured, the only things I should need for a given search are:

  • A populated search object like the one above
  • A data source

No other scenario-specific coding should be required!


Solution

  • The Solution

    For starters, we create:

    public abstract class SearchQuery 
    {
        public Type ResultType { get; set; }
        public SearchQuery(Type searchResultType)
        {
            ResultType = searchResultType;
        }
    }
    

    We'll also create the attributes we used above to define the search field:

        protected class Comparison : Attribute
        {
            public ExpressionType Type;
            public Comparison(ExpressionType type)
            {
                Type = type;
            }
        }
    
        protected class LinkedField : Attribute
        {
            public string TargetField;
            public LinkedField(string target)
            {
                TargetField = target;
            }
        }
    

    For each search field, we'll need to know not only WHAT search is done, but also WHETHER the search is done. For example, if the value of "TxNumber" is null, we wouldn't want to run that search. So we create a SearchField object that contains, in addition to the actual search value, two expressions: one that represents performing the search, and one that validates whether the search should be applied.

        private class SearchFilter<T>
        {
            public Expression<Func<object, bool>> ApplySearchCondition { get; set; }
            public Expression<Func<T, bool>> SearchExpression { get; set; }
            public object SearchValue { get; set; }
    
            public IQueryable<T> Apply(IQueryable<T> query)
            {
                //if the search value meets the criteria (e.g. is not null), apply it; otherwise, just return the original query.
                bool valid = ApplySearchCondition.Compile().Invoke(SearchValue);
                return valid ? query.Where(SearchExpression) : query;
            }
        }
    

    Once we have created all our filters, all we need to do is loop through them and call the "Apply" method on our dataset! Easy!

    The next step is creating the validation expressions. We'll do this based on the Type; every int? is validated the same as every other int?.

        private static Expression<Func<object, bool>> GetValidationExpression(Type type)
        {
            //throw exception for non-nullable types (strings are nullable, but is a reference type and thus has to be called out separately)
            if (type != typeof(string) && !(type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)))
                throw new Exception("Non-nullable types not supported.");
    
            //strings can't be blank, numbers can't be 0, and dates can't be minvalue
            if (type == typeof(string   )) return t => !string.IsNullOrWhiteSpace((string)t);
            if (type == typeof(int?     )) return t => t != null && (int)t >= 0;
            if (type == typeof(decimal? )) return t => t != null && (decimal)t >= decimal.Zero;
            if (type == typeof(DateTime?)) return t => t != null && (DateTime?)t != DateTime.MinValue;
    
            //everything else just can't be null
            return t => t != null;
        }
    

    This was all I needed for my application, but there is definitely more validation that could be done.

    The search expression is slightly more complicated and required a parser to "De-qualify" Field/Property names (there's probably a better word, but if so, I don't know it). Basically, if I specified "Order.Customer.Name" as a linked field and I'm searching through Orders, I need to turn that into "Customer.Name" because there is no Order Field inside an Order object. Or at least I hope not. :) This isn't certain, but I considered it better to accept and correct fully-qualified object names than to support that edge case.

        public static List<string> DeQualifyFieldName(string targetField, Type targetType)
        {
            var r = targetField.Split('.').ToList();
            foreach (var p in targetType.Name.Split('.'))
                if (r.First() == p) r.RemoveAt(0);
            return r;
        }
    

    This is just straight text parsing, and returns the Field name in "levels" (e.g. "Customer"|"Name").

    All right, let's get our search expression together.

        private Expression<Func<T, bool>> GetSearchExpression<T>(
            string targetField, ExpressionType comparison, object value)
        {
            //get the property or field of the target object (ResultType)
            //which will contain the value to be checked
            var param = Expression.Parameter(ResultType, "t");
            Expression left = null;
            foreach (var part in DeQualifyFieldName(targetField, ResultType))
                left = Expression.PropertyOrField(left == null ? param : left, part);
    
            //Get the value against which the property/field will be compared
            var right = Expression.Constant(value);
    
            //join the expressions with the specified operator
            var binaryExpression = Expression.MakeBinary(comparison, left, right);
            return Expression.Lambda<Func<T, bool>>(binaryExpression, param);
        }
    

    Not so bad! What we're trying to create is, for example:

    t => t.Customer.Name == "Searched Name"
    

    Where t is our ReturnType--an Order, in this case. First we create the parameter, t. Then, we loop through the parts of the property/field name until we have the full title of the object we're targeting (naming it "left" because it's the left side of our comparison). The "right" side of our comparison is simple: the constant provided by the user.

    Then we create the binary expression and turn it into a lambda. Easy as falling off a log! If falling off a log required countless hours of frustration and failed methodologies, anyway. But I digress.

    We've got all the pieces now; all we need is a method to assemble our query:

        protected IQueryable<T> ApplyFilters<T>(IQueryable<T> data)
        {
            if (data == null) return null;
            IQueryable<T> retVal = data.AsQueryable();
    
            //get all the fields and properties that have search attributes specified
            var fields = GetType().GetFields().Cast<MemberInfo>()
                                  .Concat(GetType().GetProperties())
                                  .Where(f => f.GetCustomAttribute(typeof(LinkedField)) != null)
                                  .Where(f => f.GetCustomAttribute(typeof(Comparison)) != null);
    
            //loop through them and generate expressions for validation and searching
            try
            {
                foreach (var f in fields)
                {
                    var value = f.MemberType == MemberTypes.Property ? ((PropertyInfo)f).GetValue(this) : ((FieldInfo)f).GetValue(this);
                    if (value == null) continue;
                    Type t = f.MemberType == MemberTypes.Property ? ((PropertyInfo)f).PropertyType : ((FieldInfo)f).FieldType;
                    retVal = new SearchFilter<T>
                    {
                        SearchValue = value,
                        ApplySearchCondition = GetValidationExpression(t),
                        SearchExpression = GetSearchExpression<T>(GetTargetField(f), ((Comparison)f.GetCustomAttribute(typeof(Comparison))).Type, value)
                    }.Apply(retVal); //once the expressions are generated, go ahead and (try to) apply it
                }
            }
            catch (Exception ex) { throw (ErrorInfo = ex); }
            return retVal;
        }
    

    Basically, we just grab a list of fields/properties in the derived class (that are linked), create a SearchFilter object from them, and apply them.

    Clean-Up

    There's a bit more, of course. For example, we're specifying object links with strings. What if there's a typo?

    In my case, I have the class check whenever it spins up an instance of a derived class, like this:

        private bool ValidateLinkedField(string fieldName)
        {
            //loop through the "levels" (e.g. Order / Customer / Name) validating that the fields/properties all exist
            Type currentType = ResultType;
            foreach (string currentLevel in DeQualifyFieldName(fieldName, ResultType))
            {
                MemberInfo match = (MemberInfo)currentType.GetField(currentLevel) ?? currentType.GetProperty(currentLevel);
                if (match == null) return false;
                currentType = match.MemberType == MemberTypes.Property ? ((PropertyInfo)match).PropertyType
                                                                       : ((FieldInfo)match).FieldType;
            }
            return true; //if we checked all levels and found matches, exit
        }
    

    The rest is all implementation minutia. If you're interested in checking it out, a project that includes a full implementation, including test data, is here. It's a VS 2015 project, but if that's an issue, just grab the Program.cs and Search.cs files and throw them into a new project in your IDE of choice.

    Thanks to everyone on StackOverflow who asked the questions and wrote the answers that helped me put this together!