基于运行时状态进行查询 (Visual Basic)

考虑针对数据源定义 IQueryableIQueryable(Of T) 的代码:

Dim companyNames As String() = {
    "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.
Dim companyNamesSource As IQueryable(Of String) = companyNames.AsQueryable
Dim fixedQry = companyNamesSource.OrderBy(Function(x) x)

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

IQueryable/IQueryable(Of T) 和表达式树

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

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

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

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

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

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

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

Dim length = 1
Dim qry = companyNamesSource.
    Select(Function(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 方法执行两个步骤:

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

' Dim sortByLength As Boolean  = ...

Dim qry = companyNamesSource
If sortByLength Then qry = qry.OrderBy(Function(x) x.Length)

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

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

' Dim startsWith As String = ...
' Dim endsWith As String = ...

Dim expr As Expression(Of Func(Of String, Boolean))
If String.IsNullOrEmpty(startsWith) AndAlso String.IsNullOrEmpty(endsWith) Then
    expr = Function(x) True
ElseIf String.IsNullOrEmpty(startsWith) Then
    expr = Function(x) x.EndsWith(endsWith)
ElseIf String.IsNullOrEmpty(endsWith) Then
    expr = Function(x) x.StartsWith(startsWith)
Else
    expr = Function(x) x.StartsWith(startsWith) AndAlso x.EndsWith(endsWith)
End If
Dim qry = companyNamesSource.Where(expr)

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

' This is functionally equivalent to the previous example.

' Imports LinqKit
' Dim startsWith As String = ...
' Dim endsWith As String = ...

Dim expr As Expression(Of Func(Of String, Boolean)) = PredicateBuilder.[New](Of String)(False)
Dim original = expr
If Not String.IsNullOrEmpty(startsWith) Then expr = expr.Or(Function(x) x.StartsWith(startsWith))
If Not String.IsNullOrEmpty(endsWith) Then expr = expr.Or(Function(x) x.EndsWith(endsWith))
If expr Is original Then expr = Function(x) True

Dim qry = companyNamesSource.Where(expr)

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

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

构造 Expression(Of TDelegate)

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

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

Dim expr As Expression(Of Func(Of String, Boolean)) = Function(x As String) x.StartsWith("a")

LambdaExpression 具有两个组件:

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

构造 Expression(Of TDelegate) 的基本步骤如下所示:

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

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

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

    Dim expr As Expression(Of Func(Of String, Boolean)) =
        Lambda(Of Func(Of String, Boolean))(body, x)
    

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

方案

假设你有多个实体类型:

Public Class Person
    Property LastName As String
    Property FirstName As String
    Property DateOfBirth As Date
End Class

Public Class Car
    Property Model As String
    Property Year As Integer
End Class

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

' Dim term = ...
Dim personsQry = (New List(Of Person)).AsQueryable.
    Where(Function(x) x.FirstName.Contains(term) OrElse x.LastName.Contains(term))

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

' Dim term = ...
Dim carsQry = (New List(Of Car)).AsQueryable.
    Where(Function(x) x.Model.Contains(term))

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

示例

' Imports System.Linq.Expressions.Expression
Function TextFilter(Of T)(source As IQueryable(Of T), term As String) As IQueryable(Of T)
    If String.IsNullOrEmpty(term) Then Return source

    ' T is a compile-time placeholder for the element type of the query
    Dim elementType = GetType(T)

    ' Get all the string properties on this specific type
    Dim stringProperties As PropertyInfo() =
        elementType.GetProperties.
            Where(Function(x) x.PropertyType = GetType(String)).
            ToArray
    If stringProperties.Length = 0 Then Return source

    ' Get the right overload of String.Contains
    Dim containsMethod As MethodInfo =
        GetType(String).GetMethod("Contains", {GetType(String)})

    ' Create the parameter for the expression tree --
    ' the 'x' in 'Function(x) x.PropertyName.Contains("term")'
    ' The type of the parameter is the query's element type
    Dim prm As ParameterExpression =
        Parameter(elementType)

    ' Generate an expression tree node corresponding to each property
    Dim expressions As IEnumerable(Of Expression) =
        stringProperties.Select(Of Expression)(Function(prp)
                                                   ' For each property, we want an expression node like this:
                                                   ' x.PropertyName.Contains("term")
                                                   Return [Call](      ' .Contains(...)
                                                       [Property](     ' .PropertyName
                                                           prm,        ' x
                                                           prp
                                                       ),
                                                       containsMethod,
                                                       Constant(term)  ' "term"
                                                   )
                                               End Function)

    ' Combine the individual nodes into a single expression tree node using OrElse
    Dim body As Expression =
        expressions.Aggregate(Function(prev, current) [OrElse](prev, current))

    ' Wrap the expression body in a compile-time-typed lambda expression
    Dim lmbd As Expression(Of Func(Of T, Boolean)) =
        Lambda(Of Func(Of T, Boolean))(body, prm)

    ' Because the lambda is compile-time-typed, we can use it with the Where method
    Return source.Where(lmbd)
End Function

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

Dim qry = TextFilter(
    (New List(Of Person)).AsQueryable,
    "abcd"
).Where(Function(x) x.DateOfBirth < #1/1/2001#)

Dim qry1 = TextFilter(
    (New List(Of Car)).AsQueryable,
    "abcd"
).Where(Function(x) x.Year = 2010)

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

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

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

Function TextFilter_Untyped(source As IQueryable, term As String) As IQueryable
    If String.IsNullOrEmpty(term) Then Return source
    Dim 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.
    Dim x As (Expression, ParameterExpression) = ConstructBody(elementType, term)
    Dim body As Expression = x.Item1
    Dim prm As ParameterExpression = x.Item2
    If body Is Nothing Then Return source

    Dim filteredTree As Expression = [Call](
        GetType(Queryable),
        "Where",
        {elementType},
        source.Expression,
        Lambda(body, prm)
    )

    Return source.Provider.CreateQuery(filteredTree)
End Function

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

动态 LINQ 库

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

例如,可以重写前面的示例(包括表达式树构造),如下所示:

' Imports System.Linq.Dynamic.Core

Function TextFilter_Strings(source As IQueryable, term As String) As IQueryable
    If String.IsNullOrEmpty(term) Then Return source

    Dim elementType = source.ElementType
    Dim stringProperties = elementType.GetProperties.
            Where(Function(x) x.PropertyType = GetType(String)).
            ToArray
    If stringProperties.Length = 0 Then Return source

    Dim filterExpr = String.Join(
        " || ",
        stringProperties.Select(Function(prp) $"{prp.Name}.Contains(@0)")
    )

    Return source.Where(filterExpr, term)
End Function

另请参阅