Decode.Queries
3.0.0
dotnet add package Decode.Queries --version 3.0.0
NuGet\Install-Package Decode.Queries -Version 3.0.0
<PackageReference Include="Decode.Queries" Version="3.0.0" />
<PackageVersion Include="Decode.Queries" Version="3.0.0" />
<PackageReference Include="Decode.Queries" />
paket add Decode.Queries --version 3.0.0
#r "nuget: Decode.Queries, 3.0.0"
#:package Decode.Queries@3.0.0
#addin nuget:?package=Decode.Queries&version=3.0.0
#tool nuget:?package=Decode.Queries&version=3.0.0
Decode.Queries
Pagination contracts for .NET query APIs: a normalized page request and an immutable page response with navigation metadata.
No dependencies. Targets net8.0, net9.0 and net10.0, so a Domain or Application layer can
reference it without taking on a web or data-access stack.
📦 Installation
dotnet add package Decode.Queries
🛠️ Usage
1. Accept the query
using Decode.Queries;
[HttpGet]
public async Task<IActionResult> GetUsers([FromQuery] PagedQuery query, CancellationToken ct)
{
PagedQueryResponse<UserResponse> page = await _repository.GetPagedAsync(query, ct);
return Ok(page);
}
2. Use it in the repository
public async Task<PagedQueryResponse<UserResponse>> GetPagedAsync(PagedQuery query, CancellationToken ct)
{
long total = await connection.ExecuteScalarAsync<long>("SELECT COUNT(1) FROM app_user", ...);
// Offset is (Page - 1) * PageSize, the expression every repository otherwise rewrites.
List<UserResponse> rows = (await connection.QueryAsync<UserResponse>(
"SELECT ... FROM app_user ORDER BY created_at DESC LIMIT @Limit OFFSET @Offset",
new { Limit = query.PageSize, query.Offset })).ToList();
return PagedQueryResponse.Create(rows, total, query);
}
PagedQueryResponse.Create reads Page and PageSize from the query as it was actually served
— normalized and capped — so the response reports what the caller received rather than what they
asked for.
Response shape
{
"data": [ { "id": "3fa85f64-...", "name": "Product A" } ],
"total": 250,
"page": 2,
"pageSize": 10,
"totalPages": 25,
"hasPreviousPage": true,
"hasNextPage": true
}
It composes with Decode.AspNetCore's envelope without conflict — the page goes in data:
{ "success": true, "data": { "data": [...], "total": 250, ... }, "errors": null, "errorId": null }
📖 Design notes
Invalid input is clamped, invalid configuration throws
page=0 reads back as 1; a pageSize above the cap reads back as the cap. These arrive from a
query string, where an out-of-range value is far more often a naive client than an attack, and a
400 buys nothing — the effective PageSize is echoed in the response, so a caller that was capped
can see it.
PagedQuery.DefaultPageSize and PagedQuery.DefaultMaxPageSize do the opposite and throw on a
non-positive value. They are written by a developer at startup, not received from a caller, and the
right failure for bad configuration is a loud one.
Normalization happens on read, not on write
A model binder assigns properties in no defined order. Clamping inside the setter makes the outcome
depend on whether PageSize was bound before or after anything it depends on; clamping on read
does not.
The cap cannot be raised by the caller
MaxPageSize has no setter, so a model binder cannot reach it. A public settable cap on a
model-bound type is a cap in name only — the client would send
?maxPageSize=1000000&pageSize=1000000 and lift its own limit.
Raising it is the application's decision, expressed by deriving a type:
public class ExportQuery() : PagedQuery(maxPageSize: 5_000);
The response cannot describe a page it does not hold
Every property is read-only and every derived value is computed in the constructor. A shape with
settable Page, Total and Data lets nothing stop a response from reporting page 5 of 3 while
carrying the rows of page 1, and HasNextPage then lies to the client.
Data is IReadOnlyList<T>, not IEnumerable<T>
A lazy sequence stored in a response model is enumerated by the serializer, which runs after the action returned — by then the database connection and the transaction that produced it are typically gone, and the failure surfaces during response writing rather than where the query was issued. Requiring a materialized list moves that to the call site.
Total, TotalPages and Offset are 64-bit
Total is the result of a COUNT. TotalPages is derived from it, and narrowing it to int would
wrap silently at a small page size over a large table. Offset is (Page - 1) * PageSize, which
overflows int at page numbers a caller can simply type into a query string.
The page count uses integer ceiling division rather than Math.Ceiling((double)total / pageSize),
which loses precision once the count passes 2^53.
⚠️ Known limitation: deep pagination
A large Offset is expensive on every database engine, because the skipped rows are still produced
before being discarded. Nothing here prevents that, and capping the page number would break
legitimate deep paging. If it matters for your dataset, page by key (WHERE id > @lastSeen) rather
than by offset — a different contract, deliberately not modelled by this package.
📄 License
MIT License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.