Skip to content

Releases: janzen01/efcore.pagination

v10.1.0

Choose a tag to compare

@janzen01 janzen01 released this 13 Sep 11:49
Immutable release. Only release title and notes can be modified.
be23924

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 code member on both the Minimal API and the controller pipeline, carrying a
    stable PaginateQueryError value. Clients that matched on detail text can match on this instead.
  • The controller 400 is always application/problem+json. It could previously be
    application/json depending on how the host was wired.
  • An app that registers MVC services but never calls AddProblemDetails() loses traceId from its
    Minimal API 400. The two pipelines now agree on where traceId comes from.
  • The generated 400 schema changes: instance removed, code added, traceId conditional.
  • Value-conversion 400 wording 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 carries code: ValueTypeNotSupported rather than
    ValueInvalid.
  • A duplicated filter.<field> no longer pre-empts LimitOutOfRange / MaxOffsetExceeded. The
    published precedence — paging guards first — holds again.
  • Three consumer-extension failures moved 500400. 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
    throws OperationCanceledException rather than a validation error, and a token cancelled during the
    row read throws instead of returning the page it had.
  • ArgumentNullException moves from the faulted task to the call. The four Paginate*Async entry
    points are non-async wrappers now, so a null source / request / config / selector /
    postMap / projector throws at the call site. paramName can 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, not PaginateQueryException, 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.
  • null assigned to PaginateLikeDefaults.Strategy throws at the assignment rather than at the
    next query.
  • PaginateLinkContext validates. 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-in Link response
    header. A with expression 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 sortBy comparisons are invariant, not host-culture, and this applies
    to EnumerableQuery<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...

Read more

10.0.3

Choose a tag to compare

@janzen01 janzen01 released this 29 Aug 18:04
Immutable release. Only release title and notes can be modified.
5b434fa

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

  • LocalDateTime was a projection leaf with no parser — sorting worked,
    filtering answered 400 Filtering values of type 'LocalDateTime' is not supported. An asymmetry that read as a bug, and was one.
  • Auto-projection recursed into DateOnly, TimeOnly and TimeSpan looking
    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, Duration and
    YearMonth, plus their projections onto DateTime, TimeOnly and
    DateTimeOffset. Instant now 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.Shipped is 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

Choose a tag to compare

@janzen01 janzen01 released this 26 Aug 00:47
Immutable release. Only release title and notes can be modified.
v10.0.2
6232bec

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

  • meta now 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 omitted sortBy gets the configured DefaultSortBy back; one that omitted searchBy gets
    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:desc echoes name: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, Deconstruct and with on
    PaginatedMeta are unchanged.

  • ApplyPagination and ApplyPaginateFilters — two extension methods on IQueryable<TEntity> that
    compose the query the engine would run and hand it back unexecuted. ApplyPagination gives you the page
    query, so ToQueryString() finally has something to print without a database round-trip;
    ApplyPaginateFilters gives you the matching set, for facet counts, a Sum or an export computed over
    everything the request matched rather than over one page of it. Both return a PaginateComposedQuery<TEntity>
    carrying the same effective state meta reports, for callers assembling their own envelope. Both reject
    exactly what PaginateAsync rejects, at compose time — except that ApplyPaginateFilters does 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 through EqualityComparer<T>.Default, which is reference equality for a list or a
    dictionary — so two PaginatedResponse<T> values describing the identical page compared unequal, and their
    hash codes differed per instance. True for Items since 10.0.0, and the new meta members would have
    widened it. PaginatedResponse<T> and PaginatedMeta now compare and hash structurally: items element by
    element through T's own equality (a projection record by value, a projection class by reference), order
    significant for items, sortBy and searchBy, and filter order-independent with ordinally matched keys.
    An envelope deserialized from a payload carrying null where 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

Choose a tag to compare

@janzen01 janzen01 released this 24 Aug 21:23
Immutable release. Only release title and notes can be modified.
9de544f

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

  • sortBy went unvalidated whenever nothing matched. Sort resolution ran after the COUNT, 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 back 200 with an empty list instead of 400, 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, Guid or enum field returned 500. 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 DateTime filter value with no offset was read in the server's time zone. It parsed to Kind=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 in LIKE patterns. 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's ProblemDetailsFactory (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 $btw now work on string, Guid and 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 bool field is 400, not 500. There is no ordering to ask for; use $eq.
  • $null:<value> is 400. $null is documented as valueless, and the value used to be parsed and then
    discarded — so ?filter.x=$null:false selected exactly the rows it reads as excluding. $not:$null is how
    to ask for the opposite.

Added

  • links.current — the request echoed back. Never null, and present past the last page too, where
    next and previous already say what is navigable. It is a non-positional init-only member, so the
    constructor, Deconstruct and with on PaginatedLinks are unchanged.

OpenAPI

  • The generated 400 schema documents traceId, which the runtime has always sent.
  • search and searchBy are 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 for 10.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

Choose a tag to compare

@janzen01 janzen01 released this 15 Aug 19:15
Immutable release. Only release title and notes can be modified.
v10.0.0
02ec029

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 from example to examples. Microsoft.OpenApi 2.12
    obsoleted OpenApiSchema.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.OpenApi 2.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 .snupkg symbol 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

10.0.0-rc.1 Pre-release
Pre-release

Choose a tag to compare

@janzen01 janzen01 released this 01 Aug 20:12
Immutable release. Only release title and notes can be modified.
v10.0.0-rc.1
5c764e1

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,500

Install

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 InstantDateTimeOffset

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 $not negation and $and / $or between criteria on the same field.
  • Deterministic paging. WithTieBreaker appends 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, ProblemDetails on bad input, first / previous / next /
    last links, 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.