根据运行时状态进行查询

在大多数 LINQ 查询中,查询的一般形式是在代码中设置的。 可以使用 where 子句筛选项、使用 orderby 对输出集合进行排序、对项进行分组,或者执行一些计算。 代码可能会提供筛选器参数、排序键或查询中的其他表达式。 但是,查询的一般形式无法更改。 本文介绍使用 System.Linq.IQueryable<T> 接口和实现该接口的类型在运行时修改查询形式所需的技术。

请使用这些技术在运行时构建查询,其中的某些用户输入或运行时状态会更改你要将其用作查询的一部分的查询方法。 你想要通过添加、删除或修改查询子句来编辑查询。

注意

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

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

string[] companyNames = [
    "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"
];

// Use 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 有两个组件:

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

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

表达式树是不可变的;如果需要不同的表达式树并因此需要不同的查询,则需要将现有表达式树转换为新的表达式树。 以下各部分介绍了根据运行时状态,以不同方式进行查询的具体技术:

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

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

可以将原始查询替换为 System.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 方法的表达式时,实际上是在构造 System.Linq.Expressions.Expression<TDelegate> 的实例,其中 TDelegate 是某个委托类型,例如 Func<string, bool>Action 或自定义委托类型。

System.Linq.Expressions.Expression<TDelegate> 继承自 LambdaExpression,后者表示完整的 lambda 表达式,如以下示例所示:

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

LambdaExpression 具有两个组件:

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

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

  1. 使用 Parameter 工厂方法为 lambda 表达式中的每个参数(如果有)定义 ParameterExpression 的对象。
    ParameterExpression x = Parameter(typeof(string), "x");
    
  2. 使用已定义的 ParameterExpression 以及 Expression 中的工厂方法来构造 LambdaExpression 的主体。 例如,表示 x.StartsWith("a") 的表达式的构造方式如下:
    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", [typeof(string)])!,
        Constant("a")
    );
    
  3. 使用适当的 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", [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<TDelegate> 的表达式树

如果你有 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",
        [elementType],
        source.Expression,
        Lambda(body, prm!)
    );

    return source.Provider.CreateQuery(filteredTree);
}

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

动态 LINQ 库

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