基于运行时状态进行查询 (C#)

备注

请确保在 .cs 文件顶部添加 using System.Linq.Expressions;using static System.Linq.Expressions.Expression;

考虑针对数据源定义 IQueryableIQueryable<T> 的代码:

var companyNames = new[] {
    "Consolidated Messenger", "Alpine Ski House", "Southridge Video",
    "City Power & Light", "Coho Winery", "Wide World Importers",
    "Graphic Design Institute", "Adventure Works", "Humongous Insurance",
    "Woodgrove Bank", "Margie's Travel", "Northwind Traders",
    "Blue Yonder Airlines", "Trey Research", "The Phone Company",
    "Wingtip Toys", "Lucerne Publishing", "Fourth Coffee"
};

// We're using an in-memory array as the data source, but the IQueryable could have come
// from anywhere -- an ORM backed by a database, a web request, or any other LINQ provider.
IQueryable<string> companyNamesSource = companyNames.AsQueryable();
var fixedQry = companyNames.OrderBy(x => x);

每次运行此代码时,都将执行相同的确切查询。 这通常不是很有用,因为你可能希望代码根据运行时的情况执行不同的查询。 本文介绍如何根据运行时状态执行不同的查询。

IQueryable/IQueryable<T> 和表达式树

从根本上讲,IQueryable 有两个组件:

  • Expression:当前查询的组件的与语言和数据源无关的表示形式,以表达式树的形式表示。
  • Provider:LINQ 提供程序的实例,它知道如何将当前查询具体化为一个值或一组值。

在动态查询的上下文中,提供程序通常会保持不变;查询的表达式树将因查询而异。

表达式树是不可变的;如果需要不同的表达式树并因此需要不同的查询,则需要将现有表达式树转换为新的表达式树,从而转换为新的 IQueryable

以下各部分介绍了根据运行时状态,以不同方式进行查询的具体技术:

  • 从表达式树中使用运行时状态
  • 调用其他 LINQ 方法
  • 改变传入到 LINQ 方法的表达式树
  • 使用 Expression 中的工厂方法构造 Expression<TDelegate> 表达式树
  • 将方法调用节点添加到 IQueryable 的表达式树
  • 构造字符串,并使用 动态 LINQ 库

从表达式树中使用运行时状态

假设 LINQ 提供程序支持,进行动态查询的最简单方式是通过封闭的变量(如以下代码示例中的 length)直接在查询中引用运行时状态:

var length = 1;
var qry = companyNamesSource
    .Select(x => x.Substring(0, length))
    .Distinct();

Console.WriteLine(string.Join(",", qry));
// prints: C, A, S, W, G, H, M, N, B, T, L, F

length = 2;
Console.WriteLine(string.Join(",", qry));
// prints: Co, Al, So, Ci, Wi, Gr, Ad, Hu, Wo, Ma, No, Bl, Tr, Th, Lu, Fo

内部表达式树以及查询尚未修改;查询只返回不同的值,因为 length 的值已更改。

调用其他 LINQ 方法

通常,内置 LINQ 方法执行两个步骤:

可以将原始查询替换为 IQueryable<T> 返回方法的结果,以获取新的查询。 可以基于运行时状态在一定条件下执行此操作,如以下示例中所示:

// bool sortByLength = /* ... */;

var qry = companyNamesSource;
if (sortByLength)
{
    qry = qry.OrderBy(x => x.Length);
}

改变传入到 LINQ 方法的表达式树

可以将不同的表达式传入到 LINQ 方法,具体取决于运行时状态:

// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>> expr = (startsWith, endsWith) switch
{
    ("" or null, "" or null) => x => true,
    (_, "" or null) => x => x.StartsWith(startsWith),
    ("" or null, _) => x => x.EndsWith(endsWith),
    (_, _) => x => x.StartsWith(startsWith) || x.EndsWith(endsWith)
};

var qry = companyNamesSource.Where(expr);

你可能还希望使用第三方库(如 LinqKitPredicateBuilder)来编写各种子表达式:

// This is functionally equivalent to the previous example.

// using LinqKit;
// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>>? expr = PredicateBuilder.New<string>(false);
var original = expr;
if (!string.IsNullOrEmpty(startsWith))
{
    expr = expr.Or(x => x.StartsWith(startsWith));
}
if (!string.IsNullOrEmpty(endsWith))
{
    expr = expr.Or(x => x.EndsWith(endsWith));
}
if (expr == original)
{
    expr = x => true;
}

var qry = companyNamesSource.Where(expr);

使用工厂方法构造表达式树和查询

在到此为止的所有示例中,我们已知道编译时的元素类型 string 并因此知道查询的类型 IQueryable<string>。 可能需要将组件添加到任何元素类型的查询中,或者添加不同的组件,具体取决于元素类型。 可以使用 System.Linq.Expressions.Expression 的工厂方法从头开始创建表达式树,从而在运行时将表达式定制为特定的元素类型。

构造 Expression<TDelegate>

构造要传入到某个 LINQ 方法的表达式时,实际上是在构造 Expression<TDelegate> 的实例,其中 TDelegate 是某个委托类型,例如 Func<string, bool>Action 或自定义委托类型。

Expression<TDelegate> 继承自 LambdaExpression,后者表示完整的 Lambda 表达式,如下所示:

Expression<Func<string, bool>> expr = x => x.StartsWith("a");

LambdaExpression 具有两个组件:

  • 参数列表 (string x)Parameters 属性表示。
  • 主体 x.StartsWith("a")Body 属性表示。

构造 Expression<TDelegate> 的基本步骤如下所示:

  • 使用 Parameter 工厂方法为 lambda 表达式中的每个参数(如果有)定义 ParameterExpression 的对象。

    ParameterExpression x = Expression.Parameter(typeof(string), "x");
    
  • 使用你定义的 ParameterExpressionExpression 的工厂方法来构造 LambdaExpression 的主体。 例如,表示 x.StartsWith("a") 的表达式的构造方式如下:

    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!,
        Constant("a")
    );
    
  • 使用适当的 Lambda 工厂方法重载,将参数和主体包装到编译时类型的 Expression<TDelegate> 中:

    Expression<Func<string, bool>> expr = Lambda<Func<string, bool>>(body, x);
    

以下部分介绍了一种方案(在该方案中,建议构造要传递到 LINQ 方法中的 Expression<TDelegate>),并提供了使用工厂方法完成此操作的完整示例。

方案

假设你有多个实体类型:

record Person(string LastName, string FirstName, DateTime DateOfBirth);
record Car(string Model, int Year);

对于这些实体类型中的任何一个,你都需要筛选并仅返回那些在其某个 string 字段内具有给定文本的实体。 对于 Person,你希望搜索 FirstNameLastName 属性:

string term = /* ... */;
var personsQry = new List<Person>()
    .AsQueryable()
    .Where(x => x.FirstName.Contains(term) || x.LastName.Contains(term));

但对于 Car,你希望仅搜索 Model 属性:

string term = /* ... */;
var carsQry = new List<Car>()
    .AsQueryable()
    .Where(x => x.Model.Contains(term));

尽管可以为 IQueryable<Person> 编写一个自定义函数,并为 IQueryable<Car> 编写另一个自定义函数,但以下函数会将此筛选添加到任何现有查询,而不考虑特定的元素类型如何。

示例

// using static System.Linq.Expressions.Expression;

IQueryable<T> TextFilter<T>(IQueryable<T> source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }

    // T is a compile-time placeholder for the element type of the query.
    Type elementType = typeof(T);

    // Get all the string properties on this specific type.
    PropertyInfo[] stringProperties =
        elementType.GetProperties()
            .Where(x => x.PropertyType == typeof(string))
            .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Get the right overload of String.Contains
    MethodInfo containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) })!;

    // Create a parameter for the expression tree:
    // the 'x' in 'x => x.PropertyName.Contains("term")'
    // The type of this parameter is the query's element type
    ParameterExpression prm = Parameter(elementType);

    // Map each property to an expression tree node
    IEnumerable<Expression> expressions = stringProperties
        .Select(prp =>
            // For each property, we have to construct an expression tree node like x.PropertyName.Contains("term")
            Call(                  // .Contains(...) 
                Property(          // .PropertyName
                    prm,           // x 
                    prp
                ),
                containsMethod,
                Constant(term)     // "term" 
            )
        );

    // Combine all the resultant expression nodes using ||
    Expression body = expressions
        .Aggregate(
            (prev, current) => Or(prev, current)
        );

    // Wrap the expression body in a compile-time-typed lambda expression
    Expression<Func<T, bool>> lambda = Lambda<Func<T, bool>>(body, prm);

    // Because the lambda is compile-time-typed (albeit with a generic parameter), we can use it with the Where method
    return source.Where(lambda);
}

由于 TextFilter 函数采用并返回 IQueryable<T>(而不仅仅是 IQueryable),因此你可以在文本筛选器后添加更多的编译时类型的查询元素。

var qry = TextFilter(
        new List<Person>().AsQueryable(), 
        "abcd"
    )
    .Where(x => x.DateOfBirth < new DateTime(2001, 1, 1));

var qry1 = TextFilter(
        new List<Car>().AsQueryable(), 
        "abcd"
    )
    .Where(x => x.Year == 2010);

将方法调用节点添加到 IQueryable 的表达式树

如果你有 IQueryable(而不是IQueryable<T>),则不能直接调用泛型 LINQ 方法。 一种替代方法是按上面所述构建内部表达式树,并在传入表达树时使用反射来调用适当的 LINQ 方法。

还可以通过在表示调用 LINQ 方法的 MethodCallExpression 中包装整个树来复制 LINQ 方法的功能:

IQueryable TextFilter_Untyped(IQueryable source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }
    Type elementType = source.ElementType;

    // The logic for building the ParameterExpression and the LambdaExpression's body is the same as in the previous example,
    // but has been refactored into the constructBody function.
    (Expression? body, ParameterExpression? prm) = constructBody(elementType, term);
    if (body is null) {return source;}

    Expression filteredTree = Call(
        typeof(Queryable),
        "Where",
        new[] { elementType},
        source.Expression,
        Lambda(body, prm!)
    );

    return source.Provider.CreateQuery(filteredTree);
}

在这种情况下,你没有编译时 T 泛型占位符,因此你将使用不需要编译时类型信息的 Lambda 重载,这会生成 LambdaExpression,而不是 Expression<TDelegate>

动态 LINQ 库

使用工厂方法构造表达式树比较复杂;编写字符串较为容易。 动态 LINQ 库公开了 上的一组扩展方法,这些方法对应于 Queryable 上的标准 LINQ 方法,后者接受采用Queryable的字符串而不是表达式树。 该库基于字符串生成相应的表达式树,并可以返回生成的已转换 IQueryable

例如,可以重新编写上一示例,如下所示:

// using System.Linq.Dynamic.Core

IQueryable TextFilter_Strings(IQueryable source, string term) {
    if (string.IsNullOrEmpty(term)) { return source; }

    var elementType = source.ElementType;

    // Get all the string property names on this specific type.
    var stringProperties = 
        elementType.GetProperties()
            .Where(x => x.PropertyType == typeof(string))
            .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Build the string expression
    string filterExpr = string.Join(
        " || ",
        stringProperties.Select(prp => $"{prp.Name}.Contains(@0)")
    );

    return source.Where(filterExpr, term);
}

请参阅