Queryable.DynamicFilter.EntityFrameworkCore 10.4.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Queryable.DynamicFilter.EntityFrameworkCore --version 10.4.0
                    
NuGet\Install-Package Queryable.DynamicFilter.EntityFrameworkCore -Version 10.4.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.EntityFrameworkCore" Version="10.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Queryable.DynamicFilter.EntityFrameworkCore" Version="10.4.0" />
                    
Directory.Packages.props
<PackageReference Include="Queryable.DynamicFilter.EntityFrameworkCore" />
                    
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.EntityFrameworkCore --version 10.4.0
                    
#r "nuget: Queryable.DynamicFilter.EntityFrameworkCore, 10.4.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.EntityFrameworkCore@10.4.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.EntityFrameworkCore&version=10.4.0
                    
Install as a Cake Addin
#tool nuget:?package=Queryable.DynamicFilter.EntityFrameworkCore&version=10.4.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.

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: 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).

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 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.

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 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.
  • 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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.12.0 0 8/25/2026
10.11.0 0 8/25/2026
10.10.0 0 8/25/2026
10.9.0 0 8/25/2026
10.8.1 0 8/25/2026
10.8.0 91 8/13/2026
10.7.0 85 8/13/2026
10.6.0 84 8/13/2026
10.5.0 80 8/13/2026
10.4.0 84 8/13/2026
10.3.0 89 8/13/2026
10.2.0 88 8/13/2026