From f6d89a0d7030f9fdb525be8aacf0b0978b46dd4d Mon Sep 17 00:00:00 2001 From: DrSkillIssue Date: Fri, 29 May 2026 11:29:04 -0500 Subject: [PATCH] fix: reuse cursor-carried total count instead of recounting each page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IncludeCount re-ran COUNT(*) on every page even though the total was already embedded in the cursor — the cached value was only consulted when IncludeCount was false. Deep pagination with a count therefore paid a full COUNT scan per page. Now a cursor-carried count is reused as-is; COUNT(*) runs only on a cursor-less (first-of-chain) request and rides the cursor forward. Changing the query is itself a cursor-less request, so the count recomputes against the new filter there. - Centralize the count decision in TotalCountResolver (was duplicated across PaginationExecutor, KeysetQueryExecutor, KeysetProjectionExecutor) - Add PaginationCount (None sentinel + AsNullable), replacing the scattered -1 / > 0 / >= 0 count-absence handling - Document the cursor-query contract (a cursor is bound to its filter) - Bump 3.0.1 -> 3.0.2 Behavior change: with IncludeCount, later pages in a chain report the count computed on the first page rather than a freshly recomputed one. Identical for correct usage (pagination resets on filter change). --- Directory.Build.props | 2 +- docs/patterns.md | 7 +++- .../PaginationExtensions.cs | 4 +- src/EFPagination/Cursor/CursorPage.cs | 2 +- src/EFPagination/Cursor/PaginationCursor.cs | 5 +++ src/EFPagination/Internal/CursorPair.cs | 4 +- .../Internal/KeysetProjectionExecutor.cs | 11 ++---- .../Internal/KeysetQueryExecutor.cs | 7 ++-- .../Internal/TotalCountResolver.cs | 38 +++++++++++++++++++ src/EFPagination/KeysetPage.cs | 4 +- src/EFPagination/KeysetQueryBuilder.cs | 4 +- src/EFPagination/PaginationCount.cs | 19 ++++++++++ src/EFPagination/PaginationExecutor.cs | 17 ++++++--- .../CursorExecutorIntegrationTests.cs | 29 ++++++++++++++ 14 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 src/EFPagination/Internal/TotalCountResolver.cs create mode 100644 src/EFPagination/PaginationCount.cs diff --git a/Directory.Build.props b/Directory.Build.props index bab5986..111c7cd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,7 +22,7 @@ - 3.0.1 + 3.0.2 DrSkillIssue MIT https://github.com/DrSkillIssue/EFPagination diff --git a/docs/patterns.md b/docs/patterns.md index 649fb2f..f47c342 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -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 diff --git a/src/EFPagination.AspNetCore/PaginationExtensions.cs b/src/EFPagination.AspNetCore/PaginationExtensions.cs index 949f682..4d9612f 100644 --- a/src/EFPagination.AspNetCore/PaginationExtensions.cs +++ b/src/EFPagination.AspNetCore/PaginationExtensions.cs @@ -16,7 +16,7 @@ public static class PaginationResponseExtensions /// A response envelope with cursor tokens and optional total count. public static PaginatedResponse ToPaginatedResponse(this CursorPage page) => new(page.Items, page.NextCursor, page.PreviousCursor, - page.TotalCount >= 0 ? page.TotalCount : null); + PaginationCount.AsNullable(page.TotalCount)); /// /// Converts a to a @@ -37,6 +37,6 @@ public static PaginatedResponse ToPaginatedResponse( items[i] = selector(span[i]); return new PaginatedResponse(items, page.NextCursor, page.PreviousCursor, - page.TotalCount >= 0 ? page.TotalCount : null); + PaginationCount.AsNullable(page.TotalCount)); } } diff --git a/src/EFPagination/Cursor/CursorPage.cs b/src/EFPagination/Cursor/CursorPage.cs index dd83181..ef0a547 100644 --- a/src/EFPagination/Cursor/CursorPage.cs +++ b/src/EFPagination/Cursor/CursorPage.cs @@ -9,7 +9,7 @@ namespace EFPagination; /// The materialized items for the current page, in correct order. /// An opaque cursor token for fetching the next page, or when no more pages exist. /// An opaque cursor token for fetching the previous page, or when on the first page. -/// The total row count when requested; otherwise -1. +/// The total row count when requested; otherwise . public readonly record struct CursorPage( List Items, string? NextCursor, diff --git a/src/EFPagination/Cursor/PaginationCursor.cs b/src/EFPagination/Cursor/PaginationCursor.cs index 725cf63..d687e2d 100644 --- a/src/EFPagination/Cursor/PaginationCursor.cs +++ b/src/EFPagination/Cursor/PaginationCursor.cs @@ -12,6 +12,11 @@ namespace EFPagination; /// matching and validates payload compatibility /// via a schema fingerprint. Cursors may optionally carry a 128-bit truncated HMAC-SHA256 /// signature for tamper detection. +/// +/// 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. +/// /// public static class PaginationCursor { diff --git a/src/EFPagination/Internal/CursorPair.cs b/src/EFPagination/Internal/CursorPair.cs index e81aaf3..4d2465f 100644 --- a/src/EFPagination/Internal/CursorPair.cs +++ b/src/EFPagination/Internal/CursorPair.cs @@ -29,9 +29,7 @@ public static (string? Next, string? Previous) Encode( { 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) diff --git a/src/EFPagination/Internal/KeysetProjectionExecutor.cs b/src/EFPagination/Internal/KeysetProjectionExecutor.cs index 3b37a8e..ab112ec 100644 --- a/src/EFPagination/Internal/KeysetProjectionExecutor.cs +++ b/src/EFPagination/Internal/KeysetProjectionExecutor.cs @@ -43,9 +43,9 @@ public static async Task> ExecuteAsync( 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, @@ -116,7 +116,7 @@ private static (string? Next, string? Previous) EncodeCursorPair( { 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; @@ -142,7 +142,4 @@ private static string EncodeFromIndex( page.ExtractKeysIntoBindings(index, bindings); return PaginationCursor.Encode(definition, new PaginationValues(bindings), options); } - - private static Task GetCountAsync(IQueryable source, CancellationToken ct) - => Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.CountAsync(source, ct); } diff --git a/src/EFPagination/Internal/KeysetQueryExecutor.cs b/src/EFPagination/Internal/KeysetQueryExecutor.cs index ea768f4..d49c94f 100644 --- a/src/EFPagination/Internal/KeysetQueryExecutor.cs +++ b/src/EFPagination/Internal/KeysetQueryExecutor.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using Microsoft.EntityFrameworkCore; namespace EFPagination.Internal; @@ -36,9 +35,9 @@ public static async Task> ExecuteAsync( 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); diff --git a/src/EFPagination/Internal/TotalCountResolver.cs b/src/EFPagination/Internal/TotalCountResolver.cs new file mode 100644 index 0000000..0bf3514 --- /dev/null +++ b/src/EFPagination/Internal/TotalCountResolver.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; + +namespace EFPagination.Internal; + +/// +/// Single source of truth for deciding a page's total row count. A count carried by an inbound +/// cursor is reused verbatim; a fresh COUNT(*) 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. +/// +internal static class TotalCountResolver +{ + /// + /// Resolves the total row count for a page. + /// + /// The source element type. + /// Whether the caller requested a total count. + /// The count carried by the inbound cursor, or when absent. + /// The unpaginated source query used to compute a fresh count. + /// A cancellation token. + /// + /// The carried count when present; otherwise a fresh count when requested; otherwise + /// . + /// + public static Task ResolveAsync( + bool includeCount, + int? cachedCount, + IQueryable source, + CancellationToken ct) + { + if (cachedCount is int carried) + return Task.FromResult(carried); + + return includeCount + ? source.CountAsync(ct) + : Task.FromResult(PaginationCount.None); + } +} diff --git a/src/EFPagination/KeysetPage.cs b/src/EFPagination/KeysetPage.cs index d7236f7..b128feb 100644 --- a/src/EFPagination/KeysetPage.cs +++ b/src/EFPagination/KeysetPage.cs @@ -8,9 +8,9 @@ namespace EFPagination; /// The materialized items for the current page. /// when a previous page exists. /// when a next page exists after . -/// The total row count when requested; otherwise -1. +/// The total row count when requested; otherwise . public readonly record struct KeysetPage( List Items, bool HasPrevious, bool HasNext, - int TotalCount = -1); + int TotalCount = PaginationCount.None); diff --git a/src/EFPagination/KeysetQueryBuilder.cs b/src/EFPagination/KeysetQueryBuilder.cs index a410d07..4c94a1c 100644 --- a/src/EFPagination/KeysetQueryBuilder.cs +++ b/src/EFPagination/KeysetQueryBuilder.cs @@ -93,8 +93,8 @@ public KeysetQueryBuilder Before(PaginationValues values) => this with { CursorString = null, Reference = null, BoundValues = values, Direction = PaginationDirection.Backward }; /// - /// Enables total row count computation via a separate SELECT COUNT(*) SQL query. - /// The result is exposed on . + /// Enables the total row count on , computed once on a + /// cursor-less request and carried forward through later cursors. /// /// A new builder with count computation enabled. public KeysetQueryBuilder IncludeCount() => this with { ShouldIncludeCount = true }; diff --git a/src/EFPagination/PaginationCount.cs b/src/EFPagination/PaginationCount.cs new file mode 100644 index 0000000..d85e843 --- /dev/null +++ b/src/EFPagination/PaginationCount.cs @@ -0,0 +1,19 @@ +namespace EFPagination; + +/// +/// A page's total row count when no count was requested, and reading that as . +/// +public static class PaginationCount +{ + /// + /// The total row count a page reports when no count was requested. + /// + public const int None = -1; + + /// + /// Reads a total row count as when no count is present. + /// + /// A total row count, or when no count was requested. + /// The count, or when no count is present. + public static int? AsNullable(int count) => count < 0 ? null : count; +} diff --git a/src/EFPagination/PaginationExecutor.cs b/src/EFPagination/PaginationExecutor.cs index 8ab6f28..a0ebd26 100644 --- a/src/EFPagination/PaginationExecutor.cs +++ b/src/EFPagination/PaginationExecutor.cs @@ -1,5 +1,4 @@ using EFPagination.Internal; -using Microsoft.EntityFrameworkCore; namespace EFPagination; @@ -8,7 +7,10 @@ namespace EFPagination; /// /// The requested number of items per page. /// The pagination direction. Defaults to . -/// When , a total row count is computed via an additional query. Defaults to . +/// +/// When , the page carries the total row count, computed once on a cursor-less +/// request and carried forward through later cursors. Defaults to . +/// /// The upper bound that clamps . Defaults to 500. public readonly record struct ExecutionOptions( int PageSize, @@ -89,6 +91,11 @@ public static Task> ExecuteAsync( /// /// Decodes an opaque cursor, executes the paginated query, and encodes next/previous cursors. /// + /// + /// 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. + /// /// The entity type. /// The base to paginate. /// The prebuilt pagination query definition. @@ -201,9 +208,9 @@ private static async Task> ExecuteCoreAsync( 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); } diff --git a/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs b/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs index d0d6fc8..2947f3a 100644 --- a/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs +++ b/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs @@ -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(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() {