Queryable.DynamicFilter 10.8.0

dotnet add package Queryable.DynamicFilter --version 10.8.0
                    
NuGet\Install-Package Queryable.DynamicFilter -Version 10.8.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Queryable.DynamicFilter" Version="10.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Queryable.DynamicFilter" Version="10.8.0" />
                    
Directory.Packages.props
<PackageReference Include="Queryable.DynamicFilter" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Queryable.DynamicFilter --version 10.8.0
                    
#r "nuget: Queryable.DynamicFilter, 10.8.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Queryable.DynamicFilter@10.8.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Queryable.DynamicFilter&version=10.8.0
                    
Install as a Cake Addin
#tool nuget:?package=Queryable.DynamicFilter&version=10.8.0
                    
Install as a Cake Tool

Queryable.DynamicFilter

NuGet Publish to NuGet

Filtro, ordenação e paginação dinâmicos para APIs ASP.NET Core, dirigidos por query string. Em vez de escrever um if para cada combinação de filtro possível, o cliente da API envia campo__operador=valor e a biblioteca monta a Expression<Func<T, bool>> correspondente em cima do seu IQueryable<T> — funciona com Entity Framework Core, mas não depende dele.

Os dois pacotes

Pacote O que faz Quando usar
Queryable.DynamicFilter Núcleo: constrói filtro (IFilterBuilder), ordenação (ISortBuilder) e aplica os dois sobre qualquer IQueryable<T> (IQuerySpecApplier). Sem dependência de EF Core. Você quer montar a página manualmente, ou sua fonte de dados não é EF Core (qualquer provider LINQ).
Queryable.DynamicFilter.EntityFrameworkCore Adiciona IPagedQueryService: filtro + ordenação + CountAsync + projeção para DTO + paginação em uma única chamada assíncrona. Depende de Microsoft.EntityFrameworkCore e referencia o núcleo. Sua fonte de dados é EF Core e você quer o fluxo pronto de ponta a ponta, incluindo a contagem total.

Instalação

dotnet add package Queryable.DynamicFilter

Se você usa EF Core e quer o serviço de paginação pronto, instale também:

dotnet add package Queryable.DynamicFilter.EntityFrameworkCore

Setup / DI

Núcleo — registra IFilterBuilder, ISortBuilder e IQuerySpecApplier como Scoped (idempotente: chamadas repetidas não sobrescrevem registros já feitos):

using Queryable.Extensions;

builder.Services.AddQueryableDynamicFilter();

EF Core — já chama AddQueryableDynamicFilter() internamente e adiciona IPagedQueryService:

using Queryable.EntityFrameworkCore.Extensions;

builder.Services.AddQueryableDynamicFilterEfCore();

Se você vai usar o Caminho A (binding automático de QuerySpec<T> a partir da query string, veja abaixo), registre também o model binder:

using Queryable.Extensions;

builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new QuerySpecModelBinderProvider());
});

Esse binder só é necessário para QuerySpec<T> recebido via [FromQuery]. Se você usa RequestQuery (Caminho B), não precisa dele — RequestQuery é um POCO simples e é resolvido pelo model binder padrão do ASP.NET Core.

Sintaxe da query string

Formato de cada filtro: campo__operador=valor. Sem o sufixo __operador (ex.: ?nome=ana), o comportamento é eq.

Operador Significado
eq Igual (padrão quando nenhum operador é informado)
neq Diferente
gt Maior que
lt Menor que
gte Maior ou igual
lte Menor ou igual
contains Contém — apenas para string
in Pertence a uma lista separada por vírgula

Propriedades aninhadas usam ponto: categoria.nome. Ordenação usa sort, com - para descendente e vírgula para múltiplos campos. Paginação usa page e pageSize; skipTotalCount=true pula o COUNT.

GET /api/produtos?ativo=true
GET /api/produtos?valor__gte=100&valor__lte=1000
GET /api/produtos?nome__contains=notebook
GET /api/produtos?categoria.nome__eq=Perifericos
GET /api/produtos?categoriaId__in=1,2,3
GET /api/produtos?sort=-valor,nome
GET /api/produtos?page=2&pageSize=20
GET /api/produtos?ativo=true&categoria.nome__contains=tech&valor__gt=50&sort=-criadoEm,nome&page=1&pageSize=10
GET /api/produtos?skipTotalCount=true

Quais campos são pesquisáveis (leia isto)

[Queryable] não restringe nada. Hoje, toda propriedade pública do tipo — e de qualquer tipo referenciado por navegação — é filtrável e ordenável por padrão. O atributo serve apenas para definir um alias diferente do nome da propriedade em C#.

Isso é implementado em PathExtension.BuildPropertyPaths<T> (pacote núcleo): o método varre type.GetProperties() e monta o mapa de aliases para todas as propriedades encontradas — a checagem que excluiria propriedades sem [Queryable] está comentada no código-fonte atual. Ou seja: se Produto tem uma propriedade pública SaldoDeCaixa sem [Queryable], ela é filtrável via ?saldoDeCaixa__gt=1000 de qualquer forma. Trate isso como superfície de exposição da API: qualquer propriedade pública de TEntity (e de suas navegações, recursivamente) pode ser consultada e ordenada por quem chama o endpoint, com ou sem o atributo. Para restringir isso — por exemplo, para impedir que uma propriedade sensível como SenhaHash seja consultável — veja a seção Configuração fluent abaixo.

using Queryable.Attributes;

public class Produto
{
    public Guid Id { get; set; }

    public string Nome { get; set; } = string.Empty;

    [Queryable("valor")]
    public decimal Preco { get; set; }

    public bool Ativo { get; set; }

    public DateTime CriadoEm { get; set; }

    public int CategoriaId { get; set; }

    public Categoria Categoria { get; set; } = default!;
}

public class Categoria
{
    public int Id { get; set; }

    public string Nome { get; set; } = string.Empty;
}

Aqui, Preco só é alcançável pelo alias valor (valor__gte=100) — sem o atributo ainda seria alcançável como preco__gte=100, mas com o alias definido o nome de exposição passa a ser o do alias. Todas as demais propriedades (Nome, Ativo, CriadoEm, CategoriaId, Categoria.Nome, Categoria.Id) são pesquisáveis pelo próprio nome, sem precisar de anotação — a correspondência é case-insensitive.

Configuração fluent

Anotar a entidade de domínio com [Queryable] obriga o projeto de Domínio a referenciar o pacote Queryable.DynamicFilter só para declarar um alias — inverte a direção de dependência que uma Clean/Onion Architecture normalmente exige (Domínio não deveria depender de nada). A configuração fluent resolve isso: os aliases são declarados numa classe fora do Domínio (tipicamente perto da API ou da Infra), derivada de QueryableConfiguration<TEntity>.

using Queryable.Configuration;

public class ProdutoQueryConfiguration : QueryableConfiguration<Produto>
{
    public ProdutoQueryConfiguration()
    {
        // Colapsa um caminho aninhado num alias plano — "categoria" resolve para Categoria.Nome.
        For(p => p.Categoria.Nome).As("categoria");

        // Sem As(...), o caminho fica registrado sob o alias padrão (nomes das propriedades
        // em minúsculo, separados por ponto) — equivalente a For(p => p.Ativo).As("ativo").
        For(p => p.Ativo);

        // Remove do mapa o alias automático correspondente a este caminho.
        Ignore(p => p.CriadoEm);

        // Opt-in: a partir daqui, só os aliases declarados com For(...) acima continuam
        // consultáveis para Produto — tudo o mais que a reflexão mapearia automaticamente
        // (incluindo CategoriaId, Categoria.Id etc.) deixa de existir no mapa.
        OnlyMapped();
    }
}

As chamadas a For(...), Ignore(...) e OnlyMapped() devem ocorrer no construtor da classe derivada — a configuração roda uma única vez, na inicialização da aplicação.

Registro no DI

using Queryable.Extensions;

builder.Services.AddQueryableDynamicFilter();
builder.Services.AddQueryableConfiguration<ProdutoQueryConfiguration>();

// Ou, para registrar todas as configurações de um assembly de uma vez:
builder.Services.AddQueryableConfigurationsFromAssembly(typeof(ProdutoQueryConfiguration).Assembly);

AddQueryableConfiguration<TConfiguration> e AddQueryableConfigurationsFromAssembly são encadeáveis com AddQueryableDynamicFilter, em qualquer ordem entre si. O que importa é a ordem em relação ao primeiro uso de IPropertyPathProvider/IFilterBuilder/ISortBuilder: o mapa de caminhos é cacheado por tipo na primeira resolução, então registrar (ou alterar) a configuração de um tipo depois de ele já ter sido consultado não tem efeito.

Semântica de mesclagem

O mapa final de cada tipo é composto assim, nesta ordem:

Passo Efeito
1. Mapa automático por reflexão Todas as propriedades públicas de TEntity (e navegações), como hoje — inclui aliases de [Queryable].
2. Ignore(...) Remove do mapa o(s) alias(es) cujo caminho corresponda estruturalmente ao caminho ignorado — a comparação é pela cadeia de PropertyInfo, não pelo texto do alias, então Ignore(p => p.Preco) remove o alias mesmo que ele venha de [Queryable("valor")].
3. Aliases configurados (For(...).As(...)) Cada alias configurado sobrescreve o automático de mesmo nome (ex.: For(p => p.Categoria.Nome).As("categoria") substitui o categoria que antes apontava só para Categoria) e coexiste com o alias automático aninhado (categoria.nome continua resolvendo, em paralelo a categoria) — os dois apontam para o mesmo caminho. Essa coexistência permite migrar o frontend para o novo alias sem quebrar o contrato HTTP num único deploy.
4. OnlyMapped() Se chamado, descarta tudo que não veio do passo 3 — o mapa final passa a conter apenas os aliases declarados via For(...) para aquele tipo. Decisão por tipo: outras entidades, configuradas ou não, não são afetadas.

Sem OnlyMapped(), configurar um tipo é estritamente aditivo: nada que já era consultável por reflexão deixa de ser, mesmo depois da configuração fluente entrar em vigor. OnlyMapped() é a forma de fechar essa superfície — por exemplo, para impedir que um campo sensível como SenhaHash seja filtrável, já que sem ele qualquer propriedade pública continua exposta por padrão (ver aviso na seção anterior).

O atributo [Queryable] continua funcionando exatamente como antes — a configuração fluente é aditiva, não o substitui. É possível misturar os dois: uma entidade com [Queryable("valor")] em Preco e uma QueryableConfiguration<Produto> que só colapsa Categoria.Nome.

Caminho A — query string automática com QuerySpec<T>

Com o model binder registrado, o controller recebe QuerySpec<T> já populado a partir da query string:

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Queryable.Core;
using Queryable.Extensions;
using Queryable.Interfaces;

[ApiController]
[Route("api/produtos")]
public class ProdutosController(AppDbContext context, IQuerySpecApplier querySpecApplier) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<PagedResult<ProdutoDto>>> Get(
        [FromQuery] QuerySpec<Produto> spec,
        CancellationToken ct)
    {
        IQueryable<Produto> query = context.Set<Produto>();

        IQueryable<Produto> filtered = querySpecApplier.Apply(query, spec);

        int totalCount = spec.SkipTotalCount ? 0 : await filtered.CountAsync(ct);

        List<ProdutoDto> items = await querySpecApplier
            .ApplyPaged(filtered, spec)
            .Select(p => new ProdutoDto
            {
                Id = p.Id,
                Nome = p.Nome,
                Preco = p.Preco
            })
            .ToListAsync(ct);

        return Ok(items.ToPagedResult(spec.Page, spec.PageSize, totalCount));
    }
}

IQuerySpecApplier.Apply aplica filtro (Where) e ordenação (OrderBy/ThenBy), mas ainda não pagina. ApplyPaged aplica Skip/Take. A contagem (CountAsync) precisa acontecer entre os dois — sobre o resultado de Apply, antes de ApplyPaged — porque ApplyPaged já corta o conjunto para o tamanho de uma página; contar depois dele daria o total da página atual, não o total do conjunto filtrado.

Caminho B — RequestQuery + IPagedQueryService (recomendado com EF Core)

Requer o pacote Queryable.DynamicFilter.EntityFrameworkCore. RequestQuery é um modelo achatado — mais fácil de expor em Swagger/OpenAPI do que um Dictionary<string, string> — que a biblioteca converte internamente em QuerySpec<T>:

public class RequestQuery
{
    public string? QueryFilter { get; set; }
    public string? Sort { get; set; }
    public int Page { get; set; } = 1;
    public int PageSize { get; set; } = 10;
    public bool SkipTotalCount { get; set; }
}

Formato de QueryFilter — regra do separador

QueryFilter concatena todos os filtros em uma única string, no formato campo__operador=valor (sem sufixo equivale a eq). A regra de separação entre pares:

Os pares são separados por ; se a string contiver ;; caso contrário, são separados por ,.

O motivo: o valor do operador in já usa vírgula como separador da lista (id__in=1,2,3). Se o separador de pares também fosse sempre vírgula, "id__in=1,2,3,ativo=true" seria fatiado em ["id__in=1", "2", "3", "ativo=true"] — e "2"/"3" não têm =, o que lança ArgumentException. Por isso, sempre que algum filtro usar in, use ; como separador de pares:

Certo:  "id__in=1,2,3;ativo=true"
Errado: "id__in=1,2,3,ativo=true"

Sem in na string, vírgula funciona normalmente: "nome=Notebook,ativo=true".

Armadilha: in via QueryFilter exige ; em algum lugar da string, mesmo com um único filtro. A regra acima decide o separador olhando só se a string contém ; — não se há mais de um par. Então um QueryFilter com um único filtro in e sem ; nenhum cai no separador ,, e a própria vírgula da lista do in é lida como separador de pares, quebrando em pedaços sem = e lançando ArgumentException. É preciso forçar o ;, mesmo sem um segundo filtro para separar:

Certo:  "categoriaid__in=1,2;"     (ponto-e-vírgula final, mesmo sem segundo filtro)
Certo:  "categoriaid__in=1,2;ativo=true"
Errado: "categoriaid__in=1,2"      (vira dois pares inválidos e lança ArgumentException)

ApplyFilterPaginatedAsync com projeção explícita

using Microsoft.AspNetCore.Mvc;
using Queryable.Core;
using Queryable.EntityFrameworkCore.Interfaces;

[ApiController]
[Route("api/produtos")]
public class ProdutosController(AppDbContext context, IPagedQueryService pagedQueryService) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<PagedResult<ProdutoDto>>> Get(
        [FromQuery] RequestQuery request,
        CancellationToken ct)
    {
        PagedResult<ProdutoDto> result = await pagedQueryService.ApplyFilterPaginatedAsync(
            context.Set<Produto>(),
            request,
            p => new ProdutoDto
            {
                Id = p.Id,
                Nome = p.Nome,
                Preco = p.Preco
            },
            afterSpec: query => query.Where(p => p.Ativo),
            ct: ct);

        return Ok(result);
    }
}

A consulta roda com AsNoTracking automaticamente. afterSpec é uma transformação opcional aplicada depois do filtro/ordenação (Apply) e antes da contagem e da paginação — útil para Include adicionais ou regras de segurança (multi-tenant, escopo do usuário logado) que não fazem sentido expor como filtro de query string.

SkipTotalCount (em RequestQuery ou diretamente em QuerySpec<T>) pula o CountAsync e retorna TotalCount = 0. Vale usar em listagens de alto volume onde o COUNT é caro e o cliente não precisa saber o total (ex.: scroll infinito).

Armadilha: ordenação em ApplyFilterPaginatedAsync

Isso custa horas de depuração se não for lido antes. QuerySpecApplier.Apply (chamado internamente por ApplyFilterPaginatedAsync, antes de afterSpec) sempre termina em SortBuilder.ApplySort. Sem sort no RequestQuery/QuerySpec<T>, ApplySort devolve query.OrderBy(x => 0)OrderBy, não ThenBy. OrderBy descarta qualquer ordenação já aplicada à query antes.

Consequências práticas:

  1. Ordenação aplicada à query antes de chamar ApplyFilterPaginatedAsync é silenciosamente descartada. context.Set<Produto>().OrderBy(p => p.Nome) passado como query não sobrevive ao Apply interno — o OrderBy(x => 0) (ou o OrderBy do sort do cliente) substitui essa ordenação assim que não houver ordenação real vinda da query string.
  2. A ordenação padrão da listagem precisa ser feita dentro do afterSpec, que roda depois do Apply interno (filtro + ordenação) e antes da contagem/paginação.
  3. Um OrderBy incondicional dentro de afterSpec sobrescreve o sort que o cliente pediu, pelo mesmo motivo: OrderBy sempre descarta a ordenação anterior, inclusive a que veio de sort na query string. O padrão precisa ser condicional:
afterSpec: q => string.IsNullOrWhiteSpace(request.Sort)
    ? q.OrderByDescending(p => p.CriadoEm)
    : q
  1. IQuerySpecApplier.Apply declara retorno IQueryable<T>, mas o valor devolvido em runtime é sempre um IOrderedQueryable<T> (produzido por ApplySort). Por isso afterSpec recebe IQueryable<T> — não IOrderedQueryable<T> — e não dá para encadear ThenBy ali sem um cast explícito para IOrderedQueryable<T> primeiro.

IProjectable<TEntity, TSelf> — projeção sem repetir a expressão

Pré-requisito arquitetural: só use quando o assembly dos DTOs puder referenciar o assembly das entidades. Implementar IProjectable<TEntity, TSelf> no DTO fecha o genérico sobre o tipo da entidade — ProdutoDto : IProjectable<Produto, ProdutoDto> obriga o assembly onde ProdutoDto mora a referenciar o assembly onde Produto mora. Em arquitetura em camadas com um projeto de contratos/DTOs deliberadamente isolado do domínio, isso quebra a fronteira — e a referência vaza transitivamente para tudo que consome esse projeto de contratos. Se esse é o seu caso, pule para a alternativa logo abaixo.

Em vez de passar a expressão de projeção em cada chamada, o próprio DTO pode declará-la como membro estático:

using System.Linq.Expressions;
using Queryable.Core;

public class ProdutoDto : IProjectable<Produto, ProdutoDto>
{
    public Guid Id { get; set; }
    public string Nome { get; set; } = string.Empty;
    public decimal Preco { get; set; }

    public static Expression<Func<Produto, ProdutoDto>> Projection =>
        produto => new ProdutoDto
        {
            Id = produto.Id,
            Nome = produto.Nome,
            Preco = produto.Preco
        };
}

E a chamada usa a sobrecarga de dois parâmetros de tipo, sem passar projection:

PagedResult<ProdutoDto> result = await pagedQueryService.ApplyFilterPaginatedAsync<Produto, ProdutoDto>(
    context.Set<Produto>(),
    request,
    afterSpec: query => query.Where(p => p.Ativo),
    ct: ct);

Ganhos sobre um mapeamento resolvido em tempo de execução: Projection é um membro static abstract (C# 11+), então esquecer de implementá-lo é erro de compilação, não falha em runtime; não há reflexão (Activator.CreateInstance, varredura de GetTypes()) para descobrir o mapeamento; e, por ser uma Expression (não um Func já compilado), o provider do EF Core traduz o Select para SQL — apenas as colunas usadas pelo DTO trafegam do banco, sem materializar Produto inteiro antes de mapear.

IProjectable fica declarado no pacote núcleo (Queryable.DynamicFilter), não no pacote de EF Core — então o assembly dos DTOs não precisa referenciar Entity Framework só para implementar a interface. Isso resolve o acoplamento com EF Core, mas é um eixo diferente do acoplamento com o domínio: IProjectable<Produto, ProdutoDto> ainda fecha o genérico sobre Produto, então o assembly do DTO referencia o assembly da entidade de qualquer forma, EF Core à parte — é exatamente o pré-requisito do início desta seção.

Ambiguidade de sobrecarga: existem duas sobrecargas de ApplyFilterPaginatedAsync com a mesma aridade — uma recebe projection explícita, outra usa TDto.Projection via IProjectable. Se você passar afterSpec posicionalmente como terceiro argumento (por exemplo, junto com null no lugar de uma projeção), o compilador pode não conseguir decidir entre as duas. Use sempre o argumento nomeado afterSpec: para desambiguar, como nos exemplos acima.

Alternativa sem acoplar o assembly dos DTOs ao domínio

Quando o pré-requisito acima não se aplica — DTOs vivem num projeto de contratos que não pode referenciar o domínio —, declare a projeção como Expression<Func<TEntity, TDto>> numa classe estática na camada que já referencia os dois lados (tipicamente a camada de aplicação/API) e use a sobrecarga de ApplyFilterPaginatedAsync com projection explícita, que já existe hoje e é o mesmo caminho usado no exemplo de ApplyFilterPaginatedAsync com projeção explícita:

public static class ProdutoProjections
{
    public static Expression<Func<Produto, ProdutoDto>> ToDto =>
        p => new ProdutoDto { Id = p.Id, Nome = p.Nome, Preco = p.Preco };
}
PagedResult<ProdutoDto> result = await pagedQueryService.ApplyFilterPaginatedAsync(
    context.Set<Produto>(),
    request,
    ProdutoProjections.ToDto,
    ct: ct);

Não se perde a garantia do compilador que o IProjectable dá — ela só muda de lugar: projection é parâmetro obrigatório nessa sobrecarga (sem overload que o dispense), então esquecer de passá-lo continua sendo erro de compilação. A diferença é só onde a cobrança mora: na declaração do DTO (IProjectable) ou no ponto de chamada (projeção explícita).

Formato da resposta

Ambos os caminhos devolvem PagedResult<T>:

public class PagedResult<T>
{
    public List<T> Items { get; set; } = [];
    public PageMeta Meta { get; set; } = new();
}

public class PageMeta
{
    public int Page { get; init; }
    public int PageSize { get; init; }
    public int TotalCount { get; init; }
    public int TotalPages { get; }   // Ceiling(TotalCount / PageSize)
    public bool HasPrevious { get; } // Page > 1
    public bool HasNext { get; }     // Page < TotalPages
}
{
  "items": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "nome": "Notebook",
      "preco": 4599.90
    }
  ],
  "meta": {
    "page": 1,
    "pageSize": 10,
    "totalCount": 42,
    "totalPages": 5,
    "hasPrevious": false,
    "hasNext": true
  }
}

Filtros compostos via JSON (OR, agrupamento e NOT)

Até aqui, tanto a query string (campo__operador=valor) quanto QueryFilter só combinam condições por AND — não há como expressar "nome contém X ou preço maior que Y" nesses dois formatos. Para isso, QuerySpec<T> e RequestQuery têm uma propriedade adicional, Filter, do tipo FilterNode? (Queryable.Filtering), que aceita uma árvore de filtro composto vinda de um corpo JSON: OR, agrupamento arbitrário e NOT.

FilterNode é uma hierarquia de records abstrata com três implementações:

Tipo Campos Significado
FilterCondition Field, Operator, Value Condição folha — mesma semântica de campo__operador=valor.
FilterGroup Logic (And/Or), Children Agrupamento de nós filhos, combinados pelo operador lógico indicado.
FilterNot Inner Negação lógica do nó interno.

A desserialização é feita por FilterNodeJsonConverter, já anotado em FilterNode via [JsonConverter(typeof(FilterNodeJsonConverter))] — não é preciso registrar nada em JsonSerializerOptions, o System.Text.Json.JsonSerializer padrão já resolve. O polimorfismo é decidido por presença de campo no objeto JSON, sem discriminador de tipo ($type):

  • objeto com fieldFilterCondition;
  • objeto com logic + childrenFilterGroup;
  • objeto com notFilterNot.

Outras regras do conversor:

  • operator ausente (ou vazio) numa condição assume "eq" — mesmo padrão do dicionário.
  • Nomes de propriedade JSON (field, operator, value, logic, children, not) e o valor de logic ("and"/"or") são case-insensitive.
  • Uma condição folha na raiz do body — { "field": ..., "value": ... }, sem logic em volta — é aceita diretamente, sem precisar embrulhá-la num grupo.

Os operadores aceitos dentro de operator são os mesmos já documentados na tabela de Sintaxe da query string: eq, neq, gt, lt, gte, lte, contains (só string) e in.

Exemplo de corpo de requisição — produtos cujo nome contenha "notebook" ou (preço ≥ 100 e ativos), usando o domínio Produto/Categoria já apresentado acima:

{
  "logic": "or",
  "children": [
    { "field": "nome", "operator": "contains", "value": "notebook" },
    {
      "logic": "and",
      "children": [
        { "field": "preco", "operator": "gte", "value": "100" },
        { "field": "ativo", "value": "true" }
      ]
    }
  ]
}

Um controller que recebe RequestQuery no corpo (POST, em vez de [FromQuery]) repassa o Filter normalmente — ToQuerySpec<T>() copia a árvore para QuerySpec<T>.Filter sem tratamento especial:

using Microsoft.AspNetCore.Mvc;
using Queryable.Core;
using Queryable.EntityFrameworkCore.Interfaces;

[ApiController]
[Route("api/produtos")]
public class ProdutosController(AppDbContext context, IPagedQueryService pagedQueryService) : ControllerBase
{
    [HttpPost("buscar")]
    public async Task<ActionResult<PagedResult<ProdutoDto>>> Buscar(
        [FromBody] RequestQuery request,
        CancellationToken ct)
    {
        PagedResult<ProdutoDto> result = await pagedQueryService.ApplyFilterPaginatedAsync(
            context.Set<Produto>(),
            request,
            p => new ProdutoDto { Id = p.Id, Nome = p.Nome, Preco = p.Preco },
            ct: ct);

        return Ok(result);
    }
}

Regra de combinação com Filters/QueryFilter: se Filter for nulo, nada muda — só o dicionário (Filters) é considerado, exatamente como antes desta funcionalidade existir. Se os dois vierem preenchidos ao mesmo tempo, o predicado final é o AND dos dois conjuntos de condições (AND(Filters, Filter)) — um nunca sobrescreve o outro. Isso é resolvido por IFilterBuilder.BuildPredicate<T>(IDictionary<string, string>, FilterNode?), chamado internamente por QuerySpecApplier.Apply.

A combinação por OR/agrupamento/NOT via Filter funciona tanto vindo do corpo JSON quanto vindo da mini-linguagem textual (seção seguinte) — as duas alimentam a mesma árvore. Só a query string tradicional (campo__operador=valor, que popula QuerySpec<T>.Filters) e QueryFilter continuam sendo exclusivamente AND; quem precisa de OR/agrupamento/NOT direto na query string usa o parâmetro filter=, descrito a seguir.

Filtros compostos na query string (mini-linguagem)

A porta JSON acima resolve OR/agrupamento/NOT para quem monta o filtro programaticamente ou consegue enviar corpo de requisição, mas não ajuda quem só tem GET disponível. Para isso existe uma mini-linguagem textual pensada para caber num único parâmetro de query string — parênteses, and, or e not direto na URL, sem POST com corpo JSON.

O parâmetro reconhecido é filter na query string (QuerySpecModelBinder<T>.BuildSpec tem um ramo dedicado para ele — nunca cai no fallback que popula Filters). Em RequestQuery, a propriedade equivalente é FilterExpression (string?). Nos dois casos, o texto é interpretado por FilterExpressionParser.Parse(string) (Queryable.Filtering) e produz a mesma árvore FilterNode que a porta JSON gera — passa pelo mesmo compilador FilterNode → Expression, sem caminho de compilação separado.

expr       := orExpr
orExpr     := andExpr ( "or" andExpr )*
andExpr    := unary ( "and" unary )*
unary      := "not"? primary
primary    := "(" expr ")" | comparison
comparison := field ( "__" operator )? "=" value

Precedência: not liga mais forte que and, que liga mais forte que ora or b and c equivale a a or (b and c), e not a and b equivale a (not a) and b. Parênteses sobrepõem qualquer precedência.

and, or e not são case-insensitive (AND, And, and são equivalentes) e só contam como palavra-chave quando aparecem como token isolado fora de aspas — nunca como substring de um valor não citado. Sem sufixo __operador na chave, o operador é eq, com os mesmos operadores já documentados em Sintaxe da query string.

Regra de aspas (onde a maioria erra)

Um valor precisa vir entre aspas duplas sempre que contiver espaço, (, ), ,, =, ou for igual — ignorando maiúsculas/minúsculas — a and/or/not. Dentro de aspas, \" é aspa literal e \\ é barra invertida literal; nenhum outro escape é suportado. Fora de aspas não existe escape: o valor termina no primeiro espaço, ), , ou fim da string, e aspas abertas e não fechadas antes do fim da expressão são erro de sintaxe.

Operador in

in exige uma lista entre parênteses: id__in=(1,2,3). Isso resolve de vez a ambiguidade que existe em RequestQuery.QueryFilter (seção acima), onde a vírgula é ao mesmo tempo separador de pares campo=valor e separador de itens de in, obrigando a trocar o separador de pares para ;. Cada item da lista segue a mesma regra de aspas de um valor simples: tag__in=("a b",c).

Limitação conhecida: um item de in cujo conteúdo resolvido contém uma vírgula literal (só possível quando o item veio entre aspas, ex.: tag__in=("a, b",c)) ainda não é suportado — o parser rejeita com um erro claro em vez de gerar um filtro in silenciosamente errado (a vírgula ficaria ambígua no CSV que o operador in usa internamente).

Exemplos

Expressão Válida? Por quê
nome=joao sim equivale a nome__eq=joao
nome__contains=jo and ativo=true sim duas condições combinadas por AND
(nome__contains=ana or nome__contains=joao) and ativo=true sim OR interno agrupado, AND externo
not ativo=false sim negação de uma condição simples
id__in=(1,2,3) sim lista sem ambiguidade de separador
tag__in=("a b",c) sim item entre aspas sem vírgula literal — item único
nome="and joão" sim valor que colide com palavra-chave, escapado com aspas
nome=and joão não and fora de aspas é lido como palavra-chave, quebra o parse
nome=joão silva não espaço fora de aspas termina o valor antes do esperado
(nome=ana não parêntese não fechado
nome="joão não aspas não fechadas
id__in=1,2,3 não lista de in fora de parênteses — ambígua com separador de campos
tag__in=("a, b",c) não item de in com vírgula literal dentro das aspas — ver limitação acima
GET /api/produtos?filter=(nome__contains=ana or nome__contains=joao) and ativo=true
GET /api/produtos?filter=not ativo=false
GET /api/produtos?filter=id__in=(1,2,3)

Qualquer erro de sintaxe (parêntese ou aspas não fechados, palavra-chave sem aspas em posição de valor, in fora de parênteses, item de in com vírgula literal, token sobrando no fim da expressão etc.) lança FilterExpressionSyntaxException — especialização de ArgumentException, com a propriedade Position (1-based, posição aproximada do erro na string de entrada). Via filter= em [FromQuery] QuerySpec<T> (Caminho A), esse erro — e também um erro de limite (ver Limites de segurança de filtros compostos) — é capturado por QuerySpecModelBinder<T> e vira 400 automaticamente com [ApiController]. Chamando FilterExpressionParser.Parse/RequestQueryExtensions.ToQuerySpec<T>() diretamente (Caminho B), a exceção continua sendo lançada normalmente: sem um middleware de exceção, ela vira 500 no cliente em vez de 400.

Combinação com a árvore JSON: em RequestQuery, Filter (árvore JSON) e FilterExpression (mini-linguagem) podem ser preenchidos ao mesmo tempo. Se só um dos dois vier preenchido, QuerySpec<T>.Filter recebe esse valor sem alteração; se os dois vierem preenchidos, o predicado final é o AND dos dois — FilterGroup(FilterLogic.And, [Filter, filtro-da-expressão]), nunca um sobrescreve o outro. RequestQueryExtensions.ToQuerySpec<T>() faz essa combinação.

Limites de segurança de filtros compostos

As duas portas acima (JSON via Filter e mini-linguagem via filter/FilterExpression) aceitam uma expressão booleana arbitrária vinda de um cliente externo — na prática, um pequeno "programa" que o servidor concorda em executar. Sem teto, uma árvore muito aninhada ou com centenas de nós faz o compilador FilterNode → Expression montar uma Expression gigante e o banco planejar um WHERE desproporcional — negação de serviço tanto na aplicação (tempo/memória de montagem) quanto no banco (tempo de planejamento/execução da query). Não é preciso má-fé: um frontend com bug montando a expressão dentro de um laço já é suficiente.

FilterLimits (Queryable.Filtering) define quatro tetos, todos com default:

Limite Default O que protege
MaxDepth 6 Profundidade de aninhamento de FilterGroup/FilterNot na árvore.
MaxNodes 100 Número total de nós (FilterCondition + FilterGroup + FilterNot, somados recursivamente) na árvore.
MaxExpressionLength 4096 Tamanho, em caracteres, da string de entrada da mini-linguagem — verificado antes de tokenizar.
MaxInItems 200 Quantidade de itens na lista CSV de uma condição com operator: "in".

Como configurar

AddQueryableDynamicFilter tem uma sobrecarga que recebe um Action<FilterLimits> para sobrescrever os defaults, registrando o resultado como Singleton (a mesma instância vale para toda a aplicação, resolvida por requisição a partir do DI):

using Queryable.Extensions;

builder.Services.AddQueryableDynamicFilter(limits =>
{
    limits.MaxDepth = 8;
    limits.MaxNodes = 200;
});

Quando a validação acontece

A validação ocorre antes de qualquer compilação para Expression:

  • MaxExpressionLength é verificado sobre a string bruta da mini-linguagem, antes mesmo de tokenizar — uma expressão absurdamente longa é rejeitada por tamanho mesmo que sintaticamente inválida, sem gastar tempo de parsing nela.
  • Os outros três limites são verificados por FilterLimitValidator.Validate logo depois que a árvore é montada (ao final de FilterExpressionParser.Parse) ou desserializada (FilterNodeJsonConverter.Read), sempre antes de o compilador FilterNode → Expression de FilterBuilder tocar nela.

Como consequência, uma árvore que ao mesmo tempo excede um limite e referencia um campo inexistente falha pelo limite, não pelo campo — o validador nunca resolve nomes de propriedade (isso é responsabilidade de uma etapa posterior e separada, o compilador), então o único erro possível nesse cenário é FilterLimitExceededException.

A soma das duas origens conta

Em RequestQuery, Filter (árvore JSON) e FilterExpression (mini-linguagem) podem, cada um isoladamente, caber dentro de todos os limites e a combinação dos dois estourar um teto — por exemplo, dois grupos de 55 condições cada um ficam abaixo do MaxNodes default (100), mas a árvore combinada (FilterGroup(And, [Filter, filtro-da-expressão])) soma 113 nós. Por isso RequestQueryExtensions.ToQuerySpec<T>() (e, do mesmo jeito, QuerySpecModelBinder<T>.BuildSpec) revalida a árvore final já combinada, inteira, depois de montá-la — não basta cada origem passar isoladamente.

O erro

Um limite violado lança FilterLimitExceededException (Queryable.Filtering), especialização de ArgumentException. Expõe Limit (FilterLimitKind: MaxDepth, MaxNodes, MaxExpressionLength ou MaxInItems), Found (o valor efetivamente encontrado) e Allowed (o teto configurado), além da mensagem já com os números embutidos.

Resposta HTTP: 400 em vez de 500

QuerySpecModelBinder<T>.BindModelAsync (o binder usado por [FromQuery] QuerySpec<T>, Caminho A) captura FilterExpressionSyntaxException e FilterLimitExceededException e as converte em ModelState/ModelBindingResult.Failed() — com [ApiController], isso vira automaticamente um 400 com a mensagem do erro, em vez do 500 não tratado que a exceção produziria antes. Essa é uma mudança de comportamento em relação às versões anteriores, onde erro de sintaxe da mini-linguagem e (agora) erro de limite escapavam como exceção não capturada.

Essa conversão para 400 é específica do binder — o método estático QuerySpecModelBinder<T>.BuildSpec continua lançando as duas exceções (é assim que fica testável sem infraestrutura web), e RequestQueryExtensions.ToQuerySpec<T>() (Caminho B, chamado manualmente dentro da ação do controller) também lança normalmente. Se você chama ToQuerySpec<T>() você mesmo dentro de uma ação, sem um middleware de exceção global, o mesmo erro ainda vira 500.

Limitação conhecida: o conversor JSON sempre usa os defaults

FilterNodeJsonConverter (o JsonConverter<FilterNode> que desserializa Filter) valida a árvore assim que termina de montá-la, mas System.Text.Json não passa IServiceProvider para um JsonConverter, então essa validação usa sempre FilterLimits.Default — os limites configurados via AddQueryableDynamicFilter(Action<FilterLimits>) no container de DI não chegam até ali. Consequência prática: se você configurar limites mais permissivos que os defaults (ex.: MaxNodes = 200), um corpo JSON com 150 nós ainda é barrado pelos defaults (100) na desserialização, antes mesmo de chegar ao binder ou a ToQuerySpec.

Os limites configurados são de fato aplicados depois, na revalidação da árvore final feita por QuerySpecModelBinder<T>.BuildSpec e por RequestQueryExtensions.ToQuerySpec<T>() — mas só protegem contra limites mais restritivos que os defaults, porque tudo que já passou pelo conversor JSON necessariamente respeitou os defaults primeiro.

Limitações e armadilhas conhecidas

  • Navegação bidirecional é segura; coleções não são navegáveis; há teto de profundidade. PathExtension.BuildPropertyPaths<T> tem três guardas contra o mapeamento explodir: (1) guarda de coleção — uma propriedade cujo tipo implementa IEnumerable (e não é string) continua entrando no mapa de aliases, mas a recursão não desce dentro dela; isso elimina o vetor clássico de recursão infinita em navegação bidirecional de EF Core (Produto.Categoria / Categoria.Produtos) e também os aliases lixo que antes vinham de List<T> (.capacity, .count, .item); (2) guarda de ciclo por caminho — um tipo já presente no caminho atual não é reentrado, mas isso vale só por caminho, não globalmente, de propósito: ramos irmãos do mesmo tipo (ex.: Pedido.EnderecoEntrega e Pedido.EnderecoCobranca, ambos Endereco) continuam os dois mapeados; (3) limite de profundidadeMaxDepth = 5 níveis de aninhamento, além disso o mapeamento simplesmente para de descer. Na prática, isso significa que Produto.Categoria e Categoria.Produtos coexistindo não quebra mais nada, mas também que não dá para filtrar através de uma coleção (categoria.produtos.nome não é endereçável — só o que estiver até 5 níveis de navegação simples de profundidade).
  • O operador in usa a mesma conversão de valor dos demais operadores. BuildInExpression (em Builders/FilterBuilder.cs) compartilha o conversor escalar usado por ConvertValue, então Guid, enum, DateOnly, TimeOnly, Nullable<T> e o literal "null" funcionam normalmente dentro de in — por exemplo id__in=3fa85f64-5717-4562-b3fc-2c963f66afa6,7c9e6679-7425-40de-944b-e07fc1f90ae7 funciona. Duas coisas a saber: uma lista in sem nenhum item válido após o split lança ArgumentException; e itens vazios ou só com espaço em branco na lista são ignorados silenciosamente (id__in=1,,2 equivale a id__in=1,2).
  • contains só é suportado em string. Usar contains em qualquer outra propriedade lança NotSupportedException.
  • Page e PageSize ignoram valores <= 0 silenciosamente, mantendo o valor anterior (Page default 1, PageSize default 10) em vez de lançar erro — vale em QuerySpec<T> e, por consequência, em RequestQuery.ToQuerySpec<T>().
  • Sem sort explícito, SortBuilder aplica OrderBy(x => 0). Isso garante que Skip/Take sejam avaliados de forma determinística pelo provider LINQ, mas não implica ordem estável entre páginas no banco — se os dados mudam entre duas requisições paginadas sem ordenação real, o mesmo item pode aparecer em páginas diferentes ou ser pulado.
  • Esse mesmo OrderBy(x => 0) descarta qualquer ordenação aplicada à query antes de ApplyFilterPaginatedAsync, e um OrderBy incondicional em afterSpec sobrescreve o sort do cliente pelo mesmo motivo — ver Armadilha: ordenação em ApplyFilterPaginatedAsync para o padrão correto (ordenação padrão condicional dentro do afterSpec).
  • Chave de filtro ou campo de ordenação desconhecido lança ArgumentException ("Campo 'X' não é pesquisável." para filtro; mensagem equivalente para ordenação). Uma query string malformada ou com campo inexistente vira uma exceção não tratada — sem um middleware/filtro de exceção global, isso retorna 500 ao cliente em vez de 400.
  • QueryFilter malformado também lança ArgumentException — um item sem = (ex.: "ativo") ou com chave vazia (ex.: "=true") invalida a requisição inteira.

Testes

O repositório tem suíte automatizada em tests/Queryable.Tests (núcleo) e tests/Queryable.EntityFrameworkCore.Tests (integração EF Core, contra SQLite in-memory — necessário para pegar erro de tradução para SQL que uma lista em memória não revelaria). Roda com:

dotnet test

Licença

MIT License © 2025 Samuel G. F. Dias

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Queryable.DynamicFilter:

Package Downloads
Queryable.DynamicFilter.EntityFrameworkCore

Integração Entity Framework Core para o Queryable.DynamicFilter: paginação assíncrona com projeção para DTO.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.8.0 58 8/13/2026
10.7.0 64 8/13/2026
10.6.0 63 8/13/2026
10.5.0 57 8/13/2026
10.4.0 62 8/13/2026
10.3.0 69 8/13/2026
10.2.0 64 8/13/2026
10.1.0 2,850 6/2/2026
10.0.0 113 6/1/2026
2.4.0 380 6/11/2025
2.3.0 261 5/27/2025
2.2.0 228 5/26/2025
2.1.0 232 5/22/2025
2.0.0 224 5/21/2025
1.1.0 307 4/13/2025
1.0.0 281 4/13/2025