65.9K
CodeProject 正在变化。 阅读更多。
Home

动态生成 Lambda 表达式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (118投票s)

2016年2月26日

CPOL

5分钟阅读

viewsIcon

218194

downloadIcon

4099

一个示例, 说明如何使用 LINQ 让用户构建自己的过滤器来查询列表甚至数据库

引言

您是否曾尝试为用户提供一种动态构建自己的查询以过滤列表的方法?如果您尝试过,也许觉得它有点复杂。如果您从未尝试过,请相信我,做到这一点可能至少是乏味的。但是,借助 LINQ 的帮助,这并不需要那么困难(事实上,它甚至可能很有趣)。

我在大学时用 Delphi 做过类似的事情(大约十二年前),在阅读了 Fitim Skenderi 的这篇精彩文章 后,我决定用 C# 和 LINQ 再次构建那个应用程序。

背景

让我们想象一下我们有这样的类

public enum PersonGender
{
    Male,
    Female
}

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public PersonGender Gender { get; set; }
    public BirthData Birth { get; set; }
    public List<Contact> Contacts { get; private set; }
    
    public class BirthData
    {
        public DateTime? Date { get; set; }
        public string Country { get; set; }
    }
}

public enum ContactType
{
    Telephone,
    Email
}

public class Contact
{
    public ContactType Type { get; set; }
    public string Value { get; set; }
    public string Comments { get; set; }
}

...然后我们必须构建一个像这样的表单的后端代码来过滤 Person 对象列表

除了用于填充下拉列表、添加带有新控件集的新行等的 UI 特定代码之外;大部分工作将在于构建查询以过滤您应用程序的数据库。鉴于如今大多数数据库驱动程序都支持 LINQ,我认为诉诸此资源是合理的。

上面图片中示例的 LINQ 表达式结果将类似于此

    p => (p.Id >= 2 && p.Id <= 4)
        && (p.Birth != null p.Birth.Country != null)
        && p.Contacts.Any(c => c.Value.EndsWith("@email.com")
        || (p.Name != null && p.Name.Trim().ToLower().Contains("john")))

由于本文的目标不是开发所提问题的用户界面,而是构建将查询数据库的 LINQ 表达式,因此我将不关注 UI。我提供的代码实现了这些功能,但它既没有得到很好的组织,也没有得到优化。所以,如果您出于其他原因需要这些代码,并且发现它有点混乱,请告诉我,以便我稍后清理它。

https://github.com/dbelmont/ExpressionBuilder

说了这些,让我们来看看实际的代码是如何使用的。

Using the Code

首先,让我们熟悉一下本文稍后会出现的表达式的某些部分

  • ParameterExpression (x):这是传递给主体的参数
  • MemberExpression (x.Id):这是参数类型的属性或字段
  • ConstantExpression (3):这是一个常量值

要构建像这样的表达式,我们需要这样的代码

using System.Linq.Expressions;

var parameter = Expression.Parameter(typeof(Person), "x");
var member = Expression.Property(parameter, "Id"); //x.Id
var constant = Expression.Constant(3);
var body = Expression.GreaterThanOrEqual(member, constant); //x.Id >= 3
var finalExpression = Expression.Lambda<Func<Person, bool>>(body, param); //x => x.Id >= 3

我们在这里可以看到,这个表达式的主体有三个基本组成部分:一个属性、一个操作和一个值。如果我们的查询是许多简单表达式的集合,就像这个一样,我们可以说我们的 Filter 将是一个 FilterStatement 列表,类似于此

/// <summary>
/// Defines a filter from which a expression will be built.
/// </summary>
public interface IFilter<TClass> where TClass : class
{
    /// <summary>
    /// Group of statements that compose this filter.
    /// </summary>
    List<IFilterStatement> Statements { get; set; }
    /// <summary>
    /// Builds a LINQ expression based upon the statements included in this filter.
    /// </summary>
    /// <returns></returns>
    Expression<Func<TClass, bool>> BuildExpression();
}

/// <summary>
/// Defines how a property should be filtered.
/// </summary>
public interface IFilterStatement
{
    /// <summary>
    /// Name of the property.
    /// </summary>
    string PropertyName { get; set; }
    /// <summary>
    /// Express the interaction between the property and the constant value 
    /// defined in this filter statement.
    /// </summary>
    Operation Operation { get; set; }
    /// <summary>
    /// Constant value that will interact with the property defined in this filter statement.
    /// </summary>
    object Value { get; set; }
}

public enum Operation
{
    EqualTo,
    Contains,
    StartsWith,
    EndsWith,
    NotEqualTo,
    GreaterThan,
    GreaterThanOrEqualTo,
    LessThan,
    LessThanOrEqualTo
}

回到我们的简单表达式,我们需要这样的代码来设置我们的过滤器

var filter = new Filter();
filter.Statements.Add(new FilterStatement("Id", Operation.GreaterThanOrEqualTo, 3));
filter.BuildExpression(); //this method will return the expression x => x.Id >= 3

然后,所有的乐趣就开始了。这将是 BuildExpression 方法的第一个实现

public Expression<Func<TClass, bool>> BuildExpression()
{
    //this is in case the list of statements is empty
    Expression finalExpression = Expression.Constant(true);
    var parameter = Expression.Parameter(typeof(TClass), "x");
    foreach (var statement in Statements)
    {
        var member = Expression.Property(parameter, statement.PropertyName);
        var constant = Expression.Constant(statement.Value);
        Expression expression = null;
        switch (statement.Operation)
        {
            case Operation.Equals:
    			expression = Expression.Equal(member, constant);
    			break;
    		case Operation.GreaterThanOrEquals:
    			expression = Expression.GreaterThanOrEqual(member, constant);
    			break;
    		///and so on...
    	}
    	
    	finalExpression = Expression.AndAlso(finalExpression, expression);
    }
    
    return finalExpression;
}

除了不完整的 switch 语句之外,此方法还有一些未涵盖的问题

  1. 它不处理内部类的属性
  2. 它不支持 OR 逻辑运算符
  3. 此方法的 圈复杂度 实在不怎么样
  4. ContainsStartsWithEndsWith 操作在 System.Linq.Expressions.Expression 类中没有等效方法
  5. string 的操作是区分大小写的(如果它们不区分大小写会更好)
  6. 它不支持过滤列表类型的属性

为了解决这些问题,我们需要修改 GetExpression 方法并采取其他一些措施

  1. 为了处理内部类的属性,我们还需要一个递归方法(一个调用自身的函数)
    MemberExpression GetMemberExpression(Expression param, string propertyName)
    {
        if (propertyName.Contains("."))
        {
            int index = propertyName.IndexOf(".");
            var subParam = Expression.Property(param, propertyName.Substring(0, index));
            return GetMemberExpression(subParam, propertyName.Substring(index + 1));
        }
        
        return Expression.Property(param, propertyName);
    }
  2. 为了支持 OR 逻辑运算符,我们将向 IFilterStatement 添加另一个属性,该属性将设置过滤器语句如何与下一个语句连接
    public enum FilterStatementConnector { And, Or }
    
    /// <summary>
    /// Defines how a property should be filtered.
    /// </summary>
    public interface IFilterStatement
    {
        /// <summary>
        /// Establishes how this filter statement will connect to the next one.
        /// </summary>
        FilterStatementConnector Connector { get; set; }
        /// <summary>
        /// Name of the property (or property chain).
        /// </summary>
        string PropertyName { get; set; }
        /// <summary>
        /// Express the interaction between the property and
        /// the constant value defined in this filter statement.
        /// </summary>
        Operation Operation { get; set; }
        /// <summary>
        /// Constant value that will interact with
        /// the property defined in this filter statement.
        /// </summary>
        object Value { get; set; }
    }
  3. 为了降低由 foreach 循环内的长 switch 语句(我们有九个case,每个操作一个)引入的圈复杂度,我们将创建一个 Dictionary 来将每个操作映射到其相应的表达式。我们还将利用 IFilterStatement 中的新属性以及新的 GetMemberExpression 方法
    //Func<Expression, Expression, Expression> means that this delegate expects two expressions 
    //(namely, a MemberExpression and a ConstantExpression) and returns another one. 
    //This will be clearer when you take a look at the complete/final code.
    readonly Dictionary<Operation, Func<Expression, Expression, Expression>> Expressions;
    
    //this would go inside the constructor
    Expressions = new Dictionary<Operation, 
    Func<Expression, Expression, Expression>>();
    Expressions.Add(Operation.EqualTo,
        (member, constant) => Expression.Equal(member, constant));
    Expressions.Add(Operation.NotEqualTo,
        (member, constant) => Expression.NotEqual(member, constant));
    Expressions.Add(Operation.GreaterThan,
        (member, constant) => Expression.GreaterThan(member, constant));
    Expressions.Add(Operation.GreaterThanOrEqualTo,
        (member, constant) => Expression.GreaterThanOrEqual(member, constant));
    Expressions.Add(Operation.LessThan,
        (member, constant) => Expression.LessThan(member, constant));
    Expressions.Add(Operation.LessThanOrEqualTo,
        (member, constant) => Expression.LessThanOrEqual(member, constant));
    
    public Expression<Func<TClass, bool>> BuildExpression()
    {
        //this is in case the list of statements is empty
        Expression finalExpression = Expression.Constant(true);
        var parameter = Expression.Parameter(typeof(TClass), "x");
        var connector = FilterStatementConnector.And;
        foreach (var statement in Statements)
        {
            //*** handling inner classes properties ***
            var member = GetMemberExpression(parameter, statement.PropertyName);
            var constant = Expression.Constant(statement.Value);
            //*** decreasing the cyclomatic complexity ***
            var expression = Expressions[statement.Operation].Invoke(member, constant);
            //*** supporting the OR logical operator ***
            if (statement.Conector == FilterStatementConector.And)
            {
                finalExpression = Expression.AndAlso(finalExpression, expression);
            }
            else
            {
                finalExpression = Expression.OrElse(finalExpression, expression);
            }
    
            //we must keep the connector of this filter statement to know 
            //how to combine the final expression with the next filter statement
            connector = statement.Connector;
        }
        
        return finalExpression;
    }
  4. 为了支持 ContainsStartsWithEndsWith 操作,我们将需要获取它们的 MethodInfo 并创建一个方法调用
    static MethodInfo containsMethod = typeof(string).GetMethod("Contains");
    static MethodInfo startsWithMethod = typeof(string)
        .GetMethod("StartsWith", new [] { typeof(string) });
    static MethodInfo endsWithMethod = typeof(string)
        .GetMethod("EndsWith", new [] { typeof(string) });
    
    //at the constructor
    Expressions = new Dictionary<Operation, 
    Func<Expression, Expression, Expression>>();
    //...
    Expressions.Add(Operation.Contains,
        (member, constant) => Expression.Call(member, containsMethod, expression));
    Expressions.Add(Operation.StartsWith,
        (member, constant) => Expression.Call(member, startsWithMethod, constant));
    Expressions.Add(Operation.EndsWith,
        (member, constant) => Expression.Call(member, endsWithMethod, constant));
  5. 为了使与 string 的操作不区分大小写,我们将使用 ToLower() 方法以及 Trim() 方法(去除任何不必要的空格)。因此,我们还需要添加它们的 MethodInfo。此外,我们还将稍微修改 BuildExpression 方法。
    static MethodInfo containsMethod = typeof(string).GetMethod("Contains");
    static MethodInfo startsWithMethod = typeof(string)
        .GetMethod("StartsWith", new [] { typeof(string) });
    static MethodInfo endsWithMethod = typeof(string)
        .GetMethod("EndsWith", new [] { typeof(string) });
    
    //We need to add the Trim and ToLower MethodInfos
    static MethodInfo trimMethod = typeof(string).GetMethod("Trim", new Type[0]);
    static MethodInfo toLowerMethod = typeof(string).GetMethod("ToLower", new Type[0]);
    
    public Expression<Func<TClass, bool>> BuildExpression()
    {
        //this is in case the list of statements is empty
        Expression finalExpression = Expression.Constant(true);
        var parameter = Expression.Parameter(typeof(TClass), "x");
        var connector = FilterStatementConnector.And;
        foreach (var statement in Statements)
        {
            var member = GetMemberExpression(parameter, statement.PropertyName);
            var constant = Expression.Constant(statement.Value);
            
            if (statement.Value is string)
            {
                // x.Name.Trim()
                var trimMemberCall = Expression.Call(member, trimMethod);
                // x.Name.Trim().ToLower()
                member = Expression.Call(trimMemberCall, toLowerMethod);
                // "John ".Trim()
                var trimConstantCall = Expression.Call(constant, trimMethod); 
                // "John ".Trim().ToLower()
                constant = Expression.Call(trimConstantCall, toLowerMethod); 
            }
            
            var expression = Expressions[statement.Operation].Invoke(member, constant);
            finalExpression = CombineExpressions(finalExpression, expression, connector);
            connector = statement.Connector;
        }
        
        return finalExpression;
    }
    
    Expression CombineExpressions(Expression expr1,
        Expression expr2, FilterStatementConnector connector)
    {
        return connector == FilterStatementConnector.And ? 
    			Expression.AndAlso(expr1, expr2) : Expression.OrElse(expr1, expr2);
    }
  6. 最后但同样重要的是,支持按列表类型属性中的对象进行过滤是我发现的最难处理的要求(至少对我来说是这样)。我找到的最佳方法是提出一个约定来处理这些属性。采用的约定是在列表类型属性名称后面加上方括号提及属性,例如 Contacts[Value] 将指向 Person.Contacts 中每个 ContactValue 属性。现在,繁重的工作是从 Contacts[Value] Operation.EndsWith "@email.com" 转换为 x.Contacts.Any(i =>i.Value.EndsWith("@email.com")。这时下面的方法就派上用场了。
    static Expression ProcessListStatement(ParameterExpression param, IFilterStatement statement)
    {
        //Gets the name of the list-type property
        var basePropertyName = statement.PropertyName
            .Substring(0, statement.PropertyName.IndexOf("["));
        //Gets the name of the 'inner' property by 
        //removing the name of the main property and the brackets
        var propertyName = statement.PropertyName
            .Replace(basePropertyName, "")
            .Replace("[", "").Replace("]", "");
    
        //Gets the type of the items in the list, eg. 'Contact'
        var type = param.Type.GetProperty(basePropertyName)
            .PropertyType.GetGenericArguments()[0];
        ParameterExpression listItemParam = Expression.Parameter(type, "i");
        //Gets the expression 'inner' expression:
        // i => i.Value.Trim().ToLower().EndsWith("@email.com".Trim().ToLower())
        var lambda = Expression.Lambda(GetExpression(listItemParam, statement, propertyName),
            listItemParam);
        var member = GetMemberExpression(param, basePropertyName); //x.Contacts
        var tipoEnumerable = typeof(Enumerable);
        //MethodInfo for the 'Any' method
        var anyInfo = tipoEnumerable
            .GetMethods(BindingFlags.Static | BindingFlags.Public)
            .First(m => m.Name == "Any" && m.GetParameters().Count() == 2);
        anyInfo = anyInfo.MakeGenericMethod(type);
        //x.Contacts.Any(i => 
        //    i.Value.Trim().ToLower().EndsWith("@email.com".Trim().ToLower())
        //)
        return Expression.Call(anyInfo, member, lambda); 
    }

     

就是这样了!希望您阅读这篇内容和我的写作一样有趣。请随时发表任何评论、建议和/或问题。

关注点

好像还不够有趣,我还决定通过在 Filter 中实现 流畅接口 来进行另一项改进

/// <summary>
/// Defines a filter from which a expression will be built.
/// </summary>
public interface IFilter<TClass> where TClass : class
{
    /// <summary>
    /// Group of statements that compose this filter.
    /// </summary>
    IEnumerable<IFilterStatement> Statements { get; }
    /// <summary>
    /// Adds another statement to this filter.
    /// </summary>
    /// <param name="propertyName">
    /// Name of the property that will be filtered.</param>
    /// <param name="operation">
    /// Express the interaction between the property and the constant value.</param>
    /// <param name="value">
    /// Constant value that will interact with the property.</param>
    /// <param name="connector">
    /// Establishes how this filter statement will connect to the next one.</param>
    /// <returns>A FilterStatementConnection object that 
    /// defines how this statement will be connected to the next one.</returns>
    IFilterStatementConnection<TClass> By<TPropertyType>
        (string propertyName, Operation operation, TPropertyType value, 
        FilterStatementConnector connector = FilterStatementConnector.And);
    /// <summary>
    /// Removes all statements from this filter.
    /// </summary>
    void Clear();
    /// <summary>
    /// Builds a LINQ expression based upon the statements included in this filter.
    /// </summary>
    /// <returns></returns>
    Expression<Func<TClass, bool>> BuildExpression();
}

public interface IFilterStatementConnection<TClass> where TClass : class
{
    /// <summary>
    /// Defines that the last filter statement will 
    /// connect to the next one using the 'AND' logical operator.
    /// </summary>
    IFilter<TClass> And { get; }
    /// <summary>
    /// Defines that the last filter statement will connect 
    /// to the next one using the 'OR' logical operator.
    /// </summary>
    IFilter<TClass> Or { get; }
}

通过这样做,我们可以编写更易读的过滤器,如下所示

var filter = new Filter<Person>();
filter.By("Id", Operation.Between, 2, 4)
      .And.By("Birth.Country", Operation.IsNotNull)
      .And.By("Contacts[Value]", Operations.EndsWith, "@email.com")
      .Or.By("Name", Operaions.Contains, " John");

更新 - 2017 年 7 月

除了 UI 的一些小改进外,代码的主要更改如下

  • 增加了七个新操作:IsNull, IsNotNull, IsEmpty, IsNotEmpty, IsNullOrWhiteSpace, IsNotNullNorWhiteSpace, 和 Between;
  • 增加了属性和操作的全球化支持:现在您可以将属性和操作的描述映射到资源文件,以改善用户体验;
  • 一些操作被重命名:Equals => EqualTo; NotEquals => NotEqualTo; GreaterThanOrEquals => GreaterThanOrEqualTo; LessThanOrEquals => LessThanOrEqualTo;
  • 增加了对可空属性的支持;
  • 代码与 UI 分离:这使得项目可以转换为 NuGet 包

历史

  • 2016 年 2 月 26 日 - 首次发布。
  • 2016 年 2 月 29 日 - 对倒数第二个代码片段进行了小幅调整,一些行被错误地注释掉了。
  • 2017 年 3 月 21 日 - 添加了源代码的可下载版本。
  • 2017 年 7 月 10 日 - 新功能
  • 2017 年 8 月 23 日 - 增加了配置支持,以便可以使用除默认类型以外的其他类型。
© . All rights reserved.