The primary consumer API. Chain .Keyset() on any IQueryable<T>:
// With a prebuilt definition:
KeysetQueryBuilder<T> Keyset<T>(
this IQueryable<T> source,
PaginationQueryDefinition<T> definition) where T : class;
// With a sort registry and request (EFPagination.AspNetCore):
KeysetQueryBuilder<T> Keyset<T>(
this IQueryable<T> source,
PaginationSortRegistry<T> registry,
PaginationRequest request) where T : class;A zero-allocation readonly struct that accumulates pagination parameters. All methods return a new builder instance.
| Method | Description |
|---|---|
After(string cursor) |
Sets the forward cursor. Items after this position are returned. |
Before(string cursor) |
Sets the backward cursor. Items before this position are returned. |
AfterEntity(object entity) |
Sets an entity as the forward boundary. Properties must match the definition columns. |
BeforeEntity(object entity) |
Sets an entity as the backward boundary. |
After(PaginationValues<T> values) |
Sets pre-decoded ordered values as the forward boundary. |
Before(PaginationValues<T> values) |
Sets pre-decoded ordered values as the backward boundary. |
IncludeCount() |
Enables total row count computation via a separate SQL query. |
MaxPageSize(int max) |
Sets the maximum page size. Requests exceeding this value are clamped. Defaults to 500. |
TakeAsync(int pageSize, CancellationToken ct) |
Executes the query and returns a CursorPage<T>. |
TakeAsync<TOut>(int pageSize, Expression<Func<T, TOut>> selector, CancellationToken ct) |
Executes with a server-side projection and returns a CursorPage<TOut>. The projection runs inside the SQL statement that applies the keyset ORDER BY. See Server-Side Projection. |
StreamAsync(int pageSize, CancellationToken ct) |
Streams all remaining pages forward as IAsyncEnumerable<List<T>>. |
StreamAsync<TOut>(int pageSize, Expression<Func<T, TOut>> selector, CancellationToken ct) |
Streams projected batches via a server-side projection. |
Returned by TakeAsync. Contains the page items and opaque cursor tokens for stateless navigation.
| Property | Type | Description |
|---|---|---|
Items |
List<T> |
The page items in correct order. |
NextCursor |
string? |
Opaque cursor for the next page, or null when no more pages. |
PreviousCursor |
string? |
Opaque cursor for the previous page, or null on the first page. |
TotalCount |
int |
Total rows when IncludeCount() was called; otherwise -1. |
// First page
var page = await db.Users.Keyset(definition).TakeAsync(20);
// Forward from cursor
var page = await db.Users.Keyset(definition).After(cursor).TakeAsync(20);
// Backward from cursor
var page = await db.Users.Keyset(definition).Before(cursor).TakeAsync(20);
// With total count and max page size
var page = await db.Users.Keyset(definition).After(cursor).IncludeCount().MaxPageSize(100).TakeAsync(pageSize);
// Entity reference
var page = await db.Users.Keyset(definition).AfterEntity(lastUser).TakeAsync(20);
// Stream all pages
await foreach (var batch in db.Users.Keyset(definition).StreamAsync(100)) { }
// Server-side projection — SQL emits only the projected columns plus keyset keys
var page = await db.Users.Keyset(definition).After(cursor).TakeAsync(20,
u => new UserListItem(u.Id, u.Name, u.Email, u.Created));A prebuilt, reusable pagination definition. Created via PaginationQuery.Build<T>(). Stores column metadata and caches expression tree templates for zero per-request overhead.
static readonly PaginationQueryDefinition<User> Definition =
PaginationQuery.Build<User>(b => b.Descending(x => x.Created).Ascending(x => x.Id));See Prebuilt Definitions for details.
Factory for building reusable definitions.
// From a builder action (recommended):
PaginationQueryDefinition<T> Build<T>(Action<PaginationBuilder<T>> builderAction);
// From a property name string (for runtime sort fields):
PaginationQueryDefinition<T> Build<T>(
string propertyName,
bool descending,
string? tiebreaker = "Id",
bool tiebreakerDescending = false);The lambda overload creates a new definition each time -- store the result in a static readonly field.
The string overload caches by (propertyName, descending, tiebreaker, tiebreakerDescending) per entity type, so repeated calls with the same inputs reuse the same instance. The cache is bounded to 256 entries per type; exceeding it throws InvalidOperationException. For user-controlled sort fields, use PaginationSortRegistry<T> instead.
Fluent builder for defining pagination columns. Used inside PaginationQuery.Build<T>().
| Method | Description |
|---|---|
Ascending<TColumn>(Expression<Func<T, TColumn>>) |
Adds a column with ascending sort order. |
Descending<TColumn>(Expression<Func<T, TColumn>>) |
Adds a column with descending sort order. |
ConfigureColumn<TColumn>(Expression<Func<T, TColumn>>, bool isDescending) |
Adds a column with explicit sort direction. |
Columns can reference nested properties:
b.Ascending(x => x.Details.Created)Maps request sort names to prebuilt definitions.
public sealed class PaginationSortRegistry<T>
{
public PaginationSortRegistry(
PaginationQueryDefinition<T> defaultDefinition,
params ReadOnlySpan<SortField<T>> fields);
public PaginationQueryDefinition<T> Resolve(
ReadOnlySpan<char> sortBy,
ReadOnlySpan<char> sortDir);
public bool TryResolve(
ReadOnlySpan<char> sortBy,
ReadOnlySpan<char> sortDir,
out PaginationQueryDefinition<T> definition);
}Behavior:
Resolveuses the default definition whensortByis empty or unknown, selects the descending variant whensortDirequals"desc"(case-insensitive), and uses ascending for all other direction values.TryResolvereturnsfalsewhensortBydoes not match any registered field orsortDiris outside empty,"asc", or"desc"(case-insensitive). EmptysortByreturns the default andtrue.
One registry entry containing both direction variants for a logical sort field:
public readonly record struct SortField<T>(
string Name,
PaginationQueryDefinition<T> Ascending,
PaginationQueryDefinition<T> Descending);Use the SortField.Create<T> factory to build both definitions from a property name:
public static SortField<T> Create<T>(
string name,
string propertyName,
string? tiebreaker = "Id",
bool tiebreakerDescending = false);SortField.Create<User>("created", "Created")
// Equivalent to manually constructing:
// new SortField<User>(
// "created",
// PaginationQuery.Build<User>("Created", descending: false, tiebreaker: "Id"),
// PaginationQuery.Build<User>("Created", descending: true, tiebreaker: "Id"))Encodes and decodes opaque cursor tokens containing typed keyset values and optional metadata. All entry points are schema-bound to a PaginationQueryDefinition<T>: type information is omitted from the payload, and the decoder reconstructs CLR types from the matching definition while validating compatibility via a schema fingerprint.
// From a reference entity (or DTO with matching property names):
string Encode<T>(
PaginationQueryDefinition<T> definition,
T reference,
PaginationCursorOptions options = default) where T : notnull;
// From pre-extracted boundary values:
string Encode<T>(
PaginationQueryDefinition<T> definition,
PaginationValues<T> values,
PaginationCursorOptions options = default);bool TryDecode<T>(
ReadOnlySpan<char> encoded,
PaginationQueryDefinition<T> definition,
out PaginationValues<T> values,
out CursorMetadata metadata,
byte[]? signingKey = null);When a signingKey is passed, TryDecode verifies the HMAC-SHA256 signature appended by Encode and returns false if verification fails. The decoder also validates the schema fingerprint embedded in the cursor, rejecting cursors that were encoded against a different definition shape.
Supported value kinds include:
null,string,bool,char- integral types,
float,double,decimal Guid,DateTime,DateTimeOffset,DateOnly,TimeOnly,TimeSpan- enum values (including
Nullable<TEnum>)
TryDecode returns false for malformed, tampered, or shape-mismatched cursors.
Optional header metadata recovered alongside the values when a cursor is decoded:
public readonly record struct CursorMetadata(
string? SortBy,
int? TotalCount,
uint? Fingerprint,
int ValueCount);| Property | Type | Description |
|---|---|---|
SortBy |
string? |
The logical sort key embedded in the cursor, or null when absent. |
TotalCount |
int? |
The total row count embedded in the cursor, or null when absent. |
Fingerprint |
uint? |
The schema fingerprint embedded in the cursor. |
ValueCount |
int |
The number of boundary values decoded. |
Named pagination boundary value for manual direct-value Paginate(...) calls:
public readonly record struct ColumnValue(string Name, object? Value);Used with the Paginate(definition, direction, ReadOnlySpan<ColumnValue>) overload to seed the keyset boundary by name without an entity instance. Not used in cursor encoding or decoding — cursors carry typed values bound to the definition order, not name/value pairs.
Optional metadata stored alongside cursor values:
public readonly record struct PaginationCursorOptions(
string? SortBy = null,
int? TotalCount = null,
byte[]? SigningKey = null);| Parameter | Description |
|---|---|
SortBy |
Logical sort key to embed in the cursor (round-tripped through decode as CursorMetadata.SortBy). |
TotalCount |
Total row count to carry forward so subsequent pages skip the COUNT query. |
SigningKey |
HMAC-SHA256 key. When set, Encode appends a 128-bit truncated HMAC; TryDecode verifies it. |
Note
The schema fingerprint is derived automatically from the supplied PaginationQueryDefinition<T>. There is no caller-supplied fingerprint option.
Ordered pagination boundary values bound to a specific PaginationQueryDefinition<T>. Allocation-free readonly record struct wrapper over the underlying typed bindings.
public static PaginationValues<T> Create(
PaginationQueryDefinition<T> definition,
params object?[] values);Create a PaginationValues<T> manually when you have the values in definition column order. The definition is required so values can be coerced to each column's CLR type:
var values = PaginationValues<User>.Create(definition, lastCreated, lastId);
var page = await dbContext.Users
.Keyset(definition)
.After(values)
.TakeAsync(20);PaginationCursor.TryDecode<T> also produces a PaginationValues<T> from an encoded cursor.
public enum PaginationDirection
{
Forward,
Backward,
};Forward-- Walk in the configured sort order (toward "next" pages).Backward-- Walk against the configured sort order (toward "previous" pages).
Returned by the low-level MaterializeAsync and PaginationExecutor.ExecuteAsync.
| Property | Type | Description |
|---|---|---|
Items |
List<T> |
The current page items in correct order. |
HasPrevious |
bool |
true when a previous page exists. |
HasNext |
bool |
true when a next page exists. |
TotalCount |
int |
Total rows when requested; otherwise -1. |
Provides IAsyncEnumerable<List<T>>-based streaming pagination that automatically advances through all pages. This is the low-level static method; prefer builder.StreamAsync() for most uses.
public static async IAsyncEnumerable<List<T>> PaginateAllAsync<T>(
IQueryable<T> query,
PaginationQueryDefinition<T> definition,
int pageSize,
CancellationToken ct = default) where T : class;Nested properties are supported in pagination definitions:
PaginationQuery.Build<User>(b => b.Ascending(x => x.Details.Created).Ascending(x => x.Id));When using the low-level Paginate API with nested properties, ensure the reference object has matching nested structure:
var reference = await dbContext.Users
.Include(x => x.Details)
.FirstOrDefaultAsync(x => x.Id == id);
var context = dbContext.Users.Paginate(definition, direction, reference);Or use an anonymous type:
var reference = new { Details = new { Created = someDate }, Id = someId };For consumers who need raw IQueryable composition (e.g., .Include() between pagination and materialization), the Paginate extension methods provide direct access to the filtered query.
PaginationContext<T> Paginate<T>(
this IQueryable<T> source,
PaginationQueryDefinition<T> queryDefinition,
PaginationDirection direction = PaginationDirection.Forward,
object? reference = null)Additional overloads accept PaginationValues<T>, a strongly-typed TReference, ReadOnlySpan<ColumnValue>, or ColumnValue[] as the reference.
Tip
The fluent Keyset() API handles cursor decoding, order correction, and cursor encoding automatically. Use Paginate only when you need to compose additional IQueryable operators (such as .Include()) between pagination and materialization.
Returned by Paginate. Provides access to the filtered query and navigation helpers.
| Property | Type | Description |
|---|---|---|
Query |
IQueryable<T> |
The filtered and ordered query. Chain .Take(), .Include(), .Select(), etc. |
OrderedQuery |
IQueryable<T> |
The ordered query before the cursor filter is applied. Used by HasPreviousAsync / HasNextAsync. |
Direction |
PaginationDirection |
The direction used for this pagination call. |
Materializes a paginated query, computing HasPrevious and HasNext without extra SQL roundtrips by leveraging the pageSize + 1 overflow pattern and direction-aware inference.
Task<KeysetPage<T>> MaterializeAsync<T>(
this PaginationContext<T> context,
int pageSize,
CancellationToken ct = default)var page = await dbContext.Users
.Paginate(definition, PaginationDirection.Forward, reference)
.MaterializeAsync(20);void EnsureCorrectOrder<TData>(this PaginationContext<T> context, IList<TData> data)Reverses the list in-place when the direction is Backward. Call this after manual fetching via context.Query.Take(n).ToListAsync().
IReadOnlyList<T2> ToCorrectOrder<T, T2>(this PaginationContext<T> context, IReadOnlyList<T2> data)Returns a read-only view of items in correct order. Zero allocation for forward direction; reverse-indexed wrapper for backward.
Task<bool> HasPreviousAsync<TData>(this PaginationContext<T> context, IReadOnlyList<TData> data)
Task<bool> HasNextAsync<TData>(this PaginationContext<T> context, IReadOnlyList<TData> data)Checks whether there is more data before/after the current page. Each check executes an AnyAsync query (one roundtrip per call). The data list can be of any type with matching pagination column properties (see Loose Typing).
For zero-roundtrip boundary checks, prefer MaterializeAsync instead.
var context = dbContext.Users.Paginate(definition, direction, reference);
var users = await context.Query
.Include(x => x.Details)
.Take(20)
.ToListAsync();
context.EnsureCorrectOrder(users);
var hasPrevious = await context.HasPreviousAsync(users);
var hasNext = await context.HasNextAsync(users);Static helper that combines Paginate, materialization, and count into a single call. Returns KeysetPage<T> or CursorPage<T>.
This is an advanced API. Prefer the fluent Keyset() builder for most uses.
Task<KeysetPage<T>> ExecuteAsync<T>(
IQueryable<T> query,
PaginationQueryDefinition<T> definition,
ExecutionOptions options,
object? reference = null,
CancellationToken ct = default) where T : class;
Task<KeysetPage<T>> ExecuteAsync<T>(
IQueryable<T> query,
PaginationQueryDefinition<T> definition,
ExecutionOptions options,
PaginationValues<T> referenceValues,
CancellationToken ct = default) where T : class;
Task<KeysetPage<T>> ExecuteAsync<T>(
IQueryable<T> query,
PaginationQueryDefinition<T> definition,
ExecutionOptions options,
ReadOnlySpan<ColumnValue> referenceValues,
CancellationToken ct = default) where T : class;Decodes an opaque cursor, executes the paginated query, and encodes next/previous cursors in one call:
Task<CursorPage<T>> ExecuteFromCursorAsync<T>(
IQueryable<T> query,
PaginationQueryDefinition<T> definition,
ExecutionOptions options,
ReadOnlySpan<char> cursor,
CancellationToken ct = default) where T : class;
Task<CursorPage<T>> ExecuteFromCursorAsync<T>(
IQueryable<T> query,
PaginationQueryDefinition<T> definition,
ExecutionOptions options,
string? cursor,
CancellationToken ct = default) where T : class;Pass an empty span or null cursor for the first page. Throws ArgumentException for invalid or expired cursors.
Controls PaginationExecutor materialization:
public readonly record struct ExecutionOptions(
int PageSize,
PaginationDirection Direction = PaginationDirection.Forward,
bool IncludeCount = false,
int MaxPageSize = 500);Binds cursor-based pagination parameters from the query string:
public readonly record struct PaginationRequest(
string? After = null,
string? Before = null,
int PageSize = 25,
string? SortBy = null,
string? SortDir = null);When Before is provided, the query paginates backward from that cursor. When both After and Before are provided, Before takes precedence.
Applies cursor and direction from a PaginationRequest to an existing builder:
KeysetQueryBuilder<T> FromRequest<T>(
this KeysetQueryBuilder<T> builder,
PaginationRequest request) where T : class;Creates a builder from a sort registry and pagination request, resolving the definition from the request's sort parameters and applying cursors. Throws BadHttpRequestException for an unknown sort field or an unsupported direction:
KeysetQueryBuilder<T> Keyset<T>(
this IQueryable<T> source,
PaginationSortRegistry<T> registry,
PaginationRequest request) where T : class;JSON-serializable paginated response envelope:
public sealed record PaginatedResponse<T>(
IReadOnlyList<T> Items,
string? NextCursor,
string? PreviousCursor,
int? TotalCount);Converts a CursorPage<T> to a PaginatedResponse<T>:
PaginatedResponse<T> ToPaginatedResponse<T>(this CursorPage<T> page);
PaginatedResponse<TOut> ToPaginatedResponse<T, TOut>(
this CursorPage<T> page,
Func<T, TOut> selector);The TotalCount is mapped to null when the source value is negative.
One-call endpoint helpers that bind a PaginationRequest, execute the query, and return a typed PaginatedResponse<TOut>. Two flavors:
// In-memory projection — runs the selector after materialization:
Task<PaginatedResponse<TOut>> PaginateAsync<T, TOut>(
this IQueryable<T> query,
PaginationQueryDefinition<T> definition,
PaginationRequest request,
Func<T, TOut> selector,
int maxPageSize = 100,
bool includeCount = false,
CancellationToken ct = default) where T : class;
// Server-side projection — the selector translates to SQL:
Task<PaginatedResponse<TOut>> PaginateAsync<T, TOut>(
this IQueryable<T> query,
PaginationQueryDefinition<T> definition,
PaginationRequest request,
Expression<Func<T, TOut>> selector,
int maxPageSize = 100,
bool includeCount = false,
CancellationToken ct = default) where T : class;Both have a PaginationSortRegistry<T> overload that resolves the definition from request.SortBy/request.SortDir, throwing BadHttpRequestException for an unknown sort field or an unsupported direction:
Task<PaginatedResponse<TOut>> PaginateAsync<T, TOut>(
this IQueryable<T> query,
PaginationSortRegistry<T> registry,
PaginationRequest request,
Func<T, TOut> selector,
int maxPageSize = 100,
bool includeCount = false,
CancellationToken ct = default) where T : class;
Task<PaginatedResponse<TOut>> PaginateAsync<T, TOut>(
this IQueryable<T> query,
PaginationSortRegistry<T> registry,
PaginationRequest request,
Expression<Func<T, TOut>> selector,
int maxPageSize = 100,
bool includeCount = false,
CancellationToken ct = default) where T : class;The Expression<Func<T, TOut>> overloads run the projection inside the SQL statement that applies the keyset ORDER BY. Subqueries inside the selector stay server-side and the SELECT list materializes only the projected columns plus the keyset key columns. The pagination definition must have 1–8 key columns; outside that range, the overload throws NotSupportedException.
Every PaginateAsync overload throws BadHttpRequestException (status 400) for an invalid or expired cursor, for a request.PageSize of zero or less, and for a request.PageSize above maxPageSize. ASP.NET Core's exception handler middleware maps its status code to the response.
See Server-Side Projection for end-to-end examples.
Thrown when a reference object is missing a property required by the pagination definition. Contains:
| Property | Type | Description |
|---|---|---|
PropertyName |
string |
The missing property name. |
ReferenceType |
Type |
The reference object's type. |
EntityType |
Type |
The entity type defining the pagination. |
See Loose Typing for how to avoid this error.