Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</PropertyGroup>

<PropertyGroup Condition="'$(IsPackable)' != 'false'">
<Version>3.0.1</Version>
<Version>3.0.2</Version>
<Authors>DrSkillIssue</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/DrSkillIssue/EFPagination</PackageProjectUrl>
Expand Down
7 changes: 6 additions & 1 deletion docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,12 @@ var page = await dbContext.Users
// page.TotalCount contains the total row count
```

The total count is embedded in cursor tokens when available, so subsequent pages can carry it forward without re-executing the count query.
`COUNT(*)` runs once, on the cursor-less request; the total rides the cursor and is reused on every
later page. A cursor-less request recomputes it.

> **Cursor–query contract.** A cursor belongs to the query that produced it. Reusing one after the
> filter changes still returns a valid, ordered slice, but resumes from the old sort position and
> reports the old count — drop the cursor when the query changes.

## Complete Endpoint Example

Expand Down
4 changes: 2 additions & 2 deletions src/EFPagination.AspNetCore/PaginationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static class PaginationResponseExtensions
/// <returns>A response envelope with cursor tokens and optional total count.</returns>
public static PaginatedResponse<T> ToPaginatedResponse<T>(this CursorPage<T> page)
=> new(page.Items, page.NextCursor, page.PreviousCursor,
page.TotalCount >= 0 ? page.TotalCount : null);
PaginationCount.AsNullable(page.TotalCount));

/// <summary>
/// Converts a <see cref="CursorPage{T}"/> to a <see cref="PaginatedResponse{TOut}"/>
Expand All @@ -37,6 +37,6 @@ public static PaginatedResponse<TOut> ToPaginatedResponse<T, TOut>(
items[i] = selector(span[i]);

return new PaginatedResponse<TOut>(items, page.NextCursor, page.PreviousCursor,
page.TotalCount >= 0 ? page.TotalCount : null);
PaginationCount.AsNullable(page.TotalCount));
}
}
2 changes: 1 addition & 1 deletion src/EFPagination/Cursor/CursorPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace EFPagination;
/// <param name="Items">The materialized items for the current page, in correct order.</param>
/// <param name="NextCursor">An opaque cursor token for fetching the next page, or <see langword="null"/> when no more pages exist.</param>
/// <param name="PreviousCursor">An opaque cursor token for fetching the previous page, or <see langword="null"/> when on the first page.</param>
/// <param name="TotalCount">The total row count when requested; otherwise <c>-1</c>.</param>
/// <param name="TotalCount">The total row count when requested; otherwise <see cref="PaginationCount.None"/>.</param>
public readonly record struct CursorPage<T>(
List<T> Items,
string? NextCursor,
Expand Down
5 changes: 5 additions & 0 deletions src/EFPagination/Cursor/PaginationCursor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ namespace EFPagination;
/// matching <see cref="PaginationQueryDefinition{T}"/> and validates payload compatibility
/// via a schema fingerprint. Cursors may optionally carry a 128-bit truncated HMAC-SHA256
/// signature for tamper detection.
/// <para>
/// A cursor is bound to the filter and ordering that produced it: its boundary values and any
/// carried total count must not be reused across a changed query. Start a fresh, cursor-less
/// request when the query changes.
/// </para>
/// </remarks>
public static class PaginationCursor
{
Expand Down
4 changes: 1 addition & 3 deletions src/EFPagination/Internal/CursorPair.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ public static (string? Next, string? Previous) Encode<T>(
{
if (items.Count == 0) return (null, null);

var options = new PaginationCursorOptions(
sortBy,
totalCount > 0 ? totalCount : null);
var options = new PaginationCursorOptions(sortBy, PaginationCount.AsNullable(totalCount));

var next = hasMore ? PaginationCursor.Encode(definition, items[^1], options) : null;
var previous = (hasInitialReference || direction == PaginationDirection.Backward)
Expand Down
11 changes: 4 additions & 7 deletions src/EFPagination/Internal/KeysetProjectionExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ public static async Task<CursorPage<TOut>> ExecuteAsync<T, TOut>(
resolved.Context.Query, selector, builder.Definition.Columns,
effectivePageSize + 1, builder.Direction, ct).ConfigureAwait(false);

var totalCount = builder.ShouldIncludeCount
? await GetCountAsync(builder.Source, ct).ConfigureAwait(false)
: resolved.TotalCount ?? -1;
var totalCount = await TotalCountResolver
.ResolveAsync(builder.ShouldIncludeCount, resolved.TotalCount, builder.Source, ct)
.ConfigureAwait(false);

var (next, previous) = EncodeCursorPair(
builder.Definition, page, page.HasMore, resolved.HasInitialReference,
Expand Down Expand Up @@ -116,7 +116,7 @@ private static (string? Next, string? Previous) EncodeCursorPair<T, TOut>(
{
if (page.Count == 0) return (null, null);

var options = new PaginationCursorOptions(sortBy, totalCount > 0 ? totalCount : null);
var options = new PaginationCursorOptions(sortBy, PaginationCount.AsNullable(totalCount));

string? next = null;
string? previous = null;
Expand All @@ -142,7 +142,4 @@ private static string EncodeFromIndex<T, TOut>(
page.ExtractKeysIntoBindings(index, bindings);
return PaginationCursor.Encode(definition, new PaginationValues<T>(bindings), options);
}

private static Task<int> GetCountAsync<T>(IQueryable<T> source, CancellationToken ct)
=> Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.CountAsync(source, ct);
}
7 changes: 3 additions & 4 deletions src/EFPagination/Internal/KeysetQueryExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Runtime.CompilerServices;
using Microsoft.EntityFrameworkCore;

namespace EFPagination.Internal;

Expand Down Expand Up @@ -36,9 +35,9 @@ public static async Task<CursorPage<T>> ExecuteAsync<T>(
var (items, hasMore) = await PageMaterializer.MaterializeAsync(
resolved.Context.Query, effectivePageSize, builder.Direction, ct).ConfigureAwait(false);

var totalCount = builder.ShouldIncludeCount
? await builder.Source.CountAsync(ct).ConfigureAwait(false)
: resolved.TotalCount ?? -1;
var totalCount = await TotalCountResolver
.ResolveAsync(builder.ShouldIncludeCount, resolved.TotalCount, builder.Source, ct)
.ConfigureAwait(false);

var (next, previous) = CursorPair.Encode(
builder.Definition, items, hasMore, resolved.HasInitialReference, builder.Direction, sortBy, totalCount);
Expand Down
38 changes: 38 additions & 0 deletions src/EFPagination/Internal/TotalCountResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;

namespace EFPagination.Internal;

/// <summary>
/// Single source of truth for deciding a page's total row count. A count carried by an inbound
/// cursor is reused verbatim; a fresh <c>COUNT(*)</c> is issued only when a count was requested
/// and none was carried — that is, on a cursor-less, first-of-chain request. Subsequent pages in
/// the same cursor chain inherit the original count without re-querying.
/// </summary>
internal static class TotalCountResolver
{
/// <summary>
/// Resolves the total row count for a page.
/// </summary>
/// <typeparam name="T">The source element type.</typeparam>
/// <param name="includeCount">Whether the caller requested a total count.</param>
/// <param name="cachedCount">The count carried by the inbound cursor, or <see langword="null"/> when absent.</param>
/// <param name="source">The unpaginated source query used to compute a fresh count.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>
/// The carried count when present; otherwise a fresh count when requested; otherwise
/// <see cref="PaginationCount.None"/>.
/// </returns>
public static Task<int> ResolveAsync<T>(
bool includeCount,
int? cachedCount,
IQueryable<T> source,
CancellationToken ct)
{
if (cachedCount is int carried)
return Task.FromResult(carried);

return includeCount
? source.CountAsync(ct)
: Task.FromResult(PaginationCount.None);
}
}
4 changes: 2 additions & 2 deletions src/EFPagination/KeysetPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ namespace EFPagination;
/// <param name="Items">The materialized items for the current page.</param>
/// <param name="HasPrevious"><see langword="true"/> when a previous page exists.</param>
/// <param name="HasNext"><see langword="true"/> when a next page exists after <paramref name="Items"/>.</param>
/// <param name="TotalCount">The total row count when requested; otherwise <c>-1</c>.</param>
/// <param name="TotalCount">The total row count when requested; otherwise <see cref="PaginationCount.None"/>.</param>
public readonly record struct KeysetPage<T>(
List<T> Items,
bool HasPrevious,
bool HasNext,
int TotalCount = -1);
int TotalCount = PaginationCount.None);
4 changes: 2 additions & 2 deletions src/EFPagination/KeysetQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ public KeysetQueryBuilder<T> Before(PaginationValues<T> values)
=> this with { CursorString = null, Reference = null, BoundValues = values, Direction = PaginationDirection.Backward };

/// <summary>
/// Enables total row count computation via a separate <c>SELECT COUNT(*)</c> SQL query.
/// The result is exposed on <see cref="CursorPage{T}.TotalCount"/>.
/// Enables the total row count on <see cref="CursorPage{T}.TotalCount"/>, computed once on a
/// cursor-less request and carried forward through later cursors.
/// </summary>
/// <returns>A new builder with count computation enabled.</returns>
public KeysetQueryBuilder<T> IncludeCount() => this with { ShouldIncludeCount = true };
Expand Down
19 changes: 19 additions & 0 deletions src/EFPagination/PaginationCount.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace EFPagination;

/// <summary>
/// A page's total row count when no count was requested, and reading that as <see langword="null"/>.
/// </summary>
public static class PaginationCount
{
/// <summary>
/// The total row count a page reports when no count was requested.
/// </summary>
public const int None = -1;

/// <summary>
/// Reads a total row count as <see langword="null"/> when no count is present.
/// </summary>
/// <param name="count">A total row count, or <see cref="None"/> when no count was requested.</param>
/// <returns>The count, or <see langword="null"/> when no count is present.</returns>
public static int? AsNullable(int count) => count < 0 ? null : count;
}
17 changes: 12 additions & 5 deletions src/EFPagination/PaginationExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using EFPagination.Internal;
using Microsoft.EntityFrameworkCore;

namespace EFPagination;

Expand All @@ -8,7 +7,10 @@ namespace EFPagination;
/// </summary>
/// <param name="PageSize">The requested number of items per page.</param>
/// <param name="Direction">The pagination direction. Defaults to <see cref="PaginationDirection.Forward"/>.</param>
/// <param name="IncludeCount">When <see langword="true"/>, a total row count is computed via an additional query. Defaults to <see langword="false"/>.</param>
/// <param name="IncludeCount">
/// When <see langword="true"/>, the page carries the total row count, computed once on a cursor-less
/// request and carried forward through later cursors. Defaults to <see langword="false"/>.
/// </param>
/// <param name="MaxPageSize">The upper bound that clamps <paramref name="PageSize"/>. Defaults to <c>500</c>.</param>
public readonly record struct ExecutionOptions(
int PageSize,
Expand Down Expand Up @@ -89,6 +91,11 @@ public static Task<KeysetPage<T>> ExecuteAsync<T>(
/// <summary>
/// Decodes an opaque cursor, executes the paginated query, and encodes next/previous cursors.
/// </summary>
/// <remarks>
/// A cursor is bound to the query that produced it; reusing it after the filter changes resumes
/// from the encoded sort position within the new result set and reports the original count. Issue
/// a cursor-less request to restart and recompute when the query changes.
/// </remarks>
/// <typeparam name="T">The entity type.</typeparam>
/// <param name="query">The base <see cref="IQueryable{T}"/> to paginate.</param>
/// <param name="definition">The prebuilt pagination query definition.</param>
Expand Down Expand Up @@ -201,9 +208,9 @@ private static async Task<KeysetPage<T>> ExecuteCoreAsync<T>(
var (items, hasMore) = await PageMaterializer.MaterializeAsync(
context.Query, options.EffectivePageSize, context.Direction, ct).ConfigureAwait(false);

var totalCount = options.IncludeCount
? await query.CountAsync(ct).ConfigureAwait(false)
: previousTotalCount ?? -1;
var totalCount = await TotalCountResolver
.ResolveAsync(options.IncludeCount, previousTotalCount, query, ct)
.ConfigureAwait(false);

return (items, hasMore, totalCount);
}
Expand Down
29 changes: 29 additions & 0 deletions test/EFPagination.Tests/CursorExecutorIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,35 @@ public async Task ExecuteFromCursorAsync_WithIncludeCount_ReturnsTotalCount()
page.TotalCount.Should().Be(99);
}

[Fact]
public async Task ExecuteFromCursorAsync_WithIncludeCount_ReusesCursorCount_WithoutSecondCountQuery()
{
var def = PaginationQuery.Build<MainModel>(b => b.Ascending(x => x.Id));

var firstPage = await PaginationExecutor.ExecuteFromCursorAsync(
_dbContext.MainModels, def,
new ExecutionOptions(PageSize: 10, IncludeCount: true),
cursor: []);

firstPage.TotalCount.Should().Be(99);
var countLogsAfterFirst = CountQueryLogCount();
countLogsAfterFirst.Should().BeGreaterThan(0, "the first (cursor-less) page issues a COUNT(*)");

var secondPage = await PaginationExecutor.ExecuteFromCursorAsync(
_dbContext.MainModels, def,
new ExecutionOptions(PageSize: 10, IncludeCount: true),
cursor: firstPage.NextCursor);

secondPage.TotalCount.Should().Be(99);
CountQueryLogCount().Should().Be(countLogsAfterFirst,
"a cursor-carried count must be reused instead of re-running COUNT(*)");
}

// One COUNT(*) query surfaces across several log events (executing/executed); the absolute
// tally is irrelevant — what matters is that a cursor-carried page adds no further ones.
private int CountQueryLogCount()
=> _dbContext.LogMessages.Count(m => m.Contains("COUNT(*)", StringComparison.OrdinalIgnoreCase));

[Fact]
public async Task ExecuteFromCursorAsync_MultiColumn_RoundTripsCorrectly()
{
Expand Down