Consulta com base no estado de tempo de execução

Na maioria das consultas LINQ, a forma geral da consulta é definida no código. Você pode filtrar itens usando uma cláusula where, classificar a coleção de saída usando orderby, itens de grupo ou executar alguma computação. Seu código pode fornecer parâmetros para o filtro, a chave de classificação ou outras expressões que fazem parte da consulta. No entanto, a forma geral da consulta não pode ser alterada. Neste artigo, você aprenderá técnicas para usar a interface System.Linq.IQueryable<T> e tipos que a implementam para modificar a forma de uma consulta em tempo de execução.

Você usa essas técnicas para criar consultas em tempo de execução, em que algum estado de entrada ou tempo de execução do usuário altera os métodos de consulta que você deseja usar como parte da consulta. Você edita a consulta adicionando, removendo ou modificando cláusulas de consulta.

Observação

Adicione using System.Linq.Expressions; e using static System.Linq.Expressions.Expression; na parte superior do seu arquivo .cs.

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

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

Toda vez que você executar o código anterior, exatamente a mesma consulta será executada. Vamos aprender a modificar a consulta para estendê-la ou modificá-la. 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. 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 mais métodos LINQ
  • Variar a árvore de expressão passada para os métodos LINQ
  • Construir uma árvore de expressão Expression<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

Cada uma das técnicas permite mais funcionalidades, mas a um custo de maior complexidade.

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

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:

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

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

Chamar mais métodos LINQ

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 System.Linq.IQueryable<T> a fim de obter uma nova consulta. Você pode usar o estado de tempo de execução, como no exemplo a seguir:

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

var qry = companyNamesSource;
if (sortByLength)
{
    qry = qry.OrderBy(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:

// 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);

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

// 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);

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

Em todos os exemplos até este ponto, você conhece o tipo de elemento no tipo de compilação string e, portanto, o tipo da consulta IQueryable<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 expressão <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 System.Linq.Expressions.Expression<TDelegate>, em que TDelegate é algum tipo de delegado, como Func<string, bool>, Action ou um tipo de delegado personalizado.

System.Linq.Expressions.Expression<TDelegate> herda de LambdaExpression, que representa uma expressão lambda completa como o exemplo a seguir:

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

Uma LambdaExpression tem dois componentes:

  1. Uma lista de parâmetros – (string x) – representada pela propriedade Parameters.
  2. Um corpo – x.StartsWith("a") – representado pela propriedade Body.

As etapas básicas para construção de um Expression<TDelegate> são as seguintes:

  1. Defina objetos ParameterExpression para cada um dos parâmetros (se houver) na expressão lambda, usando o método de fábrica Parameter.
    ParameterExpression x = Parameter(typeof(string), "x");
    
  2. Construa o corpo do seu LambdaExpression, usando ParameterExpression definido e os métodos de fábrica em Expression. Por exemplo, uma expressão que representa x.StartsWith("a") poderia ser construída assim:
    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", [typeof(string)])!,
        Constant("a")
    );
    
  3. Encapsule os parâmetros e o corpo em um Expression<TDelegate> do tipo tempo de compilação, usando a sobrecarga do método de fábrica Lambda apropriada:
    Expression<Func<string, bool>> expr = Lambda<Func<string, bool>>(body, x);
    

As seções a seguir descrevem um cenário no qual talvez você queira construir uma Expression<TDelegate> para passar para um método LINQ. Ele fornece um exemplo completo de como fazer isso usando os métodos de fábrica.

Construir uma consulta completa em tempo de execução

Você deseja escrever consultas que funcionam com vários tipos de entidade:

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

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:

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

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

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

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

// 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);
}

Como a função TextFilter usa e retorna um IQueryable<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.

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

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

Se você tiver um IQueryable em vez de IQueryable<T>, não poderá chamar diretamente os métodos LINQ genéricos. Uma alternativa é criar a árvore de expressão interna conforme mostrado no exemplo 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:

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);
}

Nesse caso, você não tem um espaço reservado genérico T em tempo de compilação, portanto, você usa 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<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 pode ser reescrito da seguinte maneira:

// 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);
}