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
- Receba os filtros num tipo próprio, com tudo anulável. Nulo significa "não filtrar".
- Comece pelo que sempre vale, como pedidos não excluídos.
- Acrescente um
Wherepor filtro preenchido. Trate data final como limite exclusivo do dia seguinte. - Escolha a ordenação a partir de uma lista fechada, nunca do texto da URL.
- Limite e projete.
TakeeSelectpara um DTO trazem só o que a tela mostra. - Confira o SQL gerado com
ToQueryStringdurante 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
ToListantes de filtrar. A tabela inteira vem para a memória e o filtro roda no servidor de aplicação. - Usar
IEnumerableno lugar deIQueryablena variável. OsWhereseguintes 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
IQueryablee 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?