From 4b2c950c52a664942f74a786ab622f785eee2f6d Mon Sep 17 00:00:00 2001 From: DrSkillIssue Date: Thu, 18 Jun 2026 13:16:30 -0500 Subject: [PATCH 1/2] fix: correct backward cursor boundary emission Use direction-aware cursor availability when encoding cursor pages so backward navigation to the first page no longer emits a previous cursor, and still emits a next cursor when forward navigation remains possible. Add regression coverage for projected and non-projected cursor paths, cursorless backward pages, backward middle pages, descending multi-column definitions, and non-string backward boundaries. --- src/EFPagination/Internal/CursorPair.cs | 9 +- .../Internal/KeysetProjectionExecutor.cs | 7 +- .../CursorExecutorIntegrationTests.cs | 40 ++++++++ .../KeysetFluentCursorIntegrationTests.cs | 94 +++++++++++++++++++ .../ServerSideProjectionIntegrationTests.cs | 2 + 5 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 test/EFPagination.Tests/KeysetFluentCursorIntegrationTests.cs diff --git a/src/EFPagination/Internal/CursorPair.cs b/src/EFPagination/Internal/CursorPair.cs index 4d2465f..df9a22c 100644 --- a/src/EFPagination/Internal/CursorPair.cs +++ b/src/EFPagination/Internal/CursorPair.cs @@ -31,10 +31,11 @@ public static (string? Next, string? Previous) Encode( var options = new PaginationCursorOptions(sortBy, PaginationCount.AsNullable(totalCount)); - var next = hasMore ? PaginationCursor.Encode(definition, items[^1], options) : null; - var previous = (hasInitialReference || direction == PaginationDirection.Backward) - ? PaginationCursor.Encode(definition, items[0], options) - : null; + var hasPrevious = direction == PaginationDirection.Forward ? hasInitialReference : hasMore; + var hasNext = direction == PaginationDirection.Forward ? hasMore : hasInitialReference; + + var next = hasNext ? PaginationCursor.Encode(definition, items[^1], options) : null; + var previous = hasPrevious ? PaginationCursor.Encode(definition, items[0], options) : null; return (next, previous); } } diff --git a/src/EFPagination/Internal/KeysetProjectionExecutor.cs b/src/EFPagination/Internal/KeysetProjectionExecutor.cs index ab112ec..14399f2 100644 --- a/src/EFPagination/Internal/KeysetProjectionExecutor.cs +++ b/src/EFPagination/Internal/KeysetProjectionExecutor.cs @@ -118,13 +118,16 @@ private static (string? Next, string? Previous) EncodeCursorPair( var options = new PaginationCursorOptions(sortBy, PaginationCount.AsNullable(totalCount)); + var hasPrevious = direction == PaginationDirection.Forward ? hasInitialReference : hasMore; + var hasNext = direction == PaginationDirection.Forward ? hasMore : hasInitialReference; + string? next = null; string? previous = null; - if (hasMore) + if (hasNext) next = EncodeFromIndex(definition, page, page.Count - 1, options); - if (hasInitialReference || direction == PaginationDirection.Backward) + if (hasPrevious) previous = EncodeFromIndex(definition, page, 0, options); return (next, previous); diff --git a/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs b/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs index 2947f3a..c9daecf 100644 --- a/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs +++ b/test/EFPagination.Tests/CursorExecutorIntegrationTests.cs @@ -75,6 +75,46 @@ public async Task ExecuteFromCursorAsync_MiddlePage_HasBothCursors() secondPage.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(11, 10), o => o.WithStrictOrdering()); } + [Fact] + public async Task ExecuteFromCursorAsync_BackwardToFirstPage_HasNoPreviousCursor_AndHasNextCursor() + { + var def = PaginationQuery.Build(b => b.Ascending(x => x.Id)); + + var firstPage = await PaginationExecutor.ExecuteFromCursorAsync( + _dbContext.MainModels, def, + new ExecutionOptions(PageSize: 10), + cursor: []); + + var secondPage = await PaginationExecutor.ExecuteFromCursorAsync( + _dbContext.MainModels, def, + new ExecutionOptions(PageSize: 10), + firstPage.NextCursor); + + var back = await PaginationExecutor.ExecuteFromCursorAsync( + _dbContext.MainModels, def, + new ExecutionOptions(PageSize: 10, Direction: PaginationDirection.Backward), + secondPage.PreviousCursor); + + back.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(1, 10), o => o.WithStrictOrdering()); + back.PreviousCursor.Should().BeNull(); + back.NextCursor.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ExecuteFromCursorAsync_BackwardWithoutCursor_ReturnsLastPageWithPreviousCursor_AndNoNextCursor() + { + var def = PaginationQuery.Build(b => b.Ascending(x => x.Id)); + + var page = await PaginationExecutor.ExecuteFromCursorAsync( + _dbContext.MainModels, def, + new ExecutionOptions(PageSize: 10, Direction: PaginationDirection.Backward), + cursor: []); + + page.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(90, 10), o => o.WithStrictOrdering()); + page.PreviousCursor.Should().NotBeNullOrEmpty(); + page.NextCursor.Should().BeNull(); + } + [Fact] public async Task ExecuteFromCursorAsync_LastPage_HasNullNextCursor() { diff --git a/test/EFPagination.Tests/KeysetFluentCursorIntegrationTests.cs b/test/EFPagination.Tests/KeysetFluentCursorIntegrationTests.cs new file mode 100644 index 0000000..e01d878 --- /dev/null +++ b/test/EFPagination.Tests/KeysetFluentCursorIntegrationTests.cs @@ -0,0 +1,94 @@ +#nullable enable +using EFPagination.TestModels; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace EFPagination; + +[Collection(SqliteDatabaseCollection.Name)] +public class KeysetFluentCursorIntegrationTests +{ + private readonly TestDbContext _db; + + public KeysetFluentCursorIntegrationTests(SqliteDatabaseFixture fixture) + { + var provider = fixture.BuildServices(); + _db = provider.GetService()!; + } + + [Fact] + public async Task TakeAsync_BackwardToFirstPage_HasNoPreviousCursor_AndHasNextCursor() + { + var def = PaginationQuery.Build(b => b.Ascending(x => x.Id)); + + var first = await _db.MainModels.Keyset(def).TakeAsync(10); + var second = await _db.MainModels.Keyset(def).After(first.NextCursor!).TakeAsync(10); + + var back = await _db.MainModels.Keyset(def).Before(second.PreviousCursor!).TakeAsync(10); + + back.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(1, 10), o => o.WithStrictOrdering()); + back.PreviousCursor.Should().BeNull(); + back.NextCursor.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TakeAsync_BackwardToMiddlePage_HasBothCursors() + { + var def = PaginationQuery.Build(b => b.Ascending(x => x.Id)); + + var first = await _db.MainModels.Keyset(def).TakeAsync(10); + var second = await _db.MainModels.Keyset(def).After(first.NextCursor!).TakeAsync(10); + var third = await _db.MainModels.Keyset(def).After(second.NextCursor!).TakeAsync(10); + + var back = await _db.MainModels.Keyset(def).Before(third.PreviousCursor!).TakeAsync(10); + + back.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(11, 10), o => o.WithStrictOrdering()); + back.PreviousCursor.Should().NotBeNullOrEmpty(); + back.NextCursor.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TakeAsync_BackwardToFirstPage_WithDescendingMultiColumnDefinition_HasNoPreviousCursor_AndHasNextCursor() + { + var def = PaginationQuery.Build(b => b + .Descending(x => x.Created) + .Descending(x => x.Id)); + + var first = await _db.MainModels.Keyset(def).TakeAsync(10); + var second = await _db.MainModels.Keyset(def).After(first.NextCursor!).TakeAsync(10); + + var back = await _db.MainModels.Keyset(def).Before(second.PreviousCursor!).TakeAsync(10); + + back.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(90, 10).Reverse(), o => o.WithStrictOrdering()); + back.PreviousCursor.Should().BeNull(); + back.NextCursor.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TakeAsync_BeforeBoundValues_HasPreviousCursor_AndHasNextCursor() + { + var def = PaginationQuery.Build(b => b.Ascending(x => x.Id)); + var values = PaginationValues.Create(def, 31); + + var page = await _db.MainModels.Keyset(def).Before(values).TakeAsync(10); + + page.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(21, 10), o => o.WithStrictOrdering()); + page.PreviousCursor.Should().NotBeNullOrEmpty(); + page.NextCursor.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TakeAsync_BeforeEntity_HasPreviousCursor_AndHasNextCursor() + { + var def = PaginationQuery.Build(b => b.Ascending(x => x.Id)); + var reference = await _db.MainModels.SingleAsync(x => x.Id == 31); + + var page = await _db.MainModels.Keyset(def).BeforeEntity(reference).TakeAsync(10); + + page.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(21, 10), o => o.WithStrictOrdering()); + page.PreviousCursor.Should().NotBeNullOrEmpty(); + page.NextCursor.Should().NotBeNullOrEmpty(); + } +} diff --git a/test/EFPagination.Tests/ServerSideProjectionIntegrationTests.cs b/test/EFPagination.Tests/ServerSideProjectionIntegrationTests.cs index 53053cd..cb7cb3d 100644 --- a/test/EFPagination.Tests/ServerSideProjectionIntegrationTests.cs +++ b/test/EFPagination.Tests/ServerSideProjectionIntegrationTests.cs @@ -97,6 +97,8 @@ public async Task TakeAsync_ProjectedBackwardDirection_ReturnsPreviousPage() .TakeAsync(10, x => new ItemDto(x.Id, x.String, x.Created)); back.Items.Select(i => i.EntityId).Should().BeEquivalentTo(Enumerable.Range(1, 10), o => o.WithStrictOrdering()); + back.PreviousCursor.Should().BeNull(); + back.NextCursor.Should().NotBeNullOrEmpty(); } [Fact] From 23e2f10d60ee3c8013f4eb3bf6c571cbf96f0a59 Mon Sep 17 00:00:00 2001 From: DrSkillIssue Date: Thu, 18 Jun 2026 13:17:40 -0500 Subject: [PATCH 2/2] chore: Bump version --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 111c7cd..68e77e8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,7 +22,7 @@ - 3.0.2 + 3.0.3 DrSkillIssue MIT https://github.com/DrSkillIssue/EFPagination