Programação Por Equipe DevBras

Capa do artigo: Filtros opcionais em C#: consulta dinâmica com IQueryable e expressões

Filtros opcionais em C#: consulta dinâmica com IQueryable e expressões

Monte a consulta de uma tela de filtros em C# e EF Core sem um método por combinação: encadeie Where em IQueryable e use árvore de expressão quando a regra vier de fora.

A tela de pedidos tem quatro filtros opcionais: cliente, status, data inicial e data final. Isso dá dezesseis combinações. Alguém começou com um método por caso, depois trocou por uma string SQL montada com if, e agora o campo "cliente" aceita um apóstrofo que quebra a consulta. A resposta curta: em C# com EF Core, comece de um IQueryable com o que é fixo e acrescente um Where para cada filtro que a pessoa preencheu. Nada vai ao banco até você chamar ToListAsync, e os valores viram parâmetros. Árvore de expressão só entra quando a própria regra (campo e operador) chega de fora, como num filtro avançado montado pela pessoa.

O exemplo é o sistema de uma distribuidora de material elétrico, com a listagem de pedidos e, mais adiante, um filtro avançado de clientes.

Por que encadear Where funciona

Um IQueryable não é uma lista. É a descrição de uma consulta, guardada como árvore de expressão. Cada Where devolve uma descrição nova, um pouco maior, sem executar nada. Quando você enumera o resultado, o provedor do EF Core traduz a árvore inteira em um único comando SQL.

Por isso, dois Where seguidos equivalem a um AND, e um filtro que você não acrescentou simplesmente não existe no SQL. Não há WHERE nome LIKE '%%' fazendo varredura à toa nem 1 = 1 para facilitar a concatenação.

E os valores? Quando a lambda captura uma variável, como o texto digitado no filtro, o EF Core a transforma em parâmetro do comando. O texto do usuário nunca vira pedaço do SQL, o que elimina a injeção pelo filtro e ainda permite ao banco reaproveitar o plano de execução.

Como montar a listagem com filtros opcionais

  1. Receba os filtros num tipo próprio, com tudo anulável. Nulo significa "não filtrar".
  2. Comece pelo que sempre vale, como pedidos não excluídos.
  3. Acrescente um Where por filtro preenchido. Trate data final como limite exclusivo do dia seguinte.
  4. Escolha a ordenação a partir de uma lista fechada, nunca do texto da URL.
  5. Limite e projete. Take e Select para um DTO trazem só o que a tela mostra.
  6. Confira o SQL gerado com ToQueryString durante o desenvolvimento.
public enum StatusPedido { Aberto, Faturado, Entregue, Cancelado }

public sealed record FiltroPedidos(
    string? Cliente,
    StatusPedido? Status,
    DateOnly? De,
    DateOnly? Ate,
    string? Ordenar);

public sealed record PedidoLinha(int Id, string Cliente, StatusPedido Status, decimal Total, DateTime CriadoEm);

public async Task<List<PedidoLinha>> ListarAsync(FiltroPedidos f, CancellationToken ct)
{
    IQueryable<Pedido> q = _db.Pedidos
        .AsNoTracking()
        .Where(p => !p.Excluido);

    if (!string.IsNullOrWhiteSpace(f.Cliente))
    {
        var cliente = f.Cliente.Trim();
        q = q.Where(p => p.Cliente.Nome.Contains(cliente));
    }

    if (f.Status is not null)
        q = q.Where(p => p.Status == f.Status);

    if (f.De is not null)
    {
        var inicio = f.De.Value.ToDateTime(TimeOnly.MinValue);
        q = q.Where(p => p.CriadoEm >= inicio);
    }

    if (f.Ate is not null)
    {
        var fimExclusivo = f.Ate.Value.AddDays(1).ToDateTime(TimeOnly.MinValue);
        q = q.Where(p => p.CriadoEm < fimExclusivo);
    }

    q = f.Ordenar switch
    {
        "valor" => q.OrderByDescending(p => p.Total).ThenBy(p => p.Id),
        "cliente" => q.OrderBy(p => p.Cliente.Nome).ThenBy(p => p.Id),
        _ => q.OrderByDescending(p => p.CriadoEm).ThenBy(p => p.Id)
    };

    return await q
        .Take(50)
        .Select(p => new PedidoLinha(p.Id, p.Cliente.Nome, p.Status, p.Total, p.CriadoEm))
        .ToListAsync(ct);
}

Detalhes que evitam bug

A data final é o ponto em que mais gente erra. Se a coluna guarda data e hora e você filtra CriadoEm <= 2026-10-17, os pedidos das 9h do dia 17 ficam de fora, porque a comparação usa meia-noite. Usar "menor que o dia seguinte" pega o dia inteiro sem truque de 23:59:59.

A ordenação desempata pelo Id. Sem isso, dois pedidos com o mesmo valor podem trocar de posição entre uma página e outra; o guia de paginação que não pula explica por quê. E o Take(50) evita que a tela sem filtro nenhum puxe a tabela inteira, problema que o post sobre consulta com limite detalha.

Vendo o SQL que o EF Core gerou

Durante o desenvolvimento, troque o ToListAsync por ToQueryString() e imprima o resultado. Com só o filtro de cliente preenchido, no SQL Server e numa versão recente do EF Core, a parte do filtro sai parecida com isto (a tradução exata do Contains e o nome dos parâmetros mudam entre versões):

WHERE [p].[Excluido] = CAST(0 AS bit)
  AND [c].[Nome] LIKE @cliente_contains ESCAPE N'\'

Repare que não há status nem datas no SQL, e que o texto do cliente está num parâmetro. É isso que você quer confirmar.

Quando a regra chega de fora: árvore de expressão

Encadear Where resolve quando você conhece os filtros ao escrever o código. Na distribuidora, o comercial pediu um filtro avançado de clientes: a pessoa escolhe um campo numa lista (nome, cidade, UF), um operador (igual, contém) e um valor, e pode juntar várias condições com "todas" ou "qualquer uma". Escrever um if para cada combinação de campo e operador volta ao problema inicial, e o "qualquer uma" exige OR, que Where encadeado não faz.

Aqui entra a árvore de expressão. Você monta, em tempo de execução, a mesma estrutura que o compilador geraria para uma lambda escrita à mão:

using System.Linq.Expressions;

public sealed record CondicaoFiltro(string Campo, string Operador, string Valor);

public static class FiltroClientes
{
    private static readonly HashSet<string> CamposPermitidos =
        new(StringComparer.Ordinal) { "Nome", "Cidade", "Uf" };

    private static readonly MethodInfo Contem =
        typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!;

    public static Expression<Func<Cliente, bool>> Montar(
        IEnumerable<CondicaoFiltro> condicoes, bool qualquerUma)
    {
        var x = Expression.Parameter(typeof(Cliente), "x");
        Expression? corpo = null;

        foreach (var c in condicoes)
        {
            if (!CamposPermitidos.Contains(c.Campo))
                throw new ArgumentException("Campo não permitido: " + c.Campo);

            var membro = Expression.Property(x, c.Campo);
            Expression<Func<string>> valor = () => c.Valor;

            Expression teste = c.Operador switch
            {
                "igual" => Expression.Equal(membro, valor.Body),
                "contem" => Expression.Call(membro, Contem, valor.Body),
                _ => throw new ArgumentException("Operador não permitido: " + c.Operador)
            };

            corpo = corpo is null
                ? teste
                : qualquerUma ? Expression.OrElse(corpo, teste) : Expression.AndAlso(corpo, teste);
        }

        return Expression.Lambda<Func<Cliente, bool>>(corpo ?? Expression.Constant(true), x);
    }
}

O uso fica igual ao de qualquer lambda: _db.Clientes.Where(FiltroClientes.Montar(condicoes, qualquerUma: true)). O using System.Reflection é necessário para o MethodInfo.

Os dois cuidados que tornam isso seguro

Primeiro, o nome do campo vem de uma lista que o seu código controla. A pessoa escolhe "Cidade" num select, mas o servidor confere contra CamposPermitidos antes de criar a expressão. Sem essa lista, alguém poderia filtrar por uma propriedade que a tela nunca mostrou, como um campo de limite de crédito.

Segundo, o valor entra pela lambda () => c.Valor, e não por Expression.Constant. A lambda vira um acesso a uma variável capturada, que o EF Core trata como parâmetro, igual ao exemplo anterior. Uma constante seria embutida como literal no SQL, o que gera um plano diferente para cada valor digitado. Se você prefere não montar a árvore à mão, a biblioteca LinqKit oferece um PredicateBuilder para combinar lambdas com And e Or.

Erros comuns

  • Concatenar o filtro numa string SQL. Um apóstrofo quebra a consulta e um texto malicioso vira comando no banco.
  • Chamar ToList antes de filtrar. A tabela inteira vem para a memória e o filtro roda no servidor de aplicação.
  • Usar IEnumerable no lugar de IQueryable na variável. Os Where seguintes passam a rodar em memória, sem aviso.
  • Comparar data final com <= numa coluna com hora. Os registros do último dia somem do resultado.
  • Aceitar nome de coluna ou de ordenação direto da URL. A pessoa ordena ou filtra por campos que não deveria ver.
  • Ordenar sem desempate. A paginação repete e pula linhas.

Checklist

  • A consulta começa de um IQueryable e só executa no final.
  • Cada filtro vazio não aparece no SQL gerado.
  • Valores digitados chegam ao banco como parâmetros, conferidos com ToQueryString.
  • Data final usa limite exclusivo do dia seguinte.
  • Ordenação e campos dinâmicos saem de listas fechadas no código.
  • A listagem tem limite e projeção para DTO.
  • A composição foi testada com nenhum filtro, com um e com todos.

Pegue a tela de filtros mais usada do seu sistema e imprima o SQL com dois filtros preenchidos. Os valores aparecem como parâmetros ou colados no texto do comando?

Avalie este artigo

Referência: https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/how-to-use-expression-trees-to-build-dynamic-queries