Wiaoj.Pagination.EntityFrameworkCore
0.2.0-alpha.3
dotnet add package Wiaoj.Pagination.EntityFrameworkCore --version 0.2.0-alpha.3
NuGet\Install-Package Wiaoj.Pagination.EntityFrameworkCore -Version 0.2.0-alpha.3
<PackageReference Include="Wiaoj.Pagination.EntityFrameworkCore" Version="0.2.0-alpha.3" />
<PackageVersion Include="Wiaoj.Pagination.EntityFrameworkCore" Version="0.2.0-alpha.3" />
<PackageReference Include="Wiaoj.Pagination.EntityFrameworkCore" />
paket add Wiaoj.Pagination.EntityFrameworkCore --version 0.2.0-alpha.3
#r "nuget: Wiaoj.Pagination.EntityFrameworkCore, 0.2.0-alpha.3"
#:package Wiaoj.Pagination.EntityFrameworkCore@0.2.0-alpha.3
#addin nuget:?package=Wiaoj.Pagination.EntityFrameworkCore&version=0.2.0-alpha.3&prerelease
#tool nuget:?package=Wiaoj.Pagination.EntityFrameworkCore&version=0.2.0-alpha.3&prerelease
Wiaoj.Pagination.EntityFrameworkCore
Asynchronous Entity Framework Core query extensions for offset-based and keyset (cursor-based) pagination with $N+1$ count elimination, automatic sort direction detection, and binary cursor codecs.
Extension methods are exposed directly under the Microsoft.EntityFrameworkCore namespace for zero-configuration discoverability.
Features
- 64-bit Offset Pagination (
ToPagedResultAsync): UtilizesLongCountAsyncto support tables with billions of rows (BIGINT). - Out-of-Bounds Short-Circuiting: Bypasses data
SELECTqueries entirely when the database is empty or the requested offset exceeds the total record count. - Zero-Cost Count Keyset Pagination (
ToCursorResultAsync): Uses the $N+1$ limit technique to evaluate boundary navigation flags without executingCOUNT(*)queries. - Automatic Sort Direction Detection: Inspects the query's LINQ expression tree to handle
ASCandDESCsorting seamlessly acrossForwardandBackwardnavigation. - Built-in Binary Codecs: Native big-endian binary encoding for
SnowflakeId,long,int,Guid, andDateTimeOffsetcursor keys without string formatting overhead. - Cached Expression Execution: Caches compiled key selector delegates in a thread-safe dictionary to eliminate runtime IL compilation overhead on the hot path.
Installation
dotnet add package Wiaoj.Pagination.EntityFrameworkCore
Usage Examples
1. Offset Pagination
using Microsoft.EntityFrameworkCore;
using Wiaoj.Pagination;
public async Task<PagedResult<ProductDto>> GetProductsAsync(
PageRequest request,
AppDbContext db,
CancellationToken ct)
{
return await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.OrderBy(p => p.Id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToPagedResultAsync(request, ct);
}
You can also pass raw integers without constructing a PageRequest:
var result = await db.Products
.OrderBy(p => p.Id)
.ToPagedResultAsync(pageNumber: 2, pageSize: 20, ct);
2. Keyset / Cursor-Based Pagination (Built-in Types)
Built-in overloads handle binary cursor serialization for SnowflakeId, long, int, Guid, and DateTimeOffset without requiring manual codecs:
The examples below are service methods, where
CursorRequestis the right parameter to take. If the caller is a minimal API endpoint, bindCursorParametersfromWiaoj.Pagination.AspNetCorethere and pass it in — it converts implicitly. BindingCursorRequestfrom a request directly does not work in either form.
Distributed Unique Key (SnowflakeId)
using Microsoft.EntityFrameworkCore;
using Wiaoj.Pagination;
using Wiaoj.Primitives.Snowflake;
public async Task<CursorResult<Message>> GetMessagesAsync(
CursorRequest request,
AppDbContext db,
CancellationToken ct)
{
return await db.Messages
.AsNoTracking()
.OrderBy(m => m.Id)
.ToCursorResultAsync(request, m => m.Id, ct);
}
64-Bit Integer Key (long)
using Microsoft.EntityFrameworkCore;
using Wiaoj.Pagination;
public async Task<CursorResult<Order>> GetOrdersAsync(
CursorRequest request,
AppDbContext db,
CancellationToken ct)
{
return await db.Orders
.AsNoTracking()
.OrderBy(o => o.Id)
.ToCursorResultAsync(request, o => o.Id, ct);
}
Timestamp Key (DateTimeOffset)
public async Task<CursorResult<LogEntry>> GetLogsAsync(
CursorRequest request,
AppDbContext db,
CancellationToken ct)
{
return await db.Logs
.AsNoTracking()
.OrderByDescending(l => l.CreatedAt)
.ToCursorResultAsync(request, l => l.CreatedAt, ct);
}
3. Bidirectional Navigation Matrix
The engine inspects whether the input query uses .OrderBy(...) or .OrderByDescending(...) and automatically adjusts the SQL predicate and directional sort order:
| Base Query Order | Navigation Direction | SQL Seek Predicate | SQL Query Order | In-Memory Alignment |
|---|---|---|---|---|
| ASC | Forward |
key > pivot |
ASC |
Preserved |
| ASC | Backward |
key < pivot |
DESC |
Reversed back to ASC |
| DESC | Forward |
key < pivot |
DESC |
Preserved |
| DESC | Backward |
key > pivot |
ASC |
Reversed back to DESC |
// 1. Fetch forward
var forwardReq = new CursorRequest(currentCursor, limit: 10, CursorDirection.Forward);
var forwardPage = await db.Orders
.OrderByDescending(o => o.Id)
.ToCursorResultAsync(forwardReq, o => o.Id, ct);
// 2. Fetch backward
var backwardReq = new CursorRequest(forwardPage.Metadata.StartCursor, limit: 10, CursorDirection.Backward);
var previousPage = await db.Orders
.OrderByDescending(o => o.Id)
.ToCursorResultAsync(backwardReq, o => o.Id, ct);
The query must be ordered by the cursor key
The seek predicate is built from the key selector. The page window comes from the query's own ORDER BY. If those two refer to different columns, each page is cut from one ordering and continued in the other, so rows are skipped and repeated while every call still returns successfully. The ordering is therefore verified on every call, including the first page, and these cases throw InvalidOperationException:
| Query | Cursor key | Why it is refused |
|---|---|---|
.OrderByDescending(a => a.FileSize) |
a => a.Id |
Different column |
| (no ordering) | a => a.Id |
The database may return rows in any order |
.OrderBy(a => a.Id).ThenBy(a => a.Name) |
a => a.Id |
Name is not part of the seek |
.OrderBy(a => a.Id).Select(a => new AssetRow(a.Id, …)) |
x => x.Key |
A constructor cannot be traced back to a.Id |
This matters most when the ordering comes from a client. With Wiaoj.Querying, ?sort= chooses the ORDER BY while the handler fixes the cursor key. On a keyset endpoint, restrict AllowSort to the cursor key.
These shapes are accepted:
- Filters after the ordering.
.OrderBy(a => a.Id).Where(…)is fine.AsNoTracking()andInclude()also pass through. - A replaced ordering. In
.OrderBy(a => a.Name).OrderBy(a => a.Id), only the lastOrderBycounts. - Projections that can be followed. Anonymous types and member initialisers are traced back to their source column:
.OrderBy(a => a.Id).Select(a => new { Key = a.Id, … })paged onx => x.Keyworks. - Omitted trailing keys. The
Idtie-breaker injected by the built-in overloads is one example. An omitted key is added to theORDER BYin the direction of the level before it, so tied rows come back in the order the seek assumes.
4. Custom Key Codecs
For composite identifiers or custom types implementing IComparable<TKey>, supply custom encoding and decoding delegates:
public async Task<CursorResult<Account>> GetAccountsAsync(
CursorRequest request,
AppDbContext db,
CancellationToken ct)
{
return await db.Accounts
.OrderBy(a => a.AccountNumber)
.ToCursorResultAsync(
request: request,
keySelector: a => a.AccountNumber,
cursorEncoder: accNo => CursorToken.FromUtf8(accNo),
cursorDecoder: token => token.ToUtf8String(),
cancellationToken: ct);
}
Strongly-typed identifiers and other value-converted keys
This is also the overload for an entity whose key is a value object reaching the database through a ValueConverter:
public sealed class DeliveryLog {
public NotificationRequestId RequestId { get; private set; } // -> bigint via SnowflakeIdValueConverter
}
Such a key cannot supply a primitive key selector — there is no member access to the underlying long that EF Core could translate, so l => l.RequestId.Value.Value compiles and then fails at runtime. It does not need one. The seek predicate compares against the whole value object, which the provider already maps to its column, and the codec handles the token entirely in CLR space:
return await db.DeliveryLogs
.OrderBy(l => l.RequestId)
.ToCursorResultAsync(
request: request,
keySelector: l => l.RequestId,
cursorEncoder: id => CursorToken.FromBytes(BitConverter.GetBytes(id.Value)),
cursorDecoder: token => new NotificationRequestId(BitConverter.ToInt64(token.ToBytes())),
cancellationToken: ct);
Valueis the wire form, not the payload.CursorToken.FromUtf8("ACC-4471").Valueis"QUNDLTQ0NzE"— the Base64Url text that travels in the URL. Decoding through it produces the encoded string, or aFormatExceptionif you parse it as a number, and it fails on the second page, after the first one looked fine.ToUtf8String()andToBytes()are the counterparts ofFromUtf8andFromBytes;TryDecoderemains the allocation-free path.
TKey only has to satisfy IComparable<TKey> — nothing else. The seek is expressed as key.CompareTo(pivot) > 0, and EF Core reduces that to the same plain column comparison an operator would produce:
WHERE "d"."RequestId" > @pivot
So relational operators are not required, and neither is an implicit implementation — a key implementing IComparable<T> explicitly pages exactly the same way. CompareTo is never actually invoked for the query; only its shape in the expression tree is read.
5. Paging a projection instead of the whole entity
Paging the entity and mapping to DTOs afterwards reads every column on every page. Project in the query instead. Carry the key beside the response in an anonymous wrapper, then unwrap the page with Select, which keeps the cursors and flags:
var page = await db.Assets
.Where(a => a.ApplicationId == appId)
.OrderByDescending(a => a.Id)
.Select(a => new {
Key = a.Id,
Item = new AssetSummaryResponse(a.Id.Encode(), a.FileName, a.ContentType, a.FileSize)
})
.ToCursorResultAsync(request, x => x.Key, AssetIdCodec.Encode, AssetIdCodec.Decode, ct);
return TypedResults.Ok(page.Select(x => x.Item)); // CursorResult<AssetSummaryResponse>, metadata intact
SELECT "a"."Id", "a"."FileName", "a"."ContentType", "a"."FileSize"
FROM "Assets" AS "a"
WHERE "a"."ApplicationId" = @appId AND "a"."Id" < @pivot
ORDER BY "a"."Id" DESC
Columns the projection does not use are not read. The seek is translated over the wrapper's Key member back to the column.
The wrapper has to be a type EF Core can see through. An anonymous type or a member initialiser (new Row { Key = a.Id, … }) works. A constructor call does not, and that includes a positional record such as new AssetRow(a.Id, …), which is what response DTOs usually are. There is no binding from x.Key back to a.Id to follow. If you order before such a projection, the call refuses it. If you order after it, EF Core fails to translate the seek. Either way it fails, and it never pages on the wrong column.
A non-translatable id encoding is fine in the final projection. EF Core evaluates the top-level Select on the client, so a.Id.Encode() can appear there. It cannot appear in a Where, an OrderBy, or a subquery. On a value-converted id, EF Core guards that call with a null check built from ==. A readonly record struct has that operator. A plain struct without == fails with "The binary operator Equal is not defined".
A non-unique sort key needs the tie-breaker as a key
The built-in overloads inject the Id tie-breaker by finding a property named Id on the element type. An anonymous wrapper has no such property, so nothing is injected. When the sort column can repeat, pass the id as a second key yourself:
var page = await db.Assets
.OrderByDescending(a => a.Priority)
.Select(a => new { a.Priority, a.Id, Item = new AssetSummaryResponse(a.Id.Encode(), a.FileName, a.FileSize) })
.ToCursorResultAsync(request, x => x.Priority, x => x.Id, PriorityIdCodec.Encode, PriorityIdCodec.Decode, ct);
You do not need to write ThenBy(a => a.Id). A trailing key without its own ordering level is added to the ORDER BY in the previous level's direction: ORDER BY "Priority" DESC, "Id" DESC. Do not rely on naming a wrapper member Id to get the tie-breaker back.
A strongly-typed id works in every level of a composite or triple seek, including ids with no relational operators. Types that declare operators, and string, are compared exactly as before. Other types are compared through IComparable<T>.CompareTo.
Architectural Behavior
The N+1 Limit Optimization
When requesting a window of size $N$, the engine queries $N + 1$ records (.Take(N + 1)):
- If $N + 1$ records are returned,
HasNextis set totrue, and the extra item is removed before returning. - If $N$ or fewer records are returned,
HasNextis set tofalse. - Result: Exact boundary detection with zero
COUNT(*)database queries.
Offset Short-Circuiting
In offset pagination, the total count is fetched first via LongCountAsync. If TotalCount == 0 or skip >= TotalCount, the data query is completely skipped, returning an empty PagedResult<T> immediately.
License
This project is licensed under the MIT License.
| 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.11)
- Wiaoj.Pagination (>= 0.2.0-alpha.3)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Wiaoj.Pagination.EntityFrameworkCore:
| Package | Downloads |
|---|---|
|
Wiaoj.Querying.Pagination.EntityFrameworkCore
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0-alpha.3 | 52 | 9/24/2026 |
| 0.2.0-alpha.2 | 46 | 9/24/2026 |
| 0.2.0-alpha.1 | 49 | 9/24/2026 |
| 0.1.0-alpha.9 | 145 | 9/21/2026 |
| 0.1.0-alpha.8 | 55 | 9/21/2026 |
| 0.1.0-alpha.7 | 55 | 9/18/2026 |
| 0.1.0-alpha.6 | 56 | 9/16/2026 |
| 0.1.0-alpha.5 | 69 | 9/16/2026 |
| 0.1.0-alpha.4 | 56 | 9/16/2026 |
| 0.1.0-alpha.3 | 52 | 9/15/2026 |
| 0.1.0-alpha.2 | 92 | 9/15/2026 |
| 0.1.0-alpha.1 | 74 | 9/14/2026 |
| 0.0.1-alpha.112-preview | 57 | 9/13/2026 |
| 0.0.1-alpha.111-preview | 52 | 9/13/2026 |
| 0.0.1-alpha.110-preview | 55 | 9/12/2026 |
| 0.0.1-alpha.109-preview | 72 | 9/11/2026 |
| 0.0.1-alpha.108-preview | 72 | 9/8/2026 |
| 0.0.1-alpha.107-preview | 73 | 9/8/2026 |
| 0.0.1-alpha.106-preview | 63 | 9/8/2026 |
| 0.0.1-alpha.105-preview | 64 | 9/8/2026 |