Releases: janzen01/efcore.pagination
Release list
v10.1.0
The stable 10.1.0. Functionally identical to 10.1.0-rc.1 — the public surface has not moved since
it, and everything below applied there too. What changed is the documentation site; see Since the
release candidate at the end.
10.1.0 is a large release. It carries the library's own breaking change (WithTieBreaker is now
required) and the outcome of a repository-wide audit: 23 remediation units across the engine, the
query-string contract, the ASP.NET Core pipeline, the OpenAPI document, packaging and CI.
Most of what changed here cannot be reported by any tool. Package validation sees binary
compatibility only, and the analyzer that guards the public API surface has no slot for an attribute or
for a behaviour. So the breaking changes below — what a request is answered with, what an attribute now
warns about, what a configuration refuses — exist in this document and nowhere else. Read the
Upgrading checklist at the end even if you skip the rest.
Breaking: configurations that stop building
These fail loudly, at host start or the first use of the configuration, and each is one line to fix.
WithTieBreaker(...) is now required. A configuration without one no longer builds:
A pagination configuration requires
WithTieBreaker(...): offset paging over a non-unique order can
return the same row on two pages and skip another. Pass the entity's primary key, e.g.
WithTieBreaker(x => x.Id).
The rule itself is not new — the engine already refused such a request with
400 Pagination requires a deterministic sort order …. What changed is where it is checked. As a
per-request 400 it reported a configuration defect as a client error, and it stayed invisible for as
long as every caller happened to send sortBy, which is exactly the condition under which the paging
was silently non-deterministic anyway. That 400 and its row in the error reference are gone.
It is required outright rather than "a DefaultSortBy or a tie-breaker": default-sort fields are
filtered through When(...) and the tie-breaker is not, so the weaker rule would pass for a config
whose only default is disabled for a caller and still leave nothing to order by.
An operator the field's CLR type cannot support is refused at Build(). Configurations that
previously built and then failed, or quietly matched nothing, now say so at startup. For<T>() also
withholds range operators from a registered type that declares only a partial relational operator set.
A duplicate-resolving filter key throws at configuration. Two declarations resolving to the same
filter key are a defect rather than a last-one-wins.
Six projection shapes throw at build time instead of returning [], rows of defaults, or a 500.
If auto-projection could not map your DTO, it now tells you instead of guessing. A row whose parent
navigation is null yields a null member rather than throwing.
WithGuards(...) takes int? parameters. Source-compatible — WithGuards() and WithGuards(25)
both still compile — but a binary break for anything not recompiled. It fixes a real trap: with
int defaults equal to the engine constants, naming one guard silently reset the other three, which
matters now that a shared defaults object can supply them.
Breaking: trim and AOT consumers
The reflective surface is now annotated: [RequiresUnreferencedCode] / [RequiresDynamicCode] on
WithPagination<T>(), UseNodaTime(), PaginateNodaTime.Register(), PaginateFilterOperators.For*
and the four Filterable / FilterableMany overloads, plus
[DynamicallyAccessedMembers(PublicConstructors)] on WithPagination<TConfigProvider>'s type
parameter.
If you build with trim or AOT analysis on, you will get new IL2026 / IL3050 / IL2091
warnings — which are errors under your own TreatWarningsAsErrors. Nothing was removed and no
signature changed; the library is telling you what was already true. It builds expression trees and
uses reflection, so it is not trim-safe, and silence on an unannotated member was indistinguishable
from a claim that it was.
IsTrimmable / IsAotCompatible are deliberately not set: annotating declares the library is not
trim-safe, and those two would claim the opposite.
Breaking: the response envelope's JSON
PaginatedResponse<T>, PaginatedMeta and PaginatedLinks now carry [JsonPropertyName] on every
member, pinning the wire names to camelCase.
If your host sets PropertyNamingPolicy = null or a custom policy, the envelope's keys change.
There is no exception and no warning — the payload simply arrives with different key casing than it
did. Everything else your host serializes is unaffected; only this library's envelope is pinned.
Breaking: requests that used to be answered now return 400
Each of these is an input the wire contract never actually promised to accept.
| A request sending | used to | now |
|---|---|---|
a value containing U+0000 |
answer 500 on PostgreSQL, a page of no rows elsewhere |
400 |
an enum value that is not exactly one declared member name ($eq:+1, $eq:Active,Pending) |
parse | 400 |
a numeric value in a second grammar (1,5) |
parse on some types | 400 |
a DateTime / DateTimeOffset / TimeSpan outside the pinned ISO forms, including lowercase t / z |
parse | 400 |
?filter.<field>=$eq: (an empty value) on a nullable non-string field |
return the null rows | 400 naming $null |
the same sortBy field twice |
apply it twice | 400 |
a list entry with surrounding whitespace (DateOnly, TimeOnly, char) |
be trimmed, then parse | 400 |
a leading $and: / $or: |
be treated as part of the value | 400 |
a bare $null: |
be accepted | 400 |
$ilike / $sw / $contains on a string field, shorter than MinSearchLength or longer than MaxSearchLength |
run | 400; an empty value is always a 400 |
Lowercase t / z is the one worth a second look: RFC 3339 permits it, and the library no longer
does. The pinned forms are exact by design — the BCL's Parse is lossy in opposite directions for
DateOnly and TimeOnly, so a caller asking about one moment could silently match a whole day.
search is trimmed before it is measured or matched: ?search=%20widget%20 searches for widget,
and both length guards count the trimmed term. A term that only cleared MinSearchLength on its
padding is now a 400.
Breaking: 400 responses that changed shape or wording
- The payload gains a
codemember on both the Minimal API and the controller pipeline, carrying a
stablePaginateQueryErrorvalue. Clients that matched ondetailtext can match on this instead. - The controller
400is alwaysapplication/problem+json. It could previously be
application/jsondepending on how the host was wired. - An app that registers MVC services but never calls
AddProblemDetails()losestraceIdfrom its
Minimal API400. The two pipelines now agree on wheretraceIdcomes from. - The generated
400schema changes:instanceremoved,codeadded,traceIdconditional. - Value-conversion
400wording names the field, not the CLR type, and the echoed value is
truncated past 120 characters and stripped of control characters. Filtering values for 'x' is not supported.now carriescode: ValueTypeNotSupportedrather than
ValueInvalid.- A duplicated
filter.<field>no longer pre-emptsLimitOutOfRange/MaxOffsetExceeded. The
published precedence — paging guards first — holds again. - Three consumer-extension failures moved
500→400. A failure inside a registered converter is
reported as bad input rather than as a server fault.
Breaking: exceptions that changed type or timing
- A cancelled token wins over request validation. A token already cancelled when the call is made
throwsOperationCanceledExceptionrather than a validation error, and a token cancelled during the
row read throws instead of returning the page it had. ArgumentNullExceptionmoves from the faulted task to the call. The fourPaginate*Asyncentry
points are non-asyncwrappers now, so anullsource/request/config/selector/
postMap/projectorthrows at the call site.paramNamecan differ on a doubly-invalid call.
If you build a fan-out of tasks before awaiting them, this is the change that affects you.- A non-EF asynchronous provider is refused, and it surfaces as a
500. It throws
NotSupportedException, notPaginateQueryException, so the ASP.NET Core filters leave it alone.
Nothing a caller sends can reach it; it only fires for a server wired against a queryable-shaped
double. - Provider exceptions during ordering reach the caller unwrapped — no
TargetInvocationException
in the way. nullassigned toPaginateLikeDefaults.Strategythrows at the assignment rather than at the
next query.PaginateLinkContextvalidates. It throws on a null argument, and on a path carrying an
unescaped character, a C0 control character or DEL — those would split the opt-inLinkresponse
header. Awithexpression runs the same guard, so a copy that previously passed silently can now
throw. The offending character is reported by code point and never echoed. It also compares
structurally.- In-memory string range and
sortBycomparisons are invariant, not host-culture, and this applies
toEnumerableQuery<T>only — a synchronous provider backed by a database keeps the plain overload
it can translate.
Breaking: the generated OpenAPI document
If you commit a generated OpenAPI artefact, expect a one-time diff: examples, descriptions,
maxItems, maxLength and a oneOf on limit all change, along with the 400 schema noted above.
This is the document describing what the engine actually accepts rather than an appro...
10.0.3
Filter values are the theme: the parse table grew, the NodaTime package caught up
with its own type list, and a registration that had been quietly doing nothing
now works.
⚠️ One behaviour change
The value registry is now consulted before the built-in parsers. Resolution
order is registry → built-ins → IParsable<TSelf> → 400.
Until now, a parser registered for a type the engine already handled was a
silent no-op. Everyone affected is therefore someone who tried to override a
built-in and never found out they hadn't — and their registration starts taking
effect on upgrade. That is why this ships as a fix rather than a break, but if you
ever called RegisterValueParser for DateTime, int, Guid or another
built-in, check what it does before upgrading.
string and empty values are decided above the registry and still cannot be
overridden.
Fixed
LocalDateTimewas a projection leaf with no parser — sorting worked,
filtering answered400 Filtering values of type 'LocalDateTime' is not supported. An asymmetry that read as a bug, and was one.- Auto-projection recursed into
DateOnly,TimeOnlyandTimeSpanlooking
for a constructor to map, so a DTO that merely carried a date failed to project. - Generated OpenAPI parameter descriptions carried the platform line ending.
If you commit that document, it rewrote itself on every Windows build while
Linux CI flipped it back.
Added
- Filterable:
DateOnly,TimeOnly,TimeSpan,char, and the rest of the
integer family —byte,sbyte,ushort,uint,ulong. - Any type implementing
IParsable<TSelf>is filterable with no registration at
all, so a strongly-typed id of your own works as it stands. There is no
opt-out: parsing only ever happens for a field you declared filterable, so
whitelisting the field is the opt-in. - NodaTime:
LocalDateTime,LocalTime,OffsetDateTime,Durationand
YearMonth, plus their projections ontoDateTime,TimeOnlyand
DateTimeOffset.Instantnow also accepts an offset form
(2026-02-01T00:59:59+01:00), not only…Z.
Deliberately strict
Three inputs are refused rather than silently reinterpreted:
| Input | Why not |
|---|---|
filter.day=$eq:2026-01-03T10:00:00 on a DateOnly |
DateOnly.Parse accepts it and drops the time — a filter for one moment would match the whole day. TimeOnly is the mirror image. |
filter.length=$eq:2 on a TimeSpan |
TimeSpan.TryParse reads a bare 2 as two days. Use 2:00:00 or PT2H. |
filter.length=$eq:P1M |
A month has no fixed length; XmlConvert answers 30 days, which is an approximation rather than an answer. |
TimeSpan and Duration accept both the colon form (2:30:00) and ISO-8601
(PT2H30M) — the latter needs no percent-encoding in a URL.
Notes
- No public API was added, so
PublicAPI.Shippedis unchanged. The one
addition that would have — a round-trip value formatter — is held until the
cursor codec that needs it exists, rather than freezing a shape with no caller. - The NodaTime package shipped with zero tests until now. It has full parser,
conversion and idempotence coverage in this release; the suite is at 335. - Full documentation: value formats
· custom types
· NodaTime
Full changelog: v10.0.2...v10.0.3
10.0.2
Two additions to the response envelope and the query pipeline, plus one fix to how the envelope records
compare. Nothing here is a breaking change: 10.0.2 is a drop-in replacement for 10.0.1.
Added
-
metanow echoes the effective request. Six new keys —sortBy,search,searchBy,filter,
hasPreviousPage,hasNextPage— and the first three report what the query did, not what arrived. A
request that omittedsortBygets the configuredDefaultSortByback; one that omittedsearchBygets
every searchable field. Only the server knows where those defaults landed, which is what a client rendering
a grid header needs to draw its own sort arrows and filter chips. Field names come back canonical
(?sortBy=NAME:descechoesname:DESC), and the tie-breaker is excluded — it orders every page, but nobody
requested it. All six are non-positional init-only members, so the constructor,Deconstructandwithon
PaginatedMetaare unchanged. -
ApplyPaginationandApplyPaginateFilters— two extension methods onIQueryable<TEntity>that
compose the query the engine would run and hand it back unexecuted.ApplyPaginationgives you the page
query, soToQueryString()finally has something to print without a database round-trip;
ApplyPaginateFiltersgives you the matching set, for facet counts, aSumor an export computed over
everything the request matched rather than over one page of it. Both return aPaginateComposedQuery<TEntity>
carrying the same effective statemetareports, for callers assembling their own envelope. Both reject
exactly whatPaginateAsyncrejects, at compose time — except thatApplyPaginateFiltersdoes not validate
sortBy, which it never applies. Because all three paths compose through one code path, the SQL the composer
prints is the SQL the engine runs; the test suite asserts that against a captured command rather than
assuming it.
Fixed
-
The envelope records did not compare by value, despite being records. A record's synthesized
Equals
runs every member throughEqualityComparer<T>.Default, which is reference equality for a list or a
dictionary — so twoPaginatedResponse<T>values describing the identical page compared unequal, and their
hash codes differed per instance. True forItemssince10.0.0, and the newmetamembers would have
widened it.PaginatedResponse<T>andPaginatedMetanow compare and hash structurally:itemselement by
element throughT's own equality (a projection record by value, a projection class by reference), order
significant foritems,sortByandsearchBy, andfilterorder-independent with ordinally matched keys.
An envelope deserialized from a payload carryingnullwhere the contract promises a list reports inequality
rather than throwing.If you were relying on two separately fetched responses comparing unequal, that no longer holds.
Documentation
- New page: Query composers — both
signatures, what each one validates, and the facet and SQL-assertion recipes they unlock. - The response contract documents the echo
and, for the first time, how envelopes compare. - The performance and troubleshooting pages previously said there was no handle to call
ToQueryString()on
mid-flight. There is now, and they say so.
Supply chain
- Published from GitHub Actions with Trusted Publishing (OIDC) — there is no API key to leak, and the
publish job waits on a manual approval before it can push.
10.0.1
Fixes and hardening across the engine and the ASP.NET Core surface, plus one addition to the response
envelope. Nothing here is a breaking change: 10.0.1 is a drop-in replacement for 10.0.0.
Fixed
sortBywent unvalidated whenever nothing matched. Sort resolution ran after theCOUNT, inside the
branch that only executes for a non-empty page — so?page=999&sortBy=bogus:ASC, and any request whose
filters matched no rows, came back200with an empty list instead of400, and skipped the
deterministic-sort check with it. Resolution now happens before the count, which is the order the error
catalogue has always documented.- Range operators on a
string,Guidor enum field returned500. They built an expression .NET has no
operator for, and the failure escaped as a server error — for a request the field's own operator allow-list
had permitted. See Changed. - Pagination links dropped the path base. An app mounted under
UsePathBase("/api")handed clients links
that 404. - A
DateTimefilter value with no offset was read in the server's time zone. It parsed toKind=Local,
so on a server that is not on UTC the comparison against a UTC column shifted by the local offset. The
documented reading — no offset means UTC — is now true off UTC as well. [was not escaped inLIKEpatterns. Harmless on PostgreSQL and SQLite, a silent character range on
SQL Server.- Minimal APIs and controllers answered the same error with different payloads. The controller path went
through the app'sProblemDetailsFactory(type,traceId); the endpoint filter did not. Both do now,
whenever a factory is registered.
Changed
Three behaviours differ for input 10.0.0 handled another way:
$lt$lte$gt$gte$btwnow work onstring,Guidand enum fields. Strings order by the
column's collation,Guids by the database's byte order, enums by their underlying value. A config that
whitelisted these operators on such a field used to fail the request outright.- A range operator on a
boolfield is400, not500. There is no ordering to ask for; use$eq. $null:<value>is400.$nullis documented as valueless, and the value used to be parsed and then
discarded — so?filter.x=$null:falseselected exactly the rows it reads as excluding.$not:$nullis how
to ask for the opposite.
Added
links.current— the request echoed back. Nevernull, and present past the last page too, where
nextandpreviousalready say what is navigable. It is a non-positional init-only member, so the
constructor,DeconstructandwithonPaginatedLinksare unchanged.
OpenAPI
- The generated
400schema documentstraceId, which the runtime has always sent. searchandsearchByare no longer emitted for a resource that declares no searchable field.
Packaging
- Embedded PDBs replace the
.snupkg. Debug information now travels inside the assembly, so stepping into
library sources needs nothing configured on your side: no symbol server, no separate download, and it works
offline. There is no symbol package for10.0.1, and none is needed. - Dependencies: Microsoft.OpenApi
2.12.2.
Supply chain
- Published from GitHub Actions with Trusted Publishing (OIDC) — there is no API key to leak, and the
publish job waits on a manual approval before it can push. - The run records a build provenance attestation for every package it builds: a signed statement, held by
GitHub, tying those exact build outputs to this repository and this workflow. - Deterministic build and Source Link, as before.
Install
dotnet add package Janzen.Pagination.EntityFrameworkCore
dotnet add package Janzen.Pagination.AspNetCore # query-string binding, ProblemDetails, links, OpenAPI
dotnet add package Janzen.Pagination.PostgreSql # native ILIKE
dotnet add package Janzen.Pagination.NodaTime # Instant / LocalDate
Documentation: https://janzen01.github.io/efcore.pagination/
10.0.0
First stable release of the 10.x line. Pairs with .NET 10 and EF Core 10.
The API is the one 10.0.0-rc.1 shipped. It is now recorded in PublicAPI.Shipped.txt, so removing a
public member in a later 10.x release is a build error rather than an accident.
Changed since 10.0.0-rc.1
- OpenAPI: the
filter.<field>example moved fromexampletoexamples. Microsoft.OpenApi 2.12
obsoletedOpenApiSchema.Example, so the generated document now carries"examples": ["$eq:…"]where it
previously had"example": "$eq:…". Anything reading that key out of the generated document has to read
the new one. Nothing else about the document changed. - Dependencies: EF Core and ASP.NET Core
10.0.11, Microsoft.OpenApi2.12.0.
Supply chain
- Published from GitHub Actions with Trusted Publishing (OIDC) — there is no API key to leak, and the
publish job waits on a manual approval before it can push. - The run records a build provenance attestation for every package it builds: a signed statement, held by
GitHub, tying those exact build outputs to this repository and this workflow. - Deterministic build, Source Link, and a
.snupkgsymbol package for each library.
Install
dotnet add package Janzen.Pagination.EntityFrameworkCore
dotnet add package Janzen.Pagination.AspNetCore # query-string binding, ProblemDetails, links, OpenAPI
dotnet add package Janzen.Pagination.PostgreSql # native ILIKE
dotnet add package Janzen.Pagination.NodaTime # Instant / LocalDate
Documentation: https://janzen01.github.io/efcore.pagination/
10.0.0-rc.1
First public version of Janzen.Pagination — dynamic, configuration-driven pagination, filtering and sorting
for Entity Framework Core and ASP.NET Core — as a release candidate.
You declare, once per entity, what clients may sort by, search and filter, and which operators each field
allows. The library turns an opinionated query string into a translated EF Core query, validates everything it
cannot honour into a 400, and returns a page with metadata and navigation links.
GET /products?page=2&limit=25&sortBy=price:DESC&search=widget&filter.status=$in:Active,Draft&filter.price=$btw:10,500Install
dotnet add package Janzen.Pagination.EntityFrameworkCore --prerelease
dotnet add package Janzen.Pagination.AspNetCore --prerelease--prerelease is required while this is an rc: dotnet add package skips prereleases by default.
Documentation ·
Getting started ·
Query-string contract
Packages
| Package | Purpose |
|---|---|
Janzen.Pagination.EntityFrameworkCore |
Provider-agnostic query engine — PaginateConfig<T>, filtering / sorting / search, projection, PaginateAsync |
Janzen.Pagination.AspNetCore |
Query-string model binding, ProblemDetails, navigation links, OpenAPI metadata |
Janzen.Pagination.PostgreSql |
Case-insensitive search via native ILIKE |
Janzen.Pagination.NodaTime |
Filter / sort / project Instant and LocalDate, including Instant → DateTimeOffset |
The engine works on its own against any IQueryable<T>. The three add-ons build on it and are independent of
each other, so take only the ones you need.
What is in it
- An allow-list, not a query language. A field that is not declared is not addressable, and an operator not
granted for a field is rejected for that field. There is no "expose the whole entity" mode. - Eleven filter operators —
$eq$in$null$sw$ilike$contains$lt$lte$gt$gte$btw—
with$notnegation and$and/$orbetween criteria on the same field. - Deterministic paging.
WithTieBreakerappends a unique key as the final ordering key, so rows do not
drift between pages. Without any order at all the engine refuses the query rather than returning silently
unstable pages. - Four projection strategies, one entry point each: a DTO built for you by reflection, a caller-supplied
translatable selector, selector plus an in-memory post-map, and paginate-then-map. - ASP.NET Core, wired. Query-string binding,
ProblemDetailson bad input,first/previous/next/
lastlinks, and OpenAPI parameters generated from the same config the engine enforces. - Guards by default on page size, filter values, filter conditions, sort fields and search length.
- XML documentation ships in every package, so IntelliSense carries the contract.
The query-string contract is borrowed from nestjs-paginate
(MIT) — the same query parameters, operator names and response envelope.
Requirements
net10.0, EF Core 10. The version's first component tracks the .NET / EF Core major it targets, so a 10.x
package pairs with .NET 10 and EF Core 10, and a new .NET major means a new package line rather than a 2.0.
Why a release candidate
The public API is what 10.0.0 intends to ship, and it is complete: 201 tests, no build warnings, and the full
guide is published. The rc is here so that the publishing pipeline gets exercised once before a version that
cannot be withdrawn, and so the API can still be corrected on feedback while doing that is cheap. Please
open an issue if something is missing,
misnamed or wrong — that is exactly what this window is for.
Not covered by the test suite: native PostgreSQL ILIKE and its ESCAPE behaviour, which needs a real
PostgreSQL server. Everything else runs in-process on SQLite and plain IQueryable.