Realizar consultas com base no estado do runtime (Visual Basic)

Considere o código que define um IQueryable ou um IQueryable(Of T) em relação a uma fonte de dados:

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)

Toda vez que você executar esse código, exatamente a mesma consulta será executada. Isso, geralmente, não é muito útil, pois você pode querer que o código execute consultas diferentes com base nas condições em tempo de execução. Este artigo descreve como você pode executar uma consulta diferente com base no estado de runtime.

IQueryable/IQueryable(Of T) e árvores de expressão

Fundamentalmente, uma IQueryable tem dois componentes:

  • Expression – Uma representação (agnóstica quanto à linguagem e à fonte de dados) dos componentes da consulta atual, na forma de uma árvore de expressão.
  • Provider – Uma instância de um provedor LINQ, que sabe como materializar a consulta atual em um valor ou conjunto de valores.

No contexto de consulta dinâmica, o provedor geralmente permanece o mesmo, ao passo que a árvore de expressão da consulta varia de uma consulta para a outra.

As árvores de expressão são imutáveis; se você quiser uma árvore de expressão diferente e, portanto, uma consulta diferente, precisará traduzir a árvore de expressão existente para uma nova e, portanto, para um novo IQueryable.

As seguintes seções descrevem técnicas específicas para executar consultas diferentes em resposta ao estado de runtime:

  • Usar o estado de runtime de dentro da árvore de expressão
  • Chamar métodos LINQ adicionais
  • Variar a árvore de expressão passada para os métodos LINQ
  • Construir uma árvore de expressão Expression(Of TDelegate) usando os métodos de fábrica em Expression
  • Adicionar nós de chamada de método a uma árvore de expressão de IQueryable
  • Construir cadeias de caracteres e usar a Biblioteca dinâmica do LINQ

Usar o estado de runtime de dentro da árvore de expressão

Supondo que o provedor LINQ dê suporte a isso, a maneira mais simples de executar consultas dinamicamente é referenciar o estado de runtime diretamente na consulta por meio de uma variável fechada, como length no seguinte exemplo de código:

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

A árvore de expressão interna e, portanto, a consulta, não foi modificada; a consulta retorna valores diferentes apenas porque o valor de length foi alterado.

Chamar métodos LINQ adicionais

Em geral, os métodos LINQ internos em Queryable executam duas etapas:

  • Empacote a árvore de expressão atual em uma MethodCallExpression representando a chamada de método.
  • Passar a árvore de expressão empacotada de volta para o provedor, seja para retornar um valor por meio do método IQueryProvider.Execute do provedor ou para retornar um objeto de consulta traduzido por meio do método IQueryProvider.CreateQuery.

Você pode substituir a consulta original pelo resultado do método de retorno IQueryable(Of T) a fim de obter uma nova consulta. Você pode fazer isso condicionalmente, com base no estado de runtime, como no seguinte exemplo:

' Dim sortByLength As Boolean  = ...

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

Variar a árvore de expressão passada para os métodos LINQ

Você pode passar expressões diferentes para os métodos LINQ, dependendo do estado de runtime:

' 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)

Talvez você também queira compor as diversas subexpressões usando uma biblioteca de terceiros, como o PredicateBuilder da LinqKit:

' 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)

Construir árvores de expressão e consultas usando métodos de fábrica

Em todos os exemplos até este ponto, conhecemos, no tempo de compilação, o tipo de elemento Stringe, portanto, o tipo da consulta IQueryable(Of String). Talvez seja necessário adicionar componentes a uma consulta de qualquer tipo de elemento ou adicionar componentes diferentes, dependendo do tipo de elemento. Você pode criar árvores de expressão do zero, usando os métodos de fábrica em System.Linq.Expressions.Expression, personalizando assim a expressão em tempo de execução para um tipo de elemento específico.

Construindo uma Expression(Of TDelegate)

Quando você constrói uma expressão para passar para um dos métodos LINQ, na verdade você está criando uma instância de Expression(Of TDelegate), em que TDelegate é algum tipo de delegado, como Func(Of String, Boolean), Action ou um tipo de delegado personalizado.

Expression(Of TDelegate) herda de LambdaExpression, que representa uma expressão lambda completa como a seguinte:

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

Uma LambdaExpression tem dois componentes:

  • Uma lista de parâmetros – (x As String) – representada pela propriedade Parameters.
  • Um corpo – x.StartsWith("a") – representado pela propriedade Body.

As etapas básicas para construção de um Expression(Of TDelegate) são as seguintes:

  • Defina objetos ParameterExpression para cada um dos parâmetros (se houver) na expressão lambda, usando o método de fábrica Parameter.

    Dim x As ParameterExpression = Parameter(GetType(String), "x")
    
  • Construa o corpo do seu LambdaExpression, usando os ParameterExpression que você definiu e os métodos de fábrica em Expression. Por exemplo, uma expressão que representa x.StartsWith("a") poderia ser construída assim:

    Dim body As Expression = [Call](
        x,
        GetType(String).GetMethod("StartsWith", {GetType(String)}),
        Constant("a")
    )
    
  • Empacote os parâmetros e o corpo em um Expression(Of TDelegate) do tipo tempo de compilação, usando a sobrecarga do método de fábrica Lambda apropriada:

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

As seções a seguir descrevem um cenário no qual talvez você queira construir um Expression(Of TDelegate) para passar para um método LINQ e fornecer um exemplo completo de como fazer isso usando os métodos de fábrica.

Cenário

Digamos que você tenha vários tipos de entidade:

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

Para qualquer um desses tipos de entidade, você deve filtrar e retornar somente as entidades que têm determinado texto dentro de um dos campos string delas. Para Person, você deve pesquisar as propriedades FirstName e LastName:

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

Já para Car, você deve pesquisar apenas a propriedade Model:

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

Embora você possa gravar uma função personalizada para IQueryable(Of Person) e outra para IQueryable(Of Car), a função a seguir adiciona essa filtragem a qualquer consulta existente, independentemente do tipo do elemento.

Exemplo

' 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

Como a função TextFilter usa e retorna um IQueryable(Of T) (e não apenas um IQueryable), você pode adicionar mais elementos de consulta do tipo tempo de compilação após o filtro de texto.

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)

Adicionar nós de chamada de método à árvore de expressão de IQueryable

Se você tiver um IQueryable em vez de um IQueryable(Of T), não poderá chamar diretamente os métodos LINQ genéricos. Uma alternativa é criar a árvore de expressão interna conforme acima e usar a reflexão para invocar o método LINQ apropriado e passar como parâmetro a árvore de expressão.

Você também pode duplicar a funcionalidade do método LINQ, empacotando toda a árvore em um MethodCallExpression que representa uma chamada ao método 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

Nesse caso, você não tem um espaço reservado genérico T em tempo de compilação, portanto, você usará a sobrecarga Lambda que não requer informações do tipo tempo de compilação e que produz um LambdaExpression em vez de um Expression(Of TDelegate).

Biblioteca Dinâmica do LINQ

A construção de árvores de expressão usando métodos de fábrica é relativamente complexa; é mais fácil compor cadeias de caracteres. A Biblioteca Dinâmica do LINQ expõe um conjunto de métodos de extensão em IQueryable, correspondentes aos métodos LINQ padrão em Queryable e que aceitam cadeias de caracteres em uma sintaxe especial em vez de árvores de expressão. A biblioteca gera a árvore de expressão apropriada com base na cadeia de caracteres e pode retornar o IQueryable traduzido resultante.

Por exemplo, o exemplo anterior (incluindo a construção da árvore de expressão) pode ser reescrito da seguinte maneira:

' 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

Confira também