Queryable.DynamicFilter
10.4.0
See the version list below for details.
dotnet add package Queryable.DynamicFilter --version 10.4.0
NuGet\Install-Package Queryable.DynamicFilter -Version 10.4.0
<PackageReference Include="Queryable.DynamicFilter" Version="10.4.0" />
<PackageVersion Include="Queryable.DynamicFilter" Version="10.4.0" />
<PackageReference Include="Queryable.DynamicFilter" />
paket add Queryable.DynamicFilter --version 10.4.0
#r "nuget: Queryable.DynamicFilter, 10.4.0"
#:package Queryable.DynamicFilter@10.4.0
#addin nuget:?package=Queryable.DynamicFilter&version=10.4.0
#tool nuget:?package=Queryable.DynamicFilter&version=10.4.0
Queryable.DynamicFilter
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.
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.
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:
inviaQueryFilterexige;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 umQueryFiltercom um único filtroine sem;nenhum cai no separador,, e a própria vírgula da lista doiné lida como separador de pares, quebrando em pedaços sem=e lançandoArgumentException. É 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).
IProjectable<TEntity, TSelf> — projeção sem repetir a expressão
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.
Ambiguidade de sobrecarga: existem duas sobrecargas de
ApplyFilterPaginatedAsynccom a mesma aridade — uma recebeprojectionexplícita, outra usaTDto.ProjectionviaIProjectable. Se você passarafterSpecposicionalmente como terceiro argumento (por exemplo, junto comnullno lugar de uma projeção), o compilador pode não conseguir decidir entre as duas. Use sempre o argumento nomeadoafterSpec:para desambiguar, como nos exemplos acima.
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
}
}
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 implementaIEnumerable(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 deList<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.EnderecoEntregaePedido.EnderecoCobranca, ambosEndereco) continuam os dois mapeados; (3) limite de profundidade —MaxDepth = 5níveis de aninhamento, além disso o mapeamento simplesmente para de descer. Na prática, isso significa queProduto.CategoriaeCategoria.Produtoscoexistindo não quebra mais nada, mas também que não dá para filtrar através de uma coleção (categoria.produtos.nomenão é endereçável — só o que estiver até 5 níveis de navegação simples de profundidade). - O operador
inusa a mesma conversão de valor dos demais operadores.BuildInExpression(emBuilders/FilterBuilder.cs) compartilha o conversor escalar usado porConvertValue, entãoGuid, enum,DateOnly,TimeOnly,Nullable<T>e o literal"null"funcionam normalmente dentro dein— por exemploid__in=3fa85f64-5717-4562-b3fc-2c963f66afa6,7c9e6679-7425-40de-944b-e07fc1f90ae7funciona. Duas coisas a saber: uma listainsem nenhum item válido após o split lançaArgumentException; e itens vazios ou só com espaço em branco na lista são ignorados silenciosamente (id__in=1,,2equivale aid__in=1,2). containssó é suportado emstring. Usarcontainsem qualquer outra propriedade lançaNotSupportedException.PageePageSizeignoram valores<= 0silenciosamente, mantendo o valor anterior (Pagedefault1,PageSizedefault10) em vez de lançar erro — vale emQuerySpec<T>e, por consequência, emRequestQuery.ToQuerySpec<T>().- Sem
sortexplícito,SortBuilderaplicaOrderBy(x => 0). Isso garante queSkip/Takesejam 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. - 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. QueryFiltermalformado também lançaArgumentException— 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 | Versions 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. |
-
net10.0
- Microsoft.AspNetCore.Mvc.Abstractions (>= 2.3.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
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 | 91 | 8/13/2026 |
| 10.7.0 | 94 | 8/13/2026 |
| 10.6.0 | 96 | 8/13/2026 |
| 10.5.0 | 88 | 8/13/2026 |
| 10.4.0 | 93 | 8/13/2026 |
| 10.3.0 | 99 | 8/13/2026 |
| 10.2.0 | 95 | 8/13/2026 |
| 10.1.0 | 2,887 | 6/2/2026 |
| 10.0.0 | 114 | 6/1/2026 |
| 2.4.0 | 381 | 6/11/2025 |
| 2.3.0 | 262 | 5/27/2025 |
| 2.2.0 | 229 | 5/26/2025 |
| 2.1.0 | 234 | 5/22/2025 |
| 2.0.0 | 225 | 5/21/2025 |
| 1.1.0 | 308 | 4/13/2025 |
| 1.0.0 | 282 | 4/13/2025 |