From 6138eda9b387ac4c8b49a4f9485964173727b02c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:21:55 +0000 Subject: [PATCH 1/6] Initial plan From a3eb233f655a4930dcff7a1ed23484104e5e0653 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:26:04 +0000 Subject: [PATCH 2/6] Add cursor-based pagination infrastructure with HasCursorKey builder method Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- .../Abstractions/IQueryCursorOptions.cs | 17 +++ src/AutoQuery/Abstractions/IQueryProcessor.cs | 9 +- src/AutoQuery/CursorPagedResult.cs | 10 ++ src/AutoQuery/Extensions/QueryExtensions.cs | 101 ++++++++++++++++++ src/AutoQuery/FilterQueryBuilder.cs | 22 ++++ src/AutoQuery/PageToken.cs | 50 +++++++++ src/AutoQuery/QueryProcessor.cs | 12 +++ 7 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/AutoQuery/Abstractions/IQueryCursorOptions.cs create mode 100644 src/AutoQuery/CursorPagedResult.cs create mode 100644 src/AutoQuery/PageToken.cs diff --git a/src/AutoQuery/Abstractions/IQueryCursorOptions.cs b/src/AutoQuery/Abstractions/IQueryCursorOptions.cs new file mode 100644 index 0000000..1a86c83 --- /dev/null +++ b/src/AutoQuery/Abstractions/IQueryCursorOptions.cs @@ -0,0 +1,17 @@ +namespace AutoQuery.Abstractions; + +/// +/// Query parameters for cursor-based pagination. +/// +public interface IQueryCursorOptions : IQueryOptions +{ + /// + /// Page token representing the cursor position for pagination. + /// + string? PageToken { get; set; } + + /// + /// Number of items to return. + /// + int? PageSize { get; set; } +} diff --git a/src/AutoQuery/Abstractions/IQueryProcessor.cs b/src/AutoQuery/Abstractions/IQueryProcessor.cs index b588233..e23e238 100644 --- a/src/AutoQuery/Abstractions/IQueryProcessor.cs +++ b/src/AutoQuery/Abstractions/IQueryProcessor.cs @@ -26,5 +26,12 @@ public interface IQueryProcessor /// The selector expression, or null if no selection conditions exist. Expression>? BuildSelectorExpression(TQueryOptions queryOptions) where TQueryOptions : IQueryOptions; -} + /// + /// Gets the cursor key selector for cursor-based pagination. + /// + /// The type of the query options. + /// The type of the data. + /// The cursor key selector expression, or null if not configured. + LambdaExpression? GetCursorKeySelector(); +} diff --git a/src/AutoQuery/CursorPagedResult.cs b/src/AutoQuery/CursorPagedResult.cs new file mode 100644 index 0000000..4607bc5 --- /dev/null +++ b/src/AutoQuery/CursorPagedResult.cs @@ -0,0 +1,10 @@ +namespace AutoQuery; + +/// +/// Represents a cursor-based paginated result. +/// +/// The type of data contained in the result set. +/// The data collection of the paginated result, represented as . +/// The page token for the next page, or null if there are no more results. +/// The number of items in the current result set. +public record CursorPagedResult(IQueryable Datas, string? NextPageToken, int Count); diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 3991f21..ff85332 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -158,4 +158,105 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged return query; } + + /// + /// Applies query conditions and cursor-based pagination. + /// + /// The type of the entity being queried. + /// The type of the query options. + /// The query object. + /// The query processor. + /// The query options. + /// The cursor-based paginated result. + public static CursorPagedResult ApplyQueryCursorPaged( + this IQueryable query, + IQueryProcessor queryProcessor, + TQueryOptions queryOption) + where TQueryOptions : IQueryCursorOptions + where TData : class + { + var filterExpression = queryProcessor.BuildFilterExpression(queryOption); + var selectorExpression = queryProcessor.BuildSelectorExpression(queryOption); + + if (filterExpression != null) + query = query.Where(filterExpression); + + if (selectorExpression != null) + query = query.Select(selectorExpression); + + query = query.ApplySort(queryOption); + + // Get cursor key selector from the query processor + var cursorKeySelector = queryProcessor.GetCursorKeySelector(); + + if (cursorKeySelector == null) + throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration."); + + // Apply cursor-based filtering if page token is provided + if (!string.IsNullOrWhiteSpace(queryOption.PageToken)) + { + query = ApplyCursorFilter(query, cursorKeySelector, queryOption.PageToken); + } + + // Fetch one extra item to determine if there are more results + var pageSize = queryOption.PageSize ?? 10; + var items = query.Take(pageSize + 1).ToList(); + + // Determine if there are more results and generate next page token + string? nextPageToken = null; + var hasMore = items.Count > pageSize; + + if (hasMore) + { + items = items.Take(pageSize).ToList(); + var lastItem = items.Last(); + var cursorValue = GetCursorValue(lastItem, cursorKeySelector); + nextPageToken = PageToken.Encode(cursorValue); + } + + return new CursorPagedResult(items.AsQueryable(), nextPageToken, items.Count); + } + + /// + /// Applies cursor-based filtering to the query. + /// + private static IQueryable ApplyCursorFilter( + IQueryable query, + LambdaExpression cursorKeySelector, + string pageToken) + { + // Decode the cursor value + var cursorKeySelectorTyped = (Expression>)Expression.Lambda( + Expression.Convert(cursorKeySelector.Body, typeof(object)), + cursorKeySelector.Parameters[0] + ); + + var returnType = ((cursorKeySelector.Body as MemberExpression)?.Type) + ?? ((cursorKeySelector.Body as UnaryExpression)?.Operand as MemberExpression)?.Type + ?? typeof(object); + + var decodeMethod = typeof(PageToken).GetMethod(nameof(PageToken.Decode))!.MakeGenericMethod(returnType); + var cursorValue = decodeMethod.Invoke(null, new object[] { pageToken }); + + if (cursorValue == null) + return query; + + // Build the filter expression: entity => entity.CursorKey > cursorValue + var parameter = Expression.Parameter(typeof(TData), "entity"); + var cursorProperty = Expression.Invoke(cursorKeySelector, parameter); + var constant = Expression.Constant(cursorValue, returnType); + var greaterThan = Expression.GreaterThan(cursorProperty, constant); + var lambda = Expression.Lambda>(greaterThan, parameter); + + return query.Where(lambda); + } + + /// + /// Gets the cursor value from an entity. + /// + private static object GetCursorValue(TData entity, LambdaExpression cursorKeySelector) + { + var compiled = cursorKeySelector.Compile(); + return compiled.DynamicInvoke(entity)!; + } } diff --git a/src/AutoQuery/FilterQueryBuilder.cs b/src/AutoQuery/FilterQueryBuilder.cs index f4a1b27..ee2ce05 100644 --- a/src/AutoQuery/FilterQueryBuilder.cs +++ b/src/AutoQuery/FilterQueryBuilder.cs @@ -16,6 +16,7 @@ public class FilterQueryBuilder private readonly ConcurrentDictionary<(Type BuilderType, Type QueryPropertyType), Func>>> _compiledExpressionsCache = new(); private readonly ConcurrentDictionary> _propertyAccessorsCache = new(); private readonly Dictionary _queryOptionsProperties = typeof(TQueryOptions).GetProperties().ToDictionary(p => p.Name); + private LambdaExpression? _cursorKeySelector; /// /// Registers a property for use in filter queries. @@ -159,4 +160,25 @@ private static Expression> CombineExpressions(Expression>(body, parameter); } + + /// + /// Configures the cursor key selector for cursor-based pagination. + /// + /// The type of the cursor key. + /// The cursor key selector expression. + /// The filter query builder for fluent chaining. + public FilterQueryBuilder HasCursorKey(Expression> cursorKeySelector) + { + _cursorKeySelector = cursorKeySelector ?? throw new ArgumentNullException(nameof(cursorKeySelector)); + return this; + } + + /// + /// Gets the cursor key selector. + /// + /// The cursor key selector expression, or null if not configured. + public LambdaExpression? GetCursorKeySelector() + { + return _cursorKeySelector; + } } diff --git a/src/AutoQuery/PageToken.cs b/src/AutoQuery/PageToken.cs new file mode 100644 index 0000000..048c0f0 --- /dev/null +++ b/src/AutoQuery/PageToken.cs @@ -0,0 +1,50 @@ +using System.Text; + +namespace AutoQuery; + +/// +/// Utility class for encoding and decoding page tokens. +/// +public static class PageToken +{ + /// + /// Encodes a cursor value into an opaque page token. + /// + /// The cursor value to encode. + /// The encoded page token. + public static string Encode(object cursorValue) + { + if (cursorValue == null) + throw new ArgumentNullException(nameof(cursorValue)); + + var valueString = Convert.ToString(cursorValue, System.Globalization.CultureInfo.InvariantCulture); + if (string.IsNullOrEmpty(valueString)) + throw new ArgumentException("Cursor value cannot be empty", nameof(cursorValue)); + + var bytes = Encoding.UTF8.GetBytes(valueString); + return Convert.ToBase64String(bytes); + } + + /// + /// Decodes a page token into the original cursor value. + /// + /// The type of the cursor value. + /// The page token to decode. + /// The decoded cursor value. + public static T Decode(string pageToken) + { + if (string.IsNullOrWhiteSpace(pageToken)) + throw new ArgumentException("Page token cannot be null or empty", nameof(pageToken)); + + try + { + var bytes = Convert.FromBase64String(pageToken); + var valueString = Encoding.UTF8.GetString(bytes); + return (T)Convert.ChangeType(valueString, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + throw new ArgumentException("Invalid page token", nameof(pageToken), ex); + } + } +} diff --git a/src/AutoQuery/QueryProcessor.cs b/src/AutoQuery/QueryProcessor.cs index e983e48..648006e 100644 --- a/src/AutoQuery/QueryProcessor.cs +++ b/src/AutoQuery/QueryProcessor.cs @@ -109,4 +109,16 @@ public void ApplyConfigurationsFromAssembly(Assembly assembly) } } } + + /// + /// Gets the cursor key selector for cursor-based pagination. + /// + /// The type of the query options. + /// The type of the data. + /// The cursor key selector expression, or null if not configured. + public LambdaExpression? GetCursorKeySelector() + { + var filterQueryBuilder = GetFilterQueryBuilder(); + return filterQueryBuilder?.GetCursorKeySelector(); + } } From 417c3ff5c8577cc27b7babeea436a04b8d8eee83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:28:18 +0000 Subject: [PATCH 3/6] Add cursor pagination demo and comprehensive tests Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- .../UserCursorQueryConfiguration.cs | 21 +++ .../Controllers/UsersController.cs | 9 + .../Models/UserCursorQueryOptions.cs | 20 +++ test/AutoQuery.Tests/CursorPaginationTests.cs | 158 ++++++++++++++++++ test/AutoQuery.Tests/PageTokenTests.cs | 84 ++++++++++ 5 files changed, 292 insertions(+) create mode 100644 sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs create mode 100644 sample/AutoQueryApiDemo/Models/UserCursorQueryOptions.cs create mode 100644 test/AutoQuery.Tests/CursorPaginationTests.cs create mode 100644 test/AutoQuery.Tests/PageTokenTests.cs diff --git a/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs b/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs new file mode 100644 index 0000000..c8b6d48 --- /dev/null +++ b/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs @@ -0,0 +1,21 @@ +using AutoQuery; +using AutoQuery.Abstractions; +using AutoQuery.Extensions; +using AutoQueryApiDemo.Models; + +namespace AutoQueryApiDemo.Configurations; + +public class UserCursorQueryConfiguration : IFilterQueryConfiguration +{ + public void Configure(FilterQueryBuilder builder) + { + // Configure cursor key for cursor-based pagination + builder.HasCursorKey(d => d.Id); + + // Configure filter properties + builder.Property(q => q.FilterIds, d => d.Id) + .HasCollectionContains(); + builder.Property(q => q.FilterName, d => d.Name) + .HasEqual(); + } +} diff --git a/sample/AutoQueryApiDemo/Controllers/UsersController.cs b/sample/AutoQueryApiDemo/Controllers/UsersController.cs index 755cf56..ead0c75 100644 --- a/sample/AutoQueryApiDemo/Controllers/UsersController.cs +++ b/sample/AutoQueryApiDemo/Controllers/UsersController.cs @@ -34,4 +34,13 @@ public IActionResult Get(UserQueryOptions queryOptions) .ApplyQueryPagedResult(_queryProcessor, queryOptions); return Ok(result); } + + [HttpGet("cursor")] + [EnableFieldProjection] + public IActionResult GetWithCursor(UserCursorQueryOptions queryOptions) + { + var result = users.AsQueryable() + .ApplyQueryCursorPaged(_queryProcessor, queryOptions); + return Ok(result); + } } diff --git a/sample/AutoQueryApiDemo/Models/UserCursorQueryOptions.cs b/sample/AutoQueryApiDemo/Models/UserCursorQueryOptions.cs new file mode 100644 index 0000000..710aed2 --- /dev/null +++ b/sample/AutoQueryApiDemo/Models/UserCursorQueryOptions.cs @@ -0,0 +1,20 @@ +using AutoQuery.Abstractions; +using Microsoft.AspNetCore.Mvc; + +namespace AutoQueryApiDemo.Models; + +public class UserCursorQueryOptions : IQueryCursorOptions +{ + [FromQuery(Name = "filter[ids]")] + public int[]? FilterIds { get; set; } + [FromQuery(Name = "filter[name]")] + public string? FilterName { get; set; } + [FromQuery(Name = "fields")] + public string? Fields { get; set; } + [FromQuery(Name = "sort")] + public string? Sort { get; set; } + [FromQuery(Name = "pageToken")] + public string? PageToken { get; set; } + [FromQuery(Name = "pageSize")] + public int? PageSize { get; set; } +} diff --git a/test/AutoQuery.Tests/CursorPaginationTests.cs b/test/AutoQuery.Tests/CursorPaginationTests.cs new file mode 100644 index 0000000..f4b9a60 --- /dev/null +++ b/test/AutoQuery.Tests/CursorPaginationTests.cs @@ -0,0 +1,158 @@ +using AutoQuery.Abstractions; +using AutoQuery.Extensions; + +namespace AutoQuery.Tests; + +public class CursorPaginationTests +{ + private readonly QueryProcessor _queryProcessor; + private readonly IQueryable _testData; + + public CursorPaginationTests() + { + _queryProcessor = new QueryProcessor(); + + // Configure cursor-based pagination + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + builder.Property(q => q.Name, d => d.Name).HasEqual(); + _queryProcessor.AddFilterQueryBuilder(builder); + + // Create test data + _testData = new List + { + new TestData { Id = 1, Name = "Item 1" }, + new TestData { Id = 2, Name = "Item 2" }, + new TestData { Id = 3, Name = "Item 3" }, + new TestData { Id = 4, Name = "Item 4" }, + new TestData { Id = 5, Name = "Item 5" }, + }.AsQueryable(); + } + + [Fact] + public void HasCursorKey_ShouldSetCursorKeySelector() + { + // Arrange + var builder = new FilterQueryBuilder(); + + // Act + builder.HasCursorKey(d => d.Id); + var selector = builder.GetCursorKeySelector(); + + // Assert + Assert.NotNull(selector); + } + + [Fact] + public void GetCursorKeySelector_ShouldReturnNull_WhenNotConfigured() + { + // Arrange + var builder = new FilterQueryBuilder(); + + // Act + var selector = builder.GetCursorKeySelector(); + + // Assert + Assert.Null(selector); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldReturnFirstPage_WhenNoTokenProvided() + { + // Arrange + var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + Assert.Equal(1, result.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldReturnNextPage_WhenTokenProvided() + { + // Arrange + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; + var firstPageResult = _testData.ApplyQueryCursorPaged(_queryProcessor, firstPageOptions); + + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + PageToken = firstPageResult.NextPageToken + }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal(3, result.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldReturnNullNextToken_WhenNoMoreResults() + { + // Arrange + var queryOptions = new TestCursorQueryOptions { PageSize = 10 }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + + // Assert + Assert.Equal(5, result.Count); + Assert.Null(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldThrowException_WhenCursorKeyNotConfigured() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + // Not configuring cursor key + queryProcessor.AddFilterQueryBuilder(builder); + + var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; + + // Act & Assert + Assert.Throws(() => + _testData.ApplyQueryCursorPaged(queryProcessor, queryOptions)); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() + { + // Arrange + var queryOptions = new TestCursorQueryOptions + { + PageSize = 2, + Name = "Item 3" + }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + + // Assert + Assert.Single(result.Datas); + Assert.Equal("Item 3", result.Datas.First().Name); + Assert.Null(result.NextPageToken); + } + + public class TestData + { + public int Id { get; set; } + public string Name { get; set; } = null!; + } +} + +public class TestCursorQueryOptions : IQueryCursorOptions +{ + public string? Name { get; set; } + public string? Fields { get; set; } + public string? Sort { get; set; } + public string? PageToken { get; set; } + public int? PageSize { get; set; } +} diff --git a/test/AutoQuery.Tests/PageTokenTests.cs b/test/AutoQuery.Tests/PageTokenTests.cs new file mode 100644 index 0000000..ed20d68 --- /dev/null +++ b/test/AutoQuery.Tests/PageTokenTests.cs @@ -0,0 +1,84 @@ +using AutoQuery.Abstractions; + +namespace AutoQuery.Tests; + +public class PageTokenTests +{ + [Theory] + [InlineData(123)] + [InlineData("test-value")] + [InlineData(456L)] + public void Encode_ShouldEncodeValue(object value) + { + // Act + var token = PageToken.Encode(value); + + // Assert + Assert.NotNull(token); + Assert.NotEmpty(token); + } + + [Fact] + public void Encode_ShouldThrowArgumentNullException_WhenValueIsNull() + { + // Act & Assert + Assert.Throws(() => PageToken.Encode(null!)); + } + + [Theory] + [InlineData(123)] + [InlineData(456L)] + public void Decode_ShouldDecodeEncodedValue(object value) + { + // Arrange + var token = PageToken.Encode(value); + + // Act + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(Convert.ToInt64(value), decoded); + } + + [Fact] + public void Decode_ShouldDecodeStringValue() + { + // Arrange + var value = "test-value"; + var token = PageToken.Encode(value); + + // Act + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(value, decoded); + } + + [Fact] + public void Decode_ShouldThrowArgumentException_WhenTokenIsEmpty() + { + // Act & Assert + Assert.Throws(() => PageToken.Decode("")); + } + + [Fact] + public void Decode_ShouldThrowArgumentException_WhenTokenIsInvalid() + { + // Act & Assert + Assert.Throws(() => PageToken.Decode("invalid-token")); + } + + [Fact] + public void Encode_Decode_ShouldBeReversible() + { + // Arrange + var originalValue = 42; + + // Act + var token = PageToken.Encode(originalValue); + var decodedValue = PageToken.Decode(token); + + // Assert + Assert.Equal(originalValue, decodedValue); + } +} From 26f6f57a0b5f8f9377306487a470316dfc1b064e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:30:12 +0000 Subject: [PATCH 4/6] Add cursor pagination documentation to README Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- README.md | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c5f2c05..7309996 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ - **Dynamic Query Building**: Generate queries dynamically using expression trees. - **Filtering**: Apply flexible filtering logic to refine query results. - **Field Projection**: Return only the specified fields to optimize API responses. -- **Pagination and Sorting**: Built-in support for pagination and sorting. +- **Pagination and Sorting**: Built-in support for both offset-based and cursor-based pagination, plus sorting. +- **Cursor-Based Pagination**: Efficient pagination using opaque page tokens for large datasets. - **ASP.NET Core Integration**: Middleware support for easy integration into ASP.NET Core projects. ## Benchmark @@ -192,6 +193,115 @@ GET /Users?filter[ids]=1&filter[ids]=3&fields=Id,Name&sort=-Id&page=1&pageSize=2 } ``` +## Cursor-Based Pagination + +AutoQuery also supports cursor-based pagination, which is more efficient for large datasets and provides consistent results even when data is being modified. Unlike offset-based pagination, cursor-based pagination uses opaque page tokens to track position. + +### Benefits of Cursor-Based Pagination + +- **Consistent Results**: No duplicate or skipped items when data changes between requests +- **Better Performance**: More efficient for large datasets as it doesn't require counting or skipping rows +- **Scalability**: Works well with real-time data and high-volume scenarios + +### Using Cursor-Based Pagination + +1. Define a query options class implementing `IQueryCursorOptions`: + ```csharp + public class UserCursorQueryOptions : IQueryCursorOptions + { + [FromQuery(Name = "filter[name]")] + public string? FilterName { get; set; } + [FromQuery(Name = "fields")] + public string? Fields { get; set; } + [FromQuery(Name = "sort")] + public string? Sort { get; set; } + [FromQuery(Name = "pageToken")] + public string? PageToken { get; set; } + [FromQuery(Name = "pageSize")] + public int? PageSize { get; set; } + } + ``` + +2. Configure the cursor key in your filter configuration: + ```csharp + public class UserCursorQueryConfiguration : IFilterQueryConfiguration + { + public void Configure(FilterQueryBuilder builder) + { + // Configure cursor key for pagination + builder.HasCursorKey(d => d.Id); + + // Configure filter properties + builder.Property(q => q.FilterName, d => d.Name) + .HasEqual(); + } + } + ``` + +3. Create a controller endpoint using cursor pagination: + ```csharp + [HttpGet("cursor")] + [EnableFieldProjection] + public IActionResult GetWithCursor(UserCursorQueryOptions queryOptions) + { + var result = users.AsQueryable() + .ApplyQueryCursorPaged(_queryProcessor, queryOptions); + return Ok(result); + } + ``` + +4. Example Request (First Page): +```http +GET /Users/cursor?pageSize=2&sort=id +``` + +5. Example Response (First Page): +```json +{ + "datas": [ + { + "id": 1, + "name": "John Doe", + "email": "john.doe@example.com" + }, + { + "id": 2, + "name": "Jane Smith", + "email": "jane.smith@example.com" + } + ], + "nextPageToken": "Mg==", + "count": 2 +} +``` + +6. Example Request (Next Page): +```http +GET /Users/cursor?pageSize=2&sort=id&pageToken=Mg== +``` + +7. Example Response (Next Page): +```json +{ + "datas": [ + { + "id": 3, + "name": "Alice Johnson", + "email": "alice.johnson@example.com" + }, + { + "id": 4, + "name": "Bob Brown", + "email": "bob.brown@example.com" + } + ], + "nextPageToken": "NA==", + "count": 2 +} +``` + +**Note**: When `nextPageToken` is `null`, there are no more results to fetch. + ## Contribution Contributions are welcome! Feel free to submit issues or pull requests to improve the project. From 273c9359aace1f2f69562365a583ceabe66efe91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:35:14 +0000 Subject: [PATCH 5/6] Address code review feedback: improve error handling, add type support, and expand test coverage Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/CursorPagedResult.cs | 4 +- src/AutoQuery/Extensions/QueryExtensions.cs | 21 ++- src/AutoQuery/PageToken.cs | 20 ++- test/AutoQuery.Tests/CursorPaginationTests.cs | 149 ++++++++++++++++++ test/AutoQuery.Tests/PageTokenTests.cs | 42 +++++ 5 files changed, 221 insertions(+), 15 deletions(-) diff --git a/src/AutoQuery/CursorPagedResult.cs b/src/AutoQuery/CursorPagedResult.cs index 4607bc5..c44dcce 100644 --- a/src/AutoQuery/CursorPagedResult.cs +++ b/src/AutoQuery/CursorPagedResult.cs @@ -4,7 +4,7 @@ namespace AutoQuery; /// Represents a cursor-based paginated result. /// /// The type of data contained in the result set. -/// The data collection of the paginated result, represented as . +/// The data collection of the paginated result, represented as . /// The page token for the next page, or null if there are no more results. /// The number of items in the current result set. -public record CursorPagedResult(IQueryable Datas, string? NextPageToken, int Count); +public record CursorPagedResult(IEnumerable Datas, string? NextPageToken, int Count); diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index ff85332..6927313 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -184,14 +184,15 @@ public static CursorPagedResult ApplyQueryCursorPaged(); if (cursorKeySelector == null) throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration."); + // Apply sorting - cursor pagination requires consistent ordering + query = query.ApplySort(queryOption); + // Apply cursor-based filtering if page token is provided if (!string.IsNullOrWhiteSpace(queryOption.PageToken)) { @@ -211,10 +212,12 @@ public static CursorPagedResult ApplyQueryCursorPaged(items.AsQueryable(), nextPageToken, items.Count); + return new CursorPagedResult(items, nextPageToken, items.Count); } /// @@ -225,12 +228,6 @@ private static IQueryable ApplyCursorFilter( LambdaExpression cursorKeySelector, string pageToken) { - // Decode the cursor value - var cursorKeySelectorTyped = (Expression>)Expression.Lambda( - Expression.Convert(cursorKeySelector.Body, typeof(object)), - cursorKeySelector.Parameters[0] - ); - var returnType = ((cursorKeySelector.Body as MemberExpression)?.Type) ?? ((cursorKeySelector.Body as UnaryExpression)?.Operand as MemberExpression)?.Type ?? typeof(object); @@ -239,7 +236,7 @@ private static IQueryable ApplyCursorFilter( var cursorValue = decodeMethod.Invoke(null, new object[] { pageToken }); if (cursorValue == null) - return query; + throw new InvalidOperationException("Decoded cursor value cannot be null."); // Build the filter expression: entity => entity.CursorKey > cursorValue var parameter = Expression.Parameter(typeof(TData), "entity"); @@ -254,9 +251,9 @@ private static IQueryable ApplyCursorFilter( /// /// Gets the cursor value from an entity. /// - private static object GetCursorValue(TData entity, LambdaExpression cursorKeySelector) + private static object? GetCursorValue(TData entity, LambdaExpression cursorKeySelector) { var compiled = cursorKeySelector.Compile(); - return compiled.DynamicInvoke(entity)!; + return compiled.DynamicInvoke(entity); } } diff --git a/src/AutoQuery/PageToken.cs b/src/AutoQuery/PageToken.cs index 048c0f0..1e4eb1a 100644 --- a/src/AutoQuery/PageToken.cs +++ b/src/AutoQuery/PageToken.cs @@ -40,7 +40,25 @@ public static T Decode(string pageToken) { var bytes = Convert.FromBase64String(pageToken); var valueString = Encoding.UTF8.GetString(bytes); - return (T)Convert.ChangeType(valueString, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + + var targetType = typeof(T); + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + // Handle common cursor key types explicitly + if (underlyingType == typeof(Guid)) + { + return (T)(object)Guid.Parse(valueString); + } + if (underlyingType == typeof(DateTime)) + { + return (T)(object)DateTime.Parse(valueString, System.Globalization.CultureInfo.InvariantCulture); + } + if (underlyingType == typeof(DateTimeOffset)) + { + return (T)(object)DateTimeOffset.Parse(valueString, System.Globalization.CultureInfo.InvariantCulture); + } + + return (T)Convert.ChangeType(valueString, underlyingType, System.Globalization.CultureInfo.InvariantCulture); } catch (Exception ex) { diff --git a/test/AutoQuery.Tests/CursorPaginationTests.cs b/test/AutoQuery.Tests/CursorPaginationTests.cs index f4b9a60..9be6782 100644 --- a/test/AutoQuery.Tests/CursorPaginationTests.cs +++ b/test/AutoQuery.Tests/CursorPaginationTests.cs @@ -141,11 +141,144 @@ public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() Assert.Null(result.NextPageToken); } + [Fact] + public void ApplyQueryCursorPaged_ShouldWorkWithCustomSorting() + { + // Arrange + var queryOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "id" + }; + + // Act + var firstPage = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "id", + PageToken = firstPage.NextPageToken + }; + var secondPage = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); + + // Assert + Assert.Equal(2, firstPage.Count); + Assert.Equal(1, firstPage.Datas.First().Id); + Assert.Equal(2, secondPage.Count); + Assert.Equal(3, secondPage.Datas.First().Id); + } + + [Fact] + public void PageToken_ShouldEncodeDecodeGuid() + { + // Arrange + var guid = Guid.NewGuid(); + + // Act + var token = PageToken.Encode(guid); + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(guid, decoded); + } + + [Fact] + public void PageToken_ShouldEncodeDecodeDateTime() + { + // Arrange + var dateTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + // Act + var token = PageToken.Encode(dateTime); + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(dateTime, decoded); + } + + [Fact] + public void PageToken_ShouldEncodeDecodeLong() + { + // Arrange + var longValue = 123456789L; + + // Act + var token = PageToken.Encode(longValue); + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(longValue, decoded); + } + + [Fact] + public void ApplyQueryCursorPaged_WithLongCursorKey() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestDataLong { Id = 1L, Name = "Item 1" }, + new TestDataLong { Id = 2L, Name = "Item 2" }, + new TestDataLong { Id = 3L, Name = "Item 3" }, + }.AsQueryable(); + + var queryOptions = new TestCursorQueryOptionsLong { PageSize = 2 }; + + // Act + var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_WithStringCursorKey() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Code); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestDataString { Code = "A001", Name = "Item 1" }, + new TestDataString { Code = "A002", Name = "Item 2" }, + new TestDataString { Code = "A003", Name = "Item 3" }, + }.AsQueryable(); + + var queryOptions = new TestCursorQueryOptionsString { PageSize = 2, Sort = "code" }; + + // Act + var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + } + public class TestData { public int Id { get; set; } public string Name { get; set; } = null!; } + + public class TestDataLong + { + public long Id { get; set; } + public string Name { get; set; } = null!; + } + + public class TestDataString + { + public string Code { get; set; } = null!; + public string Name { get; set; } = null!; + } } public class TestCursorQueryOptions : IQueryCursorOptions @@ -156,3 +289,19 @@ public class TestCursorQueryOptions : IQueryCursorOptions public string? PageToken { get; set; } public int? PageSize { get; set; } } + +public class TestCursorQueryOptionsLong : IQueryCursorOptions +{ + public string? Fields { get; set; } + public string? Sort { get; set; } + public string? PageToken { get; set; } + public int? PageSize { get; set; } +} + +public class TestCursorQueryOptionsString : IQueryCursorOptions +{ + public string? Fields { get; set; } + public string? Sort { get; set; } + public string? PageToken { get; set; } + public int? PageSize { get; set; } +} diff --git a/test/AutoQuery.Tests/PageTokenTests.cs b/test/AutoQuery.Tests/PageTokenTests.cs index ed20d68..83c33f2 100644 --- a/test/AutoQuery.Tests/PageTokenTests.cs +++ b/test/AutoQuery.Tests/PageTokenTests.cs @@ -81,4 +81,46 @@ public void Encode_Decode_ShouldBeReversible() // Assert Assert.Equal(originalValue, decodedValue); } + + [Fact] + public void Decode_ShouldHandleGuid() + { + // Arrange + var guid = Guid.NewGuid(); + var token = PageToken.Encode(guid); + + // Act + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(guid, decoded); + } + + [Fact] + public void Decode_ShouldHandleDateTime() + { + // Arrange + var dateTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var token = PageToken.Encode(dateTime); + + // Act + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(dateTime, decoded); + } + + [Fact] + public void Decode_ShouldHandleLong() + { + // Arrange + var longValue = 9876543210L; + var token = PageToken.Encode(longValue); + + // Act + var decoded = PageToken.Decode(token); + + // Assert + Assert.Equal(longValue, decoded); + } } From 4e0c18b8015ed846559185f5deb5516b73bbed71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:46:09 +0000 Subject: [PATCH 6/6] Reorganize cursor pagination tests to follow project naming conventions Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- test/AutoQuery.Tests/CursorPaginationTests.cs | 307 ------------------ .../Extensions/QueryExtensionsTests.cs | 234 +++++++++++++ .../FilterQueryBuilderTests.cs | 27 ++ 3 files changed, 261 insertions(+), 307 deletions(-) delete mode 100644 test/AutoQuery.Tests/CursorPaginationTests.cs diff --git a/test/AutoQuery.Tests/CursorPaginationTests.cs b/test/AutoQuery.Tests/CursorPaginationTests.cs deleted file mode 100644 index 9be6782..0000000 --- a/test/AutoQuery.Tests/CursorPaginationTests.cs +++ /dev/null @@ -1,307 +0,0 @@ -using AutoQuery.Abstractions; -using AutoQuery.Extensions; - -namespace AutoQuery.Tests; - -public class CursorPaginationTests -{ - private readonly QueryProcessor _queryProcessor; - private readonly IQueryable _testData; - - public CursorPaginationTests() - { - _queryProcessor = new QueryProcessor(); - - // Configure cursor-based pagination - var builder = new FilterQueryBuilder(); - builder.HasCursorKey(d => d.Id); - builder.Property(q => q.Name, d => d.Name).HasEqual(); - _queryProcessor.AddFilterQueryBuilder(builder); - - // Create test data - _testData = new List - { - new TestData { Id = 1, Name = "Item 1" }, - new TestData { Id = 2, Name = "Item 2" }, - new TestData { Id = 3, Name = "Item 3" }, - new TestData { Id = 4, Name = "Item 4" }, - new TestData { Id = 5, Name = "Item 5" }, - }.AsQueryable(); - } - - [Fact] - public void HasCursorKey_ShouldSetCursorKeySelector() - { - // Arrange - var builder = new FilterQueryBuilder(); - - // Act - builder.HasCursorKey(d => d.Id); - var selector = builder.GetCursorKeySelector(); - - // Assert - Assert.NotNull(selector); - } - - [Fact] - public void GetCursorKeySelector_ShouldReturnNull_WhenNotConfigured() - { - // Arrange - var builder = new FilterQueryBuilder(); - - // Act - var selector = builder.GetCursorKeySelector(); - - // Assert - Assert.Null(selector); - } - - [Fact] - public void ApplyQueryCursorPaged_ShouldReturnFirstPage_WhenNoTokenProvided() - { - // Arrange - var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; - - // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); - - // Assert - Assert.Equal(2, result.Count); - Assert.NotNull(result.NextPageToken); - Assert.Equal(1, result.Datas.First().Id); - } - - [Fact] - public void ApplyQueryCursorPaged_ShouldReturnNextPage_WhenTokenProvided() - { - // Arrange - var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; - var firstPageResult = _testData.ApplyQueryCursorPaged(_queryProcessor, firstPageOptions); - - var secondPageOptions = new TestCursorQueryOptions - { - PageSize = 2, - PageToken = firstPageResult.NextPageToken - }; - - // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); - - // Assert - Assert.Equal(2, result.Count); - Assert.Equal(3, result.Datas.First().Id); - } - - [Fact] - public void ApplyQueryCursorPaged_ShouldReturnNullNextToken_WhenNoMoreResults() - { - // Arrange - var queryOptions = new TestCursorQueryOptions { PageSize = 10 }; - - // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); - - // Assert - Assert.Equal(5, result.Count); - Assert.Null(result.NextPageToken); - } - - [Fact] - public void ApplyQueryCursorPaged_ShouldThrowException_WhenCursorKeyNotConfigured() - { - // Arrange - var queryProcessor = new QueryProcessor(); - var builder = new FilterQueryBuilder(); - // Not configuring cursor key - queryProcessor.AddFilterQueryBuilder(builder); - - var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; - - // Act & Assert - Assert.Throws(() => - _testData.ApplyQueryCursorPaged(queryProcessor, queryOptions)); - } - - [Fact] - public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() - { - // Arrange - var queryOptions = new TestCursorQueryOptions - { - PageSize = 2, - Name = "Item 3" - }; - - // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); - - // Assert - Assert.Single(result.Datas); - Assert.Equal("Item 3", result.Datas.First().Name); - Assert.Null(result.NextPageToken); - } - - [Fact] - public void ApplyQueryCursorPaged_ShouldWorkWithCustomSorting() - { - // Arrange - var queryOptions = new TestCursorQueryOptions - { - PageSize = 2, - Sort = "id" - }; - - // Act - var firstPage = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); - var secondPageOptions = new TestCursorQueryOptions - { - PageSize = 2, - Sort = "id", - PageToken = firstPage.NextPageToken - }; - var secondPage = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); - - // Assert - Assert.Equal(2, firstPage.Count); - Assert.Equal(1, firstPage.Datas.First().Id); - Assert.Equal(2, secondPage.Count); - Assert.Equal(3, secondPage.Datas.First().Id); - } - - [Fact] - public void PageToken_ShouldEncodeDecodeGuid() - { - // Arrange - var guid = Guid.NewGuid(); - - // Act - var token = PageToken.Encode(guid); - var decoded = PageToken.Decode(token); - - // Assert - Assert.Equal(guid, decoded); - } - - [Fact] - public void PageToken_ShouldEncodeDecodeDateTime() - { - // Arrange - var dateTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); - - // Act - var token = PageToken.Encode(dateTime); - var decoded = PageToken.Decode(token); - - // Assert - Assert.Equal(dateTime, decoded); - } - - [Fact] - public void PageToken_ShouldEncodeDecodeLong() - { - // Arrange - var longValue = 123456789L; - - // Act - var token = PageToken.Encode(longValue); - var decoded = PageToken.Decode(token); - - // Assert - Assert.Equal(longValue, decoded); - } - - [Fact] - public void ApplyQueryCursorPaged_WithLongCursorKey() - { - // Arrange - var queryProcessor = new QueryProcessor(); - var builder = new FilterQueryBuilder(); - builder.HasCursorKey(d => d.Id); - queryProcessor.AddFilterQueryBuilder(builder); - - var testData = new List - { - new TestDataLong { Id = 1L, Name = "Item 1" }, - new TestDataLong { Id = 2L, Name = "Item 2" }, - new TestDataLong { Id = 3L, Name = "Item 3" }, - }.AsQueryable(); - - var queryOptions = new TestCursorQueryOptionsLong { PageSize = 2 }; - - // Act - var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); - - // Assert - Assert.Equal(2, result.Count); - Assert.NotNull(result.NextPageToken); - } - - [Fact] - public void ApplyQueryCursorPaged_WithStringCursorKey() - { - // Arrange - var queryProcessor = new QueryProcessor(); - var builder = new FilterQueryBuilder(); - builder.HasCursorKey(d => d.Code); - queryProcessor.AddFilterQueryBuilder(builder); - - var testData = new List - { - new TestDataString { Code = "A001", Name = "Item 1" }, - new TestDataString { Code = "A002", Name = "Item 2" }, - new TestDataString { Code = "A003", Name = "Item 3" }, - }.AsQueryable(); - - var queryOptions = new TestCursorQueryOptionsString { PageSize = 2, Sort = "code" }; - - // Act - var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); - - // Assert - Assert.Equal(2, result.Count); - Assert.NotNull(result.NextPageToken); - } - - public class TestData - { - public int Id { get; set; } - public string Name { get; set; } = null!; - } - - public class TestDataLong - { - public long Id { get; set; } - public string Name { get; set; } = null!; - } - - public class TestDataString - { - public string Code { get; set; } = null!; - public string Name { get; set; } = null!; - } -} - -public class TestCursorQueryOptions : IQueryCursorOptions -{ - public string? Name { get; set; } - public string? Fields { get; set; } - public string? Sort { get; set; } - public string? PageToken { get; set; } - public int? PageSize { get; set; } -} - -public class TestCursorQueryOptionsLong : IQueryCursorOptions -{ - public string? Fields { get; set; } - public string? Sort { get; set; } - public string? PageToken { get; set; } - public int? PageSize { get; set; } -} - -public class TestCursorQueryOptionsString : IQueryCursorOptions -{ - public string? Fields { get; set; } - public string? Sort { get; set; } - public string? PageToken { get; set; } - public int? PageSize { get; set; } -} diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index b5f445a..cb87c9f 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -411,3 +411,237 @@ public IEnumerator GetEnumerator() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } } + +public class QueryExtensionsCursorPaginationTests +{ + private readonly QueryProcessor _queryProcessor; + private readonly IQueryable _testData; + + public QueryExtensionsCursorPaginationTests() + { + _queryProcessor = new QueryProcessor(); + + // Configure cursor-based pagination + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + builder.Property(q => q.Name, d => d.Name).HasEqual(); + _queryProcessor.AddFilterQueryBuilder(builder); + + // Create test data + _testData = new List + { + new TestData { Id = 1, Name = "Item 1" }, + new TestData { Id = 2, Name = "Item 2" }, + new TestData { Id = 3, Name = "Item 3" }, + new TestData { Id = 4, Name = "Item 4" }, + new TestData { Id = 5, Name = "Item 5" }, + }.AsQueryable(); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldReturnFirstPage_WhenNoTokenProvided() + { + // Arrange + var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + Assert.Equal(1, result.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldReturnNextPage_WhenTokenProvided() + { + // Arrange + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; + var firstPageResult = _testData.ApplyQueryCursorPaged(_queryProcessor, firstPageOptions); + + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + PageToken = firstPageResult.NextPageToken + }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal(3, result.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldReturnNullNextToken_WhenNoMoreResults() + { + // Arrange + var queryOptions = new TestCursorQueryOptions { PageSize = 10 }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + + // Assert + Assert.Equal(5, result.Count); + Assert.Null(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldThrowException_WhenCursorKeyNotConfigured() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + // Not configuring cursor key + queryProcessor.AddFilterQueryBuilder(builder); + + var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; + + // Act & Assert + Assert.Throws(() => + _testData.ApplyQueryCursorPaged(queryProcessor, queryOptions)); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() + { + // Arrange + var queryOptions = new TestCursorQueryOptions + { + PageSize = 2, + Name = "Item 3" + }; + + // Act + var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + + // Assert + Assert.Single(result.Datas); + Assert.Equal("Item 3", result.Datas.First().Name); + Assert.Null(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_ShouldWorkWithCustomSorting() + { + // Arrange + var queryOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "id" + }; + + // Act + var firstPage = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "id", + PageToken = firstPage.NextPageToken + }; + var secondPage = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); + + // Assert + Assert.Equal(2, firstPage.Count); + Assert.Equal(1, firstPage.Datas.First().Id); + Assert.Equal(2, secondPage.Count); + Assert.Equal(3, secondPage.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPaged_WithLongCursorKey() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestDataLong { Id = 1L, Name = "Item 1" }, + new TestDataLong { Id = 2L, Name = "Item 2" }, + new TestDataLong { Id = 3L, Name = "Item 3" }, + }.AsQueryable(); + + var queryOptions = new TestCursorQueryOptionsLong { PageSize = 2 }; + + // Act + var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_WithStringCursorKey() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Code); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestDataString { Code = "A001", Name = "Item 1" }, + new TestDataString { Code = "A002", Name = "Item 2" }, + new TestDataString { Code = "A003", Name = "Item 3" }, + }.AsQueryable(); + + var queryOptions = new TestCursorQueryOptionsString { PageSize = 2, Sort = "code" }; + + // Act + var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + } + + public class TestData + { + public int Id { get; set; } + public string Name { get; set; } = null!; + } + + public class TestDataLong + { + public long Id { get; set; } + public string Name { get; set; } = null!; + } + + public class TestDataString + { + public string Code { get; set; } = null!; + public string Name { get; set; } = null!; + } + + public class TestCursorQueryOptions : IQueryCursorOptions + { + public string? Name { get; set; } + public string? Fields { get; set; } + public string? Sort { get; set; } + public string? PageToken { get; set; } + public int? PageSize { get; set; } + } + + public class TestCursorQueryOptionsLong : IQueryCursorOptions + { + public string? Fields { get; set; } + public string? Sort { get; set; } + public string? PageToken { get; set; } + public int? PageSize { get; set; } + } + + public class TestCursorQueryOptionsString : IQueryCursorOptions + { + public string? Fields { get; set; } + public string? Sort { get; set; } + public string? PageToken { get; set; } + public int? PageSize { get; set; } + } +} diff --git a/test/AutoQuery.Tests/FilterQueryBuilderTests.cs b/test/AutoQuery.Tests/FilterQueryBuilderTests.cs index 428f5ba..da1ccb5 100644 --- a/test/AutoQuery.Tests/FilterQueryBuilderTests.cs +++ b/test/AutoQuery.Tests/FilterQueryBuilderTests.cs @@ -77,6 +77,33 @@ public void BuildFilterExpression_WithMultipleExpressions_ShouldCombineCorrectly Assert.Equal(expected, compiledFilter(new TestData { Id = dataId })); } + [Fact] + public void HasCursorKey_ShouldSetCursorKeySelector() + { + // Arrange + var builder = new FilterQueryBuilder(); + + // Act + builder.HasCursorKey(d => d.Id); + var selector = builder.GetCursorKeySelector(); + + // Assert + Assert.NotNull(selector); + } + + [Fact] + public void GetCursorKeySelector_ShouldReturnNull_WhenNotConfigured() + { + // Arrange + var builder = new FilterQueryBuilder(); + + // Act + var selector = builder.GetCursorKeySelector(); + + // Assert + Assert.Null(selector); + } + private class TestData { public int Id { get; set; }