Janzen.Pagination.EntityFrameworkCore
10.1.0
Prefix Reserved
dotnet add package Janzen.Pagination.EntityFrameworkCore --version 10.1.0
NuGet\Install-Package Janzen.Pagination.EntityFrameworkCore -Version 10.1.0
<PackageReference Include="Janzen.Pagination.EntityFrameworkCore" Version="10.1.0" />
<PackageVersion Include="Janzen.Pagination.EntityFrameworkCore" Version="10.1.0" />
<PackageReference Include="Janzen.Pagination.EntityFrameworkCore" />
paket add Janzen.Pagination.EntityFrameworkCore --version 10.1.0
#r "nuget: Janzen.Pagination.EntityFrameworkCore, 10.1.0"
#:package Janzen.Pagination.EntityFrameworkCore@10.1.0
#addin nuget:?package=Janzen.Pagination.EntityFrameworkCore&version=10.1.0
#tool nuget:?package=Janzen.Pagination.EntityFrameworkCore&version=10.1.0
Janzen.Pagination.EntityFrameworkCore
Provider-agnostic, configuration-driven pagination, filtering and sorting engine built for Entity Framework Core.
The core of Janzen.Pagination: a fluent
per-entity configuration, an opinionated query-string contract, strict validation, and a
ready-made paginated response. Usable on its own; pair it with
Janzen.Pagination.PostgreSql
for native ILIKE search and
Janzen.Pagination.AspNetCore
for web pipeline integration.
The query-string contract is borrowed from nestjs-paginate (MIT) — the same parameters, operator names and response envelope.
Install
dotnet add package Janzen.Pagination.EntityFrameworkCore
Quick start
// 1. Describe what is sortable / searchable / filterable for an entity.
public sealed class ProductConfigProvider : IPaginateConfigProvider<Product>
{
public PaginateConfig<Product> GetConfig() =>
PaginateConfig<Product>.Create(b => b
.WithLimits(defaultLimit: 25, maxLimit: 100)
.Sortable("name", p => p.Name).ShowBadge("Public", "language-public") // optional badge shown in the API reference UI
.DefaultSortBy("name")
.WithTieBreaker(p => p.Id) // unique key appended as the final ordering → deterministic paging
.Searchable("name", p => p.Name)
.Filterable("status", p => p.Status, PaginateFilterOperator.Eq));
}
// 2. Build a request (bound from the query string in ASP.NET, or directly for non-web callers)…
var request = new PaginateQuery { Page = 1, Limit = 25, SortBy = ["name:DESC"] };
// 3. …and execute against an IQueryable, projecting to a DTO.
PaginatedResponse<ProductDto> response = await dbContext.Products
.PaginateAsync<Product, ProductDto>(request, config);
Navigating pages
response.Links holds first/previous/next/last as URLs, but only when a PaginateLinkContext (path +
query parameters) was supplied — the ASP.NET Core package builds one from HttpRequest. Without a context
Links is null, because a URL is meaningless with no request to be relative to. Inside it, an absent link
(previous on the first page, next on the last) is null and stays in the payload as null.
Meta carries the counters plus hasPreviousPage / hasNextPage and an echo of the effective request —
sortBy, search, searchBy, filter after the config's defaults have been applied. That is what a grid
header needs: a request that sent no sortBy still gets "sortBy": ["name:ASC"] back, because only the
server knows where DefaultSortBy landed. See
Response contract.
Off the web, navigate by Meta and WithPage instead; the result goes straight back into the engine:
var next = response.Meta.HasNextPage
? request.WithPage(response.Meta.CurrentPage + 1)
: null; // last page
// WithPage carries limit, sort, search and filters over — only the page changes.
Declaring fields
| Builder call | Enables | Notes |
|---|---|---|
.WithLimits(default, max) |
page, limit |
Required — there is no implicit page size, unless shared defaults supply one. |
.Sortable(name, expr) |
sortBy=name:ASC\|DESC |
Any expression the provider can put in ORDER BY. |
.DefaultSortBy(name, dir) |
— | Used when the request sends no sortBy; the field must be sortable. |
.WithTieBreaker(expr) |
— | Required — unique key appended as the final ordering key on every query, so offset paging is deterministic. |
.Searchable(name, expr) |
search, searchBy=name |
Selector must return string?. |
.Filterable(name, expr, ops…) |
filter.name=$op:value |
At least one operator; the list is that field's allow-list. |
.Filterable(name, expr) |
filter.name=$op:value |
Every operator the engine can build for TValue — see Operator defaults below. |
.FilterableMany(name, coll, expr, ops…) |
filter.name=$op:value |
Matches any element of a child collection (Any(...)). |
.FilterableMany(name, coll, expr) |
filter.name=$op:value |
The same derivation, from the value selector's type. |
.WithGuards(…) |
— | Ceilings on filter values / conditions / sort fields / search length — the last bounds $ilike / $sw / $contains patterns too. |
.WithMinSearchLength(n) |
— | Rejects a search term shorter than n, measured after trimming, and a shorter $ilike / $sw / $contains pattern, measured as sent. |
.WithMaxOffset(n) |
— | Rejects a request that would skip more than n rows, before the count runs. |
.AllowUnlimited(maxRows) |
limit=-1 |
Opts into one-page-everything, with a mandatory row ceiling. |
.ShowBadge(name, cssClass?) |
— | Labels the preceding field in the OpenAPI output. |
.When(bool) |
— | Gates the preceding field at query time; must be paired with .ShowBadge. |
// Filter articles by any of their tags: ?filter.tag=$in:dotnet,efcore
.FilterableMany("tag", a => a.Tags, t => t.Slug,
PaginateFilterOperator.Eq, PaginateFilterOperator.In)
// Tighter guards than the defaults (100 / 20 / 5 / 256)
.WithGuards(maxFilterValues: 25, maxSortFields: 3) // an omitted guard is left unset, not reset
Shared limits and guards
Every limit and guard can come from a shared object instead of being retyped per resource — explicitly per config, or once at startup:
var defaults = new PaginateConfigDefaults { DefaultLimit = 25, MaxLimit = 100 };
PaginateConfig<Product>.Create(defaults, b => …); // explicit
PaginateConfigDefaults.Shared = defaults; // ambient, read at Build() time
A WithLimits / WithGuards / WithX call beats the object passed to Create, which beats .Shared, which
beats the engine’s own constant — so a shared value is never a ceiling a config cannot raise. AllowUnlimited
is deliberately not shareable: an unbounded read is a claim about one resource’s size.
Full reference
Operator defaults
Omitting the operator list grants every operator the engine can build for that type: the pattern operators
for string, the range operators for numbers and dates, Eq/In for Guid, char and enums, and Null
wherever the value can actually be null. A type with no derivation throws at configuration time rather than
guessing.
.Filterable("age", p => p.Age) // Eq, In, Gt, Gte, Lt, Lte, Between
.Filterable("name", p => p.Name) // Eq, In, Null, StartsWith, Contains, ILike
.Filterable("price", p => p.Price, PaginateFilterOperators.For<decimal>()) // the same set, spelled out
A field declared this way widens when the library adds an operator to one of those rows, which is called out in that release's notes. On a large table, prefer listing what you actually serve.
A selector may cross a navigation — p => p.Author!.Name, conventionally named author.name — and on a
plain IQueryable a row whose intermediate is null behaves as the database would: no match, and $null
matches. Full reference
Badges
Attach an optional presentation badge (a label and optional CSS class) to any sortable, searchable or filterable
field with .ShowBadge(name, cssClass?) immediately after declaring it. Badges surface in the generated OpenAPI
metadata and render as chips in the API reference UI (e.g. Scalar):
.Sortable("slug", p => p.Slug).ShowBadge("Public", "language-public")
.Searchable("title", p => p.Title).ShowBadge("Beta") // no class → neutral chip
.Filterable("id", p => p.Id, PaginateFilterOperator.Eq).ShowBadge("Stable", "language-stable")
ShowBadge targets the field declared immediately before it. The library imposes no palette — you color the chip via
your API reference's custom CSS. The class must start with language-: it is the only class prefix Scalar's
markdown sanitizer keeps in a parameter description (inline styles and other classes are stripped), so ShowBadge
throws otherwise. Then register e.g. .language-public { background:#277A2C; color:#fff; border-radius:4px; padding:1px 6px }.
Conditional fields (RBAC)
Mark a field with .When(bool) to make it conditional: it stays in the OpenAPI docs (the widest surface) but at
query time is treated as not configured whenever the condition is false — a request targeting it gets a 400, worded
exactly like an unknown field so the field's existence isn't disclosed. .When must be paired with .ShowBadge(...)
so the restriction is documented, otherwise Build() throws:
PaginateConfig<Article>.Create(b => b
.WithLimits(25, 100)
.WithTieBreaker(a => a.Id)
.Sortable("title", a => a.Title)
.Filterable("isDeleted", a => a.IsDeleted, PaginateFilterOperator.Eq)
.When(currentUser.IsAdmin).ShowBadge("Admin only", "language-admin"));
.When takes a plain boolean — you evaluate it from your own context (role, claims, tenant, feature flag); the library
stays auth-agnostic. The condition is captured when the config is built, so per-user gating means one cached config
per distinct set of permissions — two roles, two static fields — rather than calling Create per request. Building
walks every selector expression tree, which is not work that belongs on a hot path.
Projection strategies
Four ways to shape each page row into a DTO — pick the cheapest that fits:
| Strategy | Entry point | Runs where | Use for |
|---|---|---|---|
| Auto | PaginateAsync<TEntity, TResult>(request, config) |
SQL | DTOs buildable by convention: scalars (DateOnly, TimeOnly and TimeSpan included), single nested objects, and the NodaTime → BCL conversions such as Instant → DateTimeOffset. |
| Selector | PaginateSelectAsync<TEntity, TResult>(request, config, selector) |
SQL (+ shaper) | Anything expressible as a Select: aggregates, sub-collections, conversions — in one narrow query. |
| Selector + finalize | PaginateSelectMapAsync<TEntity, TProjection, TResult>(request, config, selector, postMap) |
SQL + in-memory | Most of the row translates, but a field or two needs a CLR computation EF can't translate (weighted aggregate over a sub-collection with a guard/rounding). Narrow SELECT; postMap finalizes the page only (O(page size)). |
| Map | PaginateMapAsync<TEntity, TResult>(request, config, map) |
in memory | Only when the response needs the fully loaded entity. Over-fetches by design. |
Each strategy has its own name rather than being an overload of PaginateAsync, so the call site says which one it
uses: Select means projected in SQL, Map means mapped in memory.
TEntity comes first in every entry point: these are C# extension-block members, so explicit type arguments
must name the entity type before the result type. Where a selector, postMap or map lambda is passed,
all type arguments are inferable — db.Products.PaginateSelectAsync(request, config, selector) also compiles.
Sub-collections + NodaTime conversions in a single query
A DTO that has both one-to-many sub-collections and Instant → DateTimeOffset conversions
(even inside the sub-collection items) does not need PaginateMapAsync. Because the selector is the
query's terminal projection, EF Core runs the column reads and sub-collection materialization in SQL and
applies the (free) Instant → DateTimeOffset reinterpret in the shaper, over the page rows only:
PaginatedResponse<ProductSummary> page = await db.Products.PaginateSelectAsync<Product, ProductSummary>(request, config,
p => new ProductSummary(
p.Id,
p.Name,
p.ReleasedAt.ToDateTimeOffset(), // Instant → DateTimeOffset
p.DiscontinuedAt.HasValue ? p.DiscontinuedAt.Value.ToDateTimeOffset() // Instant? → DateTimeOffset?
: (DateTimeOffset?)null,
p.Reviews.Select(r => new ReviewDto(r.Id, r.Reviewer,
r.PostedAt.ToDateTimeOffset())).ToList())); // conversion INSIDE a sub-collection
This executes as a single query whose SELECT lists only the referenced columns — an unused jsonb
column is never fetched. The Instant → DateTimeOffset casts come from
Janzen.Pagination.NodaTime.
Query-string contract
| Parameter | Repeatable | Example | Meaning |
|---|---|---|---|
page |
no | ?page=2 |
1-based page number; defaults to 1. |
limit |
no | ?limit=50 |
Page size; defaults to DefaultLimit, rejected above MaxLimit. |
sortBy |
yes | ?sortBy=price:DESC&sortBy=name:ASC |
Applied in the order given. |
search |
no | ?search=smith |
Free text over the searchable fields. |
searchBy |
yes | ?searchBy=name |
Narrows search to a subset of them. |
filter.<field> |
yes | ?filter.status=$eq:Active |
One or more criteria per field. |
Anything else in the query string is ignored, so clients keep their own tracking parameters. page and
limit are validated and return 400.
Filter operators
filter.<field> = [$not:] [$and: | $or:] $<operator>[:<value>[,<value>…]]
| Token | Applies to | Meaning |
|---|---|---|
$eq |
any | = value |
$in |
any | IN (a, b, c) — comma-separated |
$null |
any | IS NULL |
$sw |
string | LIKE 'value%' |
$ilike |
string | LIKE '%value%' — native ILIKE with the PostgreSql package |
$contains |
string or collection | on a string: same as $ilike; on a collection: contains all listed values |
$lt $lte $gt $gte |
comparable | < <= > >= |
$btw |
comparable | inclusive range — exactly two comma-separated values |
Each field whitelists its own
operators; one that is not
granted for that field is a 400.
?filter.status=$in:Active,Draft
?filter.price=$btw:10,99.90
?filter.deletedAt=$not:$null
?filter.price=$gte:100&filter.price=$lte:500 # criteria on one field default to AND
?filter.status=$eq:Active&filter.status=$or:$eq:Draft
Criteria on different fields are always ANDed; there is no cross-field OR or grouping. $and / $or say how a
criterion joins the one before it, so a field's first criterion cannot carry one — filter.status=$or:$eq:Draft
is a 400. Enums are addressed by name (Active), numbers and dates use the invariant culture, and values are
emitted as SQL parameters rather than inlined literals.
Any type implementing IParsable<TSelf> is filterable with no registration at all, so a strongly-typed id
of your own works as a filter value as it stands. Register a parser only to accept a different format from the
one its TryParse does.
The full contract is the Query-string
contract; what each type accepts is
value formats, and every 400
a filter can produce is Errors.
Composing without executing
Two extension methods build the query the engine would run and hand it back unexecuted — for a ToQueryString()
you can assert on, and for anything computed over the matching set rather than the page:
// The page query: filters, search, ordering (tie-breaker included), Skip/Take. No count, no projection.
var composed = db.Products.ApplyPagination(request, config);
string sql = composed.Query.ToQueryString(); // the engine's query, minus the projection it would add
// composed also carries the effective Page/Limit/SortBy/Search/SearchBy/Filter, for a custom envelope.
// The matching set: filters and search only. Facets, sums, exports.
var facets = await db.Products.ApplyPaginateFilters(request, config).Query
.GroupBy(p => p.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(ct);
Both return the same PaginateComposedQuery<TEntity> and reject exactly what PaginateAsync rejects, at
compose time — except that ApplyPaginateFilters does not validate sortBy, which it never applies, and
reports it as null rather than empty. See
Query composers.
Documentation
- Getting started
- Configuration
- Projections
- Query-string contract
- Response contract
- Configuration API
- Query composers
- Errors
- Cookbook
Trimming & Native AOT
The engine builds LINQ expression trees and uses reflection (DTO projection mapping, MakeGenericMethod),
so it is not compatible with trimming or Native AOT. Every public entry point that reaches that
reflection is annotated with [RequiresUnreferencedCode] / [RequiresDynamicCode] — the four
Paginate*Async methods, the two composers, PaginateFilterOperators.For, and the Filterable /
FilterableMany builder overloads, which construct the engine's filter fields and, in the
operator-less form, derive the operator set from the field's type. Consumers
building trimmed or AOT applications therefore get accurate analyzer warnings rather than silent
runtime failures, and the analyzers run on this repository's own build so the set cannot quietly fall
behind the code.
Debugging
The package ships embedded PDBs with Source Link, so a debugger steps straight into these sources at the exact commit the version was built from. Nothing to configure: no symbol server, no separate symbol download, and it works offline.
License
MIT © Lubos Jansky
| 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.EntityFrameworkCore (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Janzen.Pagination.EntityFrameworkCore:
| Package | Downloads |
|---|---|
|
Janzen.Pagination.AspNetCore
ASP.NET Core integration for Janzen.Pagination — query-string model binding, ProblemDetails error handling, pagination links and OpenAPI metadata. |
|
|
Janzen.Pagination.PostgreSql
PostgreSQL provider for Janzen.Pagination — case-insensitive search via native ILIKE. |
|
|
Janzen.Pagination.NodaTime
NodaTime support for Janzen.Pagination — filtering, sorting and projecting Instant and LocalDate values (including Instant to DateTimeOffset projection). |
GitHub repositories
This package is not used by any popular GitHub repositories.