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 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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; } From c611f74625f6c01d213b6967636a06e7d23534a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:59:39 +0000 Subject: [PATCH 07/16] Fix cursor pagination to support descending sort order Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/Extensions/QueryExtensions.cs | 72 +++++++++++++++++-- .../Extensions/QueryExtensionsTests.cs | 59 +++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 6927313..9a0879c 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -196,7 +196,9 @@ public static CursorPagedResult ApplyQueryCursorPaged ApplyQueryCursorPaged ApplyCursorFilter( IQueryable query, LambdaExpression cursorKeySelector, - string pageToken) + string pageToken, + bool isDescending) { var returnType = ((cursorKeySelector.Body as MemberExpression)?.Type) ?? ((cursorKeySelector.Body as UnaryExpression)?.Operand as MemberExpression)?.Type @@ -238,12 +241,17 @@ private static IQueryable ApplyCursorFilter( if (cursorValue == null) throw new InvalidOperationException("Decoded cursor value cannot be null."); - // Build the filter expression: entity => entity.CursorKey > cursorValue + // Build the filter expression: entity => entity.CursorKey > cursorValue (ascending) or entity => entity.CursorKey < cursorValue (descending) 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); + + // Use LessThan for descending order, GreaterThan for ascending order + var comparison = isDescending + ? Expression.LessThan(cursorProperty, constant) + : Expression.GreaterThan(cursorProperty, constant); + + var lambda = Expression.Lambda>(comparison, parameter); return query.Where(lambda); } @@ -256,4 +264,58 @@ private static IQueryable ApplyCursorFilter( var compiled = cursorKeySelector.Compile(); return compiled.DynamicInvoke(entity); } + + /// + /// Determines if the cursor key is sorted in descending order. + /// + private static bool IsCursorKeyDescending(string? sortExpression, LambdaExpression cursorKeySelector) + { + if (string.IsNullOrWhiteSpace(sortExpression)) + return false; + + // Get the cursor key property name + var cursorPropertyName = GetPropertyName(cursorKeySelector); + if (string.IsNullOrEmpty(cursorPropertyName)) + return false; + + // Parse sort fields + var sortFields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var sortField in sortFields) + { + if (string.IsNullOrWhiteSpace(sortField)) + continue; + + var isDescending = sortField.StartsWith("-"); + var fieldName = isDescending ? sortField[1..] : sortField; + + // Check if this sort field matches the cursor key (case-insensitive) + if (string.Equals(fieldName, cursorPropertyName, StringComparison.OrdinalIgnoreCase)) + { + return isDescending; + } + } + + // If cursor key is not in the sort expression, default to ascending + return false; + } + + /// + /// Gets the property name from a lambda expression. + /// + private static string? GetPropertyName(LambdaExpression expression) + { + if (expression.Body is MemberExpression memberExpression) + { + return memberExpression.Member.Name; + } + + if (expression.Body is UnaryExpression unaryExpression && + unaryExpression.Operand is MemberExpression operandMember) + { + return operandMember.Member.Name; + } + + return null; + } } diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index cb87c9f..7e1a1b5 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -602,6 +602,65 @@ public void ApplyQueryCursorPaged_WithStringCursorKey() Assert.NotNull(result.NextPageToken); } + [Fact] + public void ApplyQueryCursorPaged_WithDescendingSort() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + queryProcessor.AddFilterQueryBuilder(builder); + + var 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(); + + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "-id" }; + + // Act - First page + var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); + + // Assert - First page should have items 5 and 4 (descending order) + Assert.Equal(2, firstPage.Count); + Assert.Equal(5, firstPage.Datas.First().Id); + Assert.Equal(4, firstPage.Datas.Last().Id); + Assert.NotNull(firstPage.NextPageToken); + + // Act - Second page + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "-id", + PageToken = firstPage.NextPageToken + }; + var secondPage = testData.ApplyQueryCursorPaged(queryProcessor, secondPageOptions); + + // Assert - Second page should have items 3 and 2 (descending order) + Assert.Equal(2, secondPage.Count); + Assert.Equal(3, secondPage.Datas.First().Id); + Assert.Equal(2, secondPage.Datas.Last().Id); + Assert.NotNull(secondPage.NextPageToken); + + // Act - Third page + var thirdPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "-id", + PageToken = secondPage.NextPageToken + }; + var thirdPage = testData.ApplyQueryCursorPaged(queryProcessor, thirdPageOptions); + + // Assert - Third page should have item 1 only + Assert.Equal(1, thirdPage.Count); + Assert.Equal(1, thirdPage.Datas.First().Id); + Assert.Null(thirdPage.NextPageToken); + } + public class TestData { public int Id { get; set; } From 7397df47bd4511f88cd2da1250bbacc3ce83c1a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:11:14 +0000 Subject: [PATCH 08/16] Fix cursor pagination with non-cursor-key sorting and add comprehensive tests Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- README.md | 10 ++ src/AutoQuery/Extensions/QueryExtensions.cs | 46 +++++- .../Extensions/QueryExtensionsTests.cs | 147 ++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7309996..e133a64 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,16 @@ AutoQuery also supports cursor-based pagination, which is more efficient for lar - **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 +### Important: Sort Order with Cursor Pagination + +**Cursor-based pagination always uses the cursor key as the primary sort field** to ensure consistent results. When you specify a different sort field (e.g., `sort=name`), the cursor key is automatically prepended to the sort expression: + +- `sort=name` becomes `sort=id,name` (Id is cursor key, Name is secondary) +- `sort=-name` becomes `sort=id,-name` (Id is primary ascending, Name secondary descending) +- `sort=id` remains `sort=id` (cursor key already specified) + +This ensures that pagination is always deterministic and consistent across requests. + ### Using Cursor-Based Pagination 1. Define a query options class implementing `IQueryCursorOptions`: diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 9a0879c..f8ca704 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -190,14 +190,21 @@ public static CursorPagedResult ApplyQueryCursorPaged + /// Ensures the cursor key is included in the sort expression for consistent pagination. + /// + private static string EnsureCursorKeyInSort(string? sortExpression, string? cursorPropertyName) + { + if (string.IsNullOrEmpty(cursorPropertyName)) + return sortExpression ?? string.Empty; + + // If no sort expression, use cursor key ascending + if (string.IsNullOrWhiteSpace(sortExpression)) + return cursorPropertyName; + + // Check if cursor key is already in the sort expression + var sortFields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var sortField in sortFields) + { + if (string.IsNullOrWhiteSpace(sortField)) + continue; + + var isDescending = sortField.StartsWith("-"); + var fieldName = isDescending ? sortField[1..] : sortField; + + if (string.Equals(fieldName, cursorPropertyName, StringComparison.OrdinalIgnoreCase)) + { + // Cursor key is already in sort, return as-is + return sortExpression; + } + } + + // Cursor key not in sort, prepend it as primary sort (ascending by default) + // This ensures cursor-based filtering works correctly + return $"{cursorPropertyName},{sortExpression}"; + } } diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index 7e1a1b5..ee9b1d0 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -661,6 +661,153 @@ public void ApplyQueryCursorPaged_WithDescendingSort() Assert.Null(thirdPage.NextPageToken); } + [Fact] + public void ApplyQueryCursorPaged_SortByDifferentField_ShouldIncludeCursorKeyInSort() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestData { Id = 3, Name = "Alice" }, + new TestData { Id = 1, Name = "Bob" }, + new TestData { Id = 4, Name = "Charlie" }, + new TestData { Id = 2, Name = "Diana" }, + new TestData { Id = 5, Name = "Eve" }, + }.AsQueryable(); + + // Act - First page sorted by Name (cursor key Id will be PRIMARY sort, Name secondary) + // Effective sort becomes: id,name + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "name" }; + var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); + + // Assert - First page should have id=1 (Bob) and id=2 (Diana) - sorted by Id first + Assert.Equal(2, firstPage.Count); + Assert.Equal(1, firstPage.Datas.First().Id); + Assert.Equal(2, firstPage.Datas.Last().Id); + Assert.NotNull(firstPage.NextPageToken); + + // Act - Second page + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "name", + PageToken = firstPage.NextPageToken + }; + var secondPage = testData.ApplyQueryCursorPaged(queryProcessor, secondPageOptions); + + // Assert - Second page should have id=3 and id=4 + Assert.Equal(2, secondPage.Count); + Assert.Equal(3, secondPage.Datas.First().Id); + Assert.Equal(4, secondPage.Datas.Last().Id); + Assert.NotNull(secondPage.NextPageToken); + + // Act - Third page + var thirdPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "name", + PageToken = secondPage.NextPageToken + }; + var thirdPage = testData.ApplyQueryCursorPaged(queryProcessor, thirdPageOptions); + + // Assert - Third page should have id=5 only + Assert.Equal(1, thirdPage.Count); + Assert.Equal(5, thirdPage.Datas.First().Id); + Assert.Null(thirdPage.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_SortByDifferentFieldDescending_ShouldIncludeCursorKeyInSort() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestData { Id = 3, Name = "Alice" }, + new TestData { Id = 1, Name = "Bob" }, + new TestData { Id = 4, Name = "Charlie" }, + new TestData { Id = 2, Name = "Diana" }, + new TestData { Id = 5, Name = "Eve" }, + }.AsQueryable(); + + // Act - First page sorted by -Name (cursor key Id will be PRIMARY sort, -Name secondary) + // Effective sort becomes: id,-name + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "-name" }; + var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); + + // Assert - First page should have id=1 and id=2 - sorted by Id first + Assert.Equal(2, firstPage.Count); + Assert.Equal(1, firstPage.Datas.First().Id); + Assert.Equal(2, firstPage.Datas.Last().Id); + Assert.NotNull(firstPage.NextPageToken); + + // Act - Second page + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "-name", + PageToken = firstPage.NextPageToken + }; + var secondPage = testData.ApplyQueryCursorPaged(queryProcessor, secondPageOptions); + + // Assert - Second page should have id=3 and id=4 + Assert.Equal(2, secondPage.Count); + Assert.Equal(3, secondPage.Datas.First().Id); + Assert.Equal(4, secondPage.Datas.Last().Id); + Assert.NotNull(secondPage.NextPageToken); + + // Act - Third page + var thirdPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "-name", + PageToken = secondPage.NextPageToken + }; + var thirdPage = testData.ApplyQueryCursorPaged(queryProcessor, thirdPageOptions); + + // Assert - Third page should have id=5 only + Assert.Equal(1, thirdPage.Count); + Assert.Equal(5, thirdPage.Datas.First().Id); + Assert.Null(thirdPage.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPaged_NoSort_ShouldUseCursorKeyAsDefaultSort() + { + // Arrange + var queryProcessor = new QueryProcessor(); + var builder = new FilterQueryBuilder(); + builder.HasCursorKey(d => d.Id); + queryProcessor.AddFilterQueryBuilder(builder); + + var testData = new List + { + new TestData { Id = 5, Name = "Item 5" }, + new TestData { Id = 2, Name = "Item 2" }, + new TestData { Id = 4, Name = "Item 4" }, + new TestData { Id = 1, Name = "Item 1" }, + new TestData { Id = 3, Name = "Item 3" }, + }.AsQueryable(); + + // Act - No sort specified + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; + var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); + + // Assert - Should be sorted by Id (cursor key) + Assert.Equal(2, firstPage.Count); + Assert.Equal(1, firstPage.Datas.First().Id); + Assert.Equal(2, firstPage.Datas.Last().Id); + Assert.NotNull(firstPage.NextPageToken); + } + public class TestData { public int Id { get; set; } From 9439bb3f4e72137090b5afc41afd755bb8c3af0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:21:41 +0000 Subject: [PATCH 09/16] Revert problematic cursor-key prepending and document limitations Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- README.md | 13 +- src/AutoQuery/Extensions/QueryExtensions.cs | 57 ++----- .../Extensions/QueryExtensionsTests.cs | 147 ------------------ 3 files changed, 20 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index e133a64..2c6a995 100644 --- a/README.md +++ b/README.md @@ -203,15 +203,16 @@ AutoQuery also supports cursor-based pagination, which is more efficient for lar - **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 -### Important: Sort Order with Cursor Pagination +### Important Limitations -**Cursor-based pagination always uses the cursor key as the primary sort field** to ensure consistent results. When you specify a different sort field (e.g., `sort=name`), the cursor key is automatically prepended to the sort expression: +**Cursor-based pagination works best when sorting by the cursor key field.** When you sort by other fields (e.g., `sort=name` when the cursor key is `id`), some items may be skipped in pagination because the cursor only tracks the ID value, not the sort field value. -- `sort=name` becomes `sort=id,name` (Id is cursor key, Name is secondary) -- `sort=-name` becomes `sort=id,-name` (Id is primary ascending, Name secondary descending) -- `sort=id` remains `sort=id` (cursor key already specified) +**Best practices:** +- **Recommended**: Sort by the cursor key: `sort=id` or `sort=-id` +- **Also works well**: Include cursor key in multi-field sort: `sort=name,id` (name primary, id as tie-breaker) +- **Limited support**: Sorting only by non-cursor fields: `sort=name` (may skip items across pages) -This ensures that pagination is always deterministic and consistent across requests. +If you need to sort by fields other than the cursor key with full pagination support, consider using offset-based pagination instead. ### Using Cursor-Based Pagination diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index f8ca704..faf8807 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -162,6 +162,17 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged /// /// Applies query conditions and cursor-based pagination. /// + /// + /// + /// Important: Cursor-based pagination works best when sorting by the cursor key field. + /// When sorting by other fields, pagination will work but may not include all items in the expected order + /// unless the cursor key is also included in the sort expression. + /// + /// + /// For optimal results with non-cursor-key sorting, include the cursor key as a secondary sort field. + /// For example: sort=name,id where id is the cursor key. + /// + /// /// The type of the entity being queried. /// The type of the query options. /// The query object. @@ -190,21 +201,14 @@ public static CursorPagedResult ApplyQueryCursorPaged - /// Ensures the cursor key is included in the sort expression for consistent pagination. - /// - private static string EnsureCursorKeyInSort(string? sortExpression, string? cursorPropertyName) - { - if (string.IsNullOrEmpty(cursorPropertyName)) - return sortExpression ?? string.Empty; - - // If no sort expression, use cursor key ascending - if (string.IsNullOrWhiteSpace(sortExpression)) - return cursorPropertyName; - - // Check if cursor key is already in the sort expression - var sortFields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var sortField in sortFields) - { - if (string.IsNullOrWhiteSpace(sortField)) - continue; - - var isDescending = sortField.StartsWith("-"); - var fieldName = isDescending ? sortField[1..] : sortField; - - if (string.Equals(fieldName, cursorPropertyName, StringComparison.OrdinalIgnoreCase)) - { - // Cursor key is already in sort, return as-is - return sortExpression; - } - } - - // Cursor key not in sort, prepend it as primary sort (ascending by default) - // This ensures cursor-based filtering works correctly - return $"{cursorPropertyName},{sortExpression}"; - } } diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index ee9b1d0..7e1a1b5 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -661,153 +661,6 @@ public void ApplyQueryCursorPaged_WithDescendingSort() Assert.Null(thirdPage.NextPageToken); } - [Fact] - public void ApplyQueryCursorPaged_SortByDifferentField_ShouldIncludeCursorKeyInSort() - { - // Arrange - var queryProcessor = new QueryProcessor(); - var builder = new FilterQueryBuilder(); - builder.HasCursorKey(d => d.Id); - queryProcessor.AddFilterQueryBuilder(builder); - - var testData = new List - { - new TestData { Id = 3, Name = "Alice" }, - new TestData { Id = 1, Name = "Bob" }, - new TestData { Id = 4, Name = "Charlie" }, - new TestData { Id = 2, Name = "Diana" }, - new TestData { Id = 5, Name = "Eve" }, - }.AsQueryable(); - - // Act - First page sorted by Name (cursor key Id will be PRIMARY sort, Name secondary) - // Effective sort becomes: id,name - var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "name" }; - var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); - - // Assert - First page should have id=1 (Bob) and id=2 (Diana) - sorted by Id first - Assert.Equal(2, firstPage.Count); - Assert.Equal(1, firstPage.Datas.First().Id); - Assert.Equal(2, firstPage.Datas.Last().Id); - Assert.NotNull(firstPage.NextPageToken); - - // Act - Second page - var secondPageOptions = new TestCursorQueryOptions - { - PageSize = 2, - Sort = "name", - PageToken = firstPage.NextPageToken - }; - var secondPage = testData.ApplyQueryCursorPaged(queryProcessor, secondPageOptions); - - // Assert - Second page should have id=3 and id=4 - Assert.Equal(2, secondPage.Count); - Assert.Equal(3, secondPage.Datas.First().Id); - Assert.Equal(4, secondPage.Datas.Last().Id); - Assert.NotNull(secondPage.NextPageToken); - - // Act - Third page - var thirdPageOptions = new TestCursorQueryOptions - { - PageSize = 2, - Sort = "name", - PageToken = secondPage.NextPageToken - }; - var thirdPage = testData.ApplyQueryCursorPaged(queryProcessor, thirdPageOptions); - - // Assert - Third page should have id=5 only - Assert.Equal(1, thirdPage.Count); - Assert.Equal(5, thirdPage.Datas.First().Id); - Assert.Null(thirdPage.NextPageToken); - } - - [Fact] - public void ApplyQueryCursorPaged_SortByDifferentFieldDescending_ShouldIncludeCursorKeyInSort() - { - // Arrange - var queryProcessor = new QueryProcessor(); - var builder = new FilterQueryBuilder(); - builder.HasCursorKey(d => d.Id); - queryProcessor.AddFilterQueryBuilder(builder); - - var testData = new List - { - new TestData { Id = 3, Name = "Alice" }, - new TestData { Id = 1, Name = "Bob" }, - new TestData { Id = 4, Name = "Charlie" }, - new TestData { Id = 2, Name = "Diana" }, - new TestData { Id = 5, Name = "Eve" }, - }.AsQueryable(); - - // Act - First page sorted by -Name (cursor key Id will be PRIMARY sort, -Name secondary) - // Effective sort becomes: id,-name - var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "-name" }; - var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); - - // Assert - First page should have id=1 and id=2 - sorted by Id first - Assert.Equal(2, firstPage.Count); - Assert.Equal(1, firstPage.Datas.First().Id); - Assert.Equal(2, firstPage.Datas.Last().Id); - Assert.NotNull(firstPage.NextPageToken); - - // Act - Second page - var secondPageOptions = new TestCursorQueryOptions - { - PageSize = 2, - Sort = "-name", - PageToken = firstPage.NextPageToken - }; - var secondPage = testData.ApplyQueryCursorPaged(queryProcessor, secondPageOptions); - - // Assert - Second page should have id=3 and id=4 - Assert.Equal(2, secondPage.Count); - Assert.Equal(3, secondPage.Datas.First().Id); - Assert.Equal(4, secondPage.Datas.Last().Id); - Assert.NotNull(secondPage.NextPageToken); - - // Act - Third page - var thirdPageOptions = new TestCursorQueryOptions - { - PageSize = 2, - Sort = "-name", - PageToken = secondPage.NextPageToken - }; - var thirdPage = testData.ApplyQueryCursorPaged(queryProcessor, thirdPageOptions); - - // Assert - Third page should have id=5 only - Assert.Equal(1, thirdPage.Count); - Assert.Equal(5, thirdPage.Datas.First().Id); - Assert.Null(thirdPage.NextPageToken); - } - - [Fact] - public void ApplyQueryCursorPaged_NoSort_ShouldUseCursorKeyAsDefaultSort() - { - // Arrange - var queryProcessor = new QueryProcessor(); - var builder = new FilterQueryBuilder(); - builder.HasCursorKey(d => d.Id); - queryProcessor.AddFilterQueryBuilder(builder); - - var testData = new List - { - new TestData { Id = 5, Name = "Item 5" }, - new TestData { Id = 2, Name = "Item 2" }, - new TestData { Id = 4, Name = "Item 4" }, - new TestData { Id = 1, Name = "Item 1" }, - new TestData { Id = 3, Name = "Item 3" }, - }.AsQueryable(); - - // Act - No sort specified - var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; - var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); - - // Assert - Should be sorted by Id (cursor key) - Assert.Equal(2, firstPage.Count); - Assert.Equal(1, firstPage.Datas.First().Id); - Assert.Equal(2, firstPage.Datas.Last().Id); - Assert.NotNull(firstPage.NextPageToken); - } - public class TestData { public int Id { get; set; } From 4c44cf42e14753681928cceefbb8cfc8de4f40b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:32:50 +0000 Subject: [PATCH 10/16] Implement composite cursors for full sorting support in cursor pagination Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- README.md | 14 +- src/AutoQuery/Extensions/QueryExtensions.cs | 242 ++++++++++++++++++-- src/AutoQuery/PageToken.cs | 39 ++++ 3 files changed, 270 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2c6a995..1e19c42 100644 --- a/README.md +++ b/README.md @@ -202,17 +202,17 @@ AutoQuery also supports cursor-based pagination, which is more efficient for lar - **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 +- **Flexible Sorting**: Supports sorting by any field using composite cursors -### Important Limitations +### How It Works -**Cursor-based pagination works best when sorting by the cursor key field.** When you sort by other fields (e.g., `sort=name` when the cursor key is `id`), some items may be skipped in pagination because the cursor only tracks the ID value, not the sort field value. +Cursor-based pagination uses **composite cursors** that encode all sort field values along with the cursor key. This allows accurate pagination regardless of which fields you sort by: -**Best practices:** -- **Recommended**: Sort by the cursor key: `sort=id` or `sort=-id` -- **Also works well**: Include cursor key in multi-field sort: `sort=name,id` (name primary, id as tie-breaker) -- **Limited support**: Sorting only by non-cursor fields: `sort=name` (may skip items across pages) +- When sorting by the cursor key (e.g., `sort=id`): The cursor encodes just the ID +- When sorting by other fields (e.g., `sort=name`): The cursor encodes both the name and ID values +- Multi-field sorts (e.g., `sort=name,-dateOfBirth,id`): All field values are encoded -If you need to sort by fields other than the cursor key with full pagination support, consider using offset-based pagination instead. +The cursor key is automatically added as a tie-breaker if not already in the sort expression, ensuring deterministic ordering. ### Using Cursor-Based Pagination diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index faf8807..a3c80d9 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -163,15 +163,8 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged /// Applies query conditions and cursor-based pagination. /// /// - /// - /// Important: Cursor-based pagination works best when sorting by the cursor key field. - /// When sorting by other fields, pagination will work but may not include all items in the expected order - /// unless the cursor key is also included in the sort expression. - /// - /// - /// For optimal results with non-cursor-key sorting, include the cursor key as a secondary sort field. - /// For example: sort=name,id where id is the cursor key. - /// + /// Cursor-based pagination now supports sorting by any field using composite cursors. + /// The page token encodes all sort field values plus the cursor key for accurate pagination. /// /// The type of the entity being queried. /// The type of the query options. @@ -201,15 +194,24 @@ public static CursorPagedResult ApplyQueryCursorPaged string.Equals(sf.PropertyName, cursorPropertyName, StringComparison.OrdinalIgnoreCase))) + { + sortFields.Add(new SortField { PropertyName = cursorPropertyName, IsDescending = false }); + } + + // Apply sorting + query = ApplySortFields(query, sortFields); // Apply cursor-based filtering if page token is provided if (!string.IsNullOrWhiteSpace(queryOption.PageToken)) { - // Determine if cursor key is sorted in descending order - bool isDescending = IsCursorKeyDescending(queryOption.Sort, cursorKeySelector); - query = ApplyCursorFilter(query, cursorKeySelector, queryOption.PageToken, isDescending); + query = ApplyCompositeCursorFilter(query, sortFields, queryOption.PageToken); } // Fetch one extra item to determine if there are more results @@ -224,10 +226,7 @@ public static CursorPagedResult ApplyQueryCursorPaged(items, nextPageToken, items.Count); @@ -329,4 +328,211 @@ private static bool IsCursorKeyDescending(string? sortExpression, LambdaExpressi return null; } + + /// + /// Represents a sort field with its direction. + /// + private class SortField + { + public string PropertyName { get; set; } = null!; + public bool IsDescending { get; set; } + } + + /// + /// Parses sort expression into list of sort fields. + /// + private static List ParseSortFields(string? sortExpression) + { + var sortFields = new List(); + + if (string.IsNullOrWhiteSpace(sortExpression)) + return sortFields; + + var fields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var field in fields) + { + if (string.IsNullOrWhiteSpace(field)) + continue; + + var isDescending = field.StartsWith("-"); + var propertyName = isDescending ? field[1..] : field; + + sortFields.Add(new SortField + { + PropertyName = propertyName, + IsDescending = isDescending + }); + } + + return sortFields; + } + + /// + /// Applies sort fields to query. + /// + private static IQueryable ApplySortFields(IQueryable query, List sortFields) + { + if (sortFields.Count == 0) + return query; + + IOrderedQueryable? orderedQuery = null; + + foreach (var sortField in sortFields) + { + var parameter = Expression.Parameter(typeof(TData), "x"); + var property = Expression.Property(parameter, sortField.PropertyName); + var lambda = Expression.Lambda(property, parameter); + + var methodName = orderedQuery == null + ? (sortField.IsDescending ? "OrderByDescending" : "OrderBy") + : (sortField.IsDescending ? "ThenByDescending" : "ThenBy"); + + var method = typeof(Queryable).GetMethods() + .First(m => m.Name == methodName && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(TData), property.Type); + + orderedQuery = (IOrderedQueryable)method.Invoke(null, new object[] { orderedQuery ?? query, lambda })!; + } + + return orderedQuery ?? query; + } + + /// + /// Creates a composite cursor token from the last item. + /// + private static string CreateCompositeCursorToken(TData lastItem, List sortFields) + { + var cursorValues = new Dictionary(); + + foreach (var sortField in sortFields) + { + var property = typeof(TData).GetProperty(sortField.PropertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (property != null) + { + var value = property.GetValue(lastItem); + cursorValues[sortField.PropertyName] = value; + } + } + + if (cursorValues.Count == 0) + throw new InvalidOperationException($"No valid properties found for sort fields in type {typeof(TData).Name}"); + + return PageToken.EncodeComposite(cursorValues); + } + + /// + /// Applies composite cursor filter to the query. + /// + private static IQueryable ApplyCompositeCursorFilter( + IQueryable query, + List sortFields, + string pageToken) + { + if (sortFields.Count == 0) + return query; + + var cursorValues = PageToken.DecodeComposite(pageToken); + var parameter = Expression.Parameter(typeof(TData), "entity"); + + // Build composite filter: (field1 > cursor1) OR (field1 = cursor1 AND field2 > cursor2) OR ... + Expression? filterExpression = null; + + for (int i = 0; i < sortFields.Count; i++) + { + Expression? currentCondition = null; + + // Build equality conditions for all previous fields + for (int j = 0; j < i; j++) + { + var prevField = sortFields[j]; + if (!cursorValues.TryGetValue(prevField.PropertyName, out var prevCursorValue)) + continue; + + var prevProperty = Expression.Property(parameter, prevField.PropertyName); + var prevValue = ConvertJsonElement(prevCursorValue, prevProperty.Type); + var prevConstant = Expression.Constant(prevValue, prevProperty.Type); + var equality = Expression.Equal(prevProperty, prevConstant); + + currentCondition = currentCondition == null ? equality : Expression.AndAlso(currentCondition, equality); + } + + // Build comparison for current field + var currentField = sortFields[i]; + if (cursorValues.TryGetValue(currentField.PropertyName, out var currentCursorValue)) + { + var currentProperty = Expression.Property(parameter, currentField.PropertyName); + var currentValue = ConvertJsonElement(currentCursorValue, currentProperty.Type); + var currentConstant = Expression.Constant(currentValue, currentProperty.Type); + + Expression comparison; + + // Use String.Compare for string comparisons + if (currentProperty.Type == typeof(string)) + { + var compareMethod = typeof(string).GetMethod(nameof(string.Compare), + new[] { typeof(string), typeof(string) })!; + var compareCall = Expression.Call(compareMethod, currentProperty, currentConstant); + var zero = Expression.Constant(0); + + comparison = currentField.IsDescending + ? Expression.LessThan(compareCall, zero) + : Expression.GreaterThan(compareCall, zero); + } + else + { + comparison = currentField.IsDescending + ? Expression.LessThan(currentProperty, currentConstant) + : Expression.GreaterThan(currentProperty, currentConstant); + } + + currentCondition = currentCondition == null + ? comparison + : Expression.AndAlso(currentCondition, comparison); + + filterExpression = filterExpression == null + ? currentCondition + : Expression.OrElse(filterExpression, currentCondition); + } + } + + if (filterExpression != null) + { + var lambda = Expression.Lambda>(filterExpression, parameter); + query = query.Where(lambda); + } + + return query; + } + + /// + /// Converts JsonElement to the target type. + /// + private static object? ConvertJsonElement(System.Text.Json.JsonElement jsonElement, Type targetType) + { + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlyingType == typeof(string)) + return jsonElement.GetString(); + if (underlyingType == typeof(int)) + return jsonElement.GetInt32(); + if (underlyingType == typeof(long)) + return jsonElement.GetInt64(); + if (underlyingType == typeof(bool)) + return jsonElement.GetBoolean(); + if (underlyingType == typeof(double)) + return jsonElement.GetDouble(); + if (underlyingType == typeof(decimal)) + return jsonElement.GetDecimal(); + if (underlyingType == typeof(DateTime)) + return jsonElement.GetDateTime(); + if (underlyingType == typeof(DateTimeOffset)) + return jsonElement.GetDateTimeOffset(); + if (underlyingType == typeof(Guid)) + return jsonElement.GetGuid(); + + // Fallback: try to deserialize + return System.Text.Json.JsonSerializer.Deserialize(jsonElement.GetRawText(), targetType); + } } diff --git a/src/AutoQuery/PageToken.cs b/src/AutoQuery/PageToken.cs index 1e4eb1a..575c937 100644 --- a/src/AutoQuery/PageToken.cs +++ b/src/AutoQuery/PageToken.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; namespace AutoQuery; @@ -25,6 +26,21 @@ public static string Encode(object cursorValue) return Convert.ToBase64String(bytes); } + /// + /// Encodes multiple cursor values into an opaque page token (composite cursor). + /// + /// Dictionary of field names and their values. + /// The encoded page token. + public static string EncodeComposite(Dictionary cursorValues) + { + if (cursorValues == null || cursorValues.Count == 0) + throw new ArgumentException("Cursor values cannot be null or empty", nameof(cursorValues)); + + var json = JsonSerializer.Serialize(cursorValues); + var bytes = Encoding.UTF8.GetBytes(json); + return Convert.ToBase64String(bytes); + } + /// /// Decodes a page token into the original cursor value. /// @@ -65,4 +81,27 @@ public static T Decode(string pageToken) throw new ArgumentException("Invalid page token", nameof(pageToken), ex); } } + + /// + /// Decodes a composite page token into a dictionary of cursor values. + /// + /// The page token to decode. + /// Dictionary of field names and their values. + public static Dictionary DecodeComposite(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 json = Encoding.UTF8.GetString(bytes); + return JsonSerializer.Deserialize>(json) + ?? throw new InvalidOperationException("Failed to deserialize page token"); + } + catch (Exception ex) + { + throw new ArgumentException("Invalid page token", nameof(pageToken), ex); + } + } } From 36d548b434b454364ee7770727571ff7bb195db0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:20:25 +0000 Subject: [PATCH 11/16] Optimize cursor pagination code: remove inline comments and improve performance Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/Extensions/QueryExtensions.cs | 89 +++++++++------------ src/AutoQuery/PageToken.cs | 27 ++----- 2 files changed, 46 insertions(+), 70 deletions(-) diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index a3c80d9..4381f18 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -163,7 +163,7 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged /// Applies query conditions and cursor-based pagination. /// /// - /// Cursor-based pagination now supports sorting by any field using composite cursors. + /// Supports sorting by any field using composite cursors. /// The page token encodes all sort field values plus the cursor key for accurate pagination. /// /// The type of the entity being queried. @@ -188,45 +188,36 @@ public static CursorPagedResult ApplyQueryCursorPaged(); - if (cursorKeySelector == null) throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration."); - // Parse sort fields var sortFields = ParseSortFields(queryOption.Sort); var cursorPropertyName = GetPropertyName(cursorKeySelector); - // Ensure cursor key is in sort as tie-breaker if not already present if (!string.IsNullOrEmpty(cursorPropertyName) && !sortFields.Any(sf => string.Equals(sf.PropertyName, cursorPropertyName, StringComparison.OrdinalIgnoreCase))) { sortFields.Add(new SortField { PropertyName = cursorPropertyName, IsDescending = false }); } - // Apply sorting query = ApplySortFields(query, sortFields); - // Apply cursor-based filtering if page token is provided if (!string.IsNullOrWhiteSpace(queryOption.PageToken)) { query = ApplyCompositeCursorFilter(query, sortFields, 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(); - nextPageToken = CreateCompositeCursorToken(lastItem, sortFields); + nextPageToken = CreateCompositeCursorToken(items[^1], sortFields); } return new CursorPagedResult(items, nextPageToken, items.Count); @@ -343,12 +334,11 @@ private class SortField /// private static List ParseSortFields(string? sortExpression) { - var sortFields = new List(); - if (string.IsNullOrWhiteSpace(sortExpression)) - return sortFields; + return new List(); var fields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var sortFields = new List(fields.Length); foreach (var field in fields) { @@ -368,6 +358,8 @@ private static List ParseSortFields(string? sortExpression) return sortFields; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary _orderByMethodCache = new(); + /// /// Applies sort fields to query. /// @@ -388,9 +380,11 @@ private static IQueryable ApplySortFields(IQueryable query, ? (sortField.IsDescending ? "OrderByDescending" : "OrderBy") : (sortField.IsDescending ? "ThenByDescending" : "ThenBy"); - var method = typeof(Queryable).GetMethods() - .First(m => m.Name == methodName && m.GetParameters().Length == 2) - .MakeGenericMethod(typeof(TData), property.Type); + var cacheKey = $"{methodName}_{typeof(TData).FullName}_{property.Type.FullName}"; + var method = _orderByMethodCache.GetOrAdd(cacheKey, _ => + typeof(Queryable).GetMethods() + .First(m => m.Name == methodName && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(TData), property.Type)); orderedQuery = (IOrderedQueryable)method.Invoke(null, new object[] { orderedQuery ?? query, lambda })!; } @@ -398,21 +392,26 @@ private static IQueryable ApplySortFields(IQueryable query, return orderedQuery ?? query; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); + /// /// Creates a composite cursor token from the last item. /// private static string CreateCompositeCursorToken(TData lastItem, List sortFields) { - var cursorValues = new Dictionary(); + var cursorValues = new Dictionary(sortFields.Count); + var typeName = typeof(TData).FullName!; foreach (var sortField in sortFields) { - var property = typeof(TData).GetProperty(sortField.PropertyName, - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + var cacheKey = $"{typeName}.{sortField.PropertyName}"; + var property = _propertyCache.GetOrAdd(cacheKey, _ => + typeof(TData).GetProperty(sortField.PropertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)); + if (property != null) { - var value = property.GetValue(lastItem); - cursorValues[sortField.PropertyName] = value; + cursorValues[sortField.PropertyName] = property.GetValue(lastItem); } } @@ -422,6 +421,9 @@ private static string CreateCompositeCursorToken(TData lastItem, List /// Applies composite cursor filter to the query. /// @@ -435,15 +437,12 @@ private static IQueryable ApplyCompositeCursorFilter( var cursorValues = PageToken.DecodeComposite(pageToken); var parameter = Expression.Parameter(typeof(TData), "entity"); - - // Build composite filter: (field1 > cursor1) OR (field1 = cursor1 AND field2 > cursor2) OR ... Expression? filterExpression = null; for (int i = 0; i < sortFields.Count; i++) { Expression? currentCondition = null; - // Build equality conditions for all previous fields for (int j = 0; j < i; j++) { var prevField = sortFields[j]; @@ -458,7 +457,6 @@ private static IQueryable ApplyCompositeCursorFilter( currentCondition = currentCondition == null ? equality : Expression.AndAlso(currentCondition, equality); } - // Build comparison for current field var currentField = sortFields[i]; if (cursorValues.TryGetValue(currentField.PropertyName, out var currentCursorValue)) { @@ -468,12 +466,9 @@ private static IQueryable ApplyCompositeCursorFilter( Expression comparison; - // Use String.Compare for string comparisons if (currentProperty.Type == typeof(string)) { - var compareMethod = typeof(string).GetMethod(nameof(string.Compare), - new[] { typeof(string), typeof(string) })!; - var compareCall = Expression.Call(compareMethod, currentProperty, currentConstant); + var compareCall = Expression.Call(_stringCompareMethod, currentProperty, currentConstant); var zero = Expression.Constant(0); comparison = currentField.IsDescending @@ -513,26 +508,18 @@ private static IQueryable ApplyCompositeCursorFilter( { var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; - if (underlyingType == typeof(string)) - return jsonElement.GetString(); - if (underlyingType == typeof(int)) - return jsonElement.GetInt32(); - if (underlyingType == typeof(long)) - return jsonElement.GetInt64(); - if (underlyingType == typeof(bool)) - return jsonElement.GetBoolean(); - if (underlyingType == typeof(double)) - return jsonElement.GetDouble(); - if (underlyingType == typeof(decimal)) - return jsonElement.GetDecimal(); - if (underlyingType == typeof(DateTime)) - return jsonElement.GetDateTime(); - if (underlyingType == typeof(DateTimeOffset)) - return jsonElement.GetDateTimeOffset(); - if (underlyingType == typeof(Guid)) - return jsonElement.GetGuid(); - - // Fallback: try to deserialize - return System.Text.Json.JsonSerializer.Deserialize(jsonElement.GetRawText(), targetType); + return Type.GetTypeCode(underlyingType) switch + { + TypeCode.String => jsonElement.GetString(), + TypeCode.Int32 => jsonElement.GetInt32(), + TypeCode.Int64 => jsonElement.GetInt64(), + TypeCode.Boolean => jsonElement.GetBoolean(), + TypeCode.Double => jsonElement.GetDouble(), + TypeCode.Decimal => jsonElement.GetDecimal(), + TypeCode.DateTime => jsonElement.GetDateTime(), + _ => underlyingType == typeof(DateTimeOffset) ? jsonElement.GetDateTimeOffset() : + underlyingType == typeof(Guid) ? jsonElement.GetGuid() : + System.Text.Json.JsonSerializer.Deserialize(jsonElement.GetRawText(), targetType) + }; } } diff --git a/src/AutoQuery/PageToken.cs b/src/AutoQuery/PageToken.cs index 575c937..a8b83f6 100644 --- a/src/AutoQuery/PageToken.cs +++ b/src/AutoQuery/PageToken.cs @@ -22,12 +22,11 @@ public static string Encode(object cursorValue) if (string.IsNullOrEmpty(valueString)) throw new ArgumentException("Cursor value cannot be empty", nameof(cursorValue)); - var bytes = Encoding.UTF8.GetBytes(valueString); - return Convert.ToBase64String(bytes); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(valueString)); } /// - /// Encodes multiple cursor values into an opaque page token (composite cursor). + /// Encodes multiple cursor values into an opaque page token. /// /// Dictionary of field names and their values. /// The encoded page token. @@ -37,8 +36,7 @@ public static string EncodeComposite(Dictionary cursorValues) throw new ArgumentException("Cursor values cannot be null or empty", nameof(cursorValues)); var json = JsonSerializer.Serialize(cursorValues); - var bytes = Encoding.UTF8.GetBytes(json); - return Convert.ToBase64String(bytes); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); } /// @@ -54,25 +52,17 @@ public static T Decode(string pageToken) try { - var bytes = Convert.FromBase64String(pageToken); - var valueString = Encoding.UTF8.GetString(bytes); - - var targetType = typeof(T); - var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + var valueString = Encoding.UTF8.GetString(Convert.FromBase64String(pageToken)); + var underlyingType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - // 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); } @@ -94,8 +84,7 @@ public static Dictionary DecodeComposite(string pageToken) try { - var bytes = Convert.FromBase64String(pageToken); - var json = Encoding.UTF8.GetString(bytes); + var json = Encoding.UTF8.GetString(Convert.FromBase64String(pageToken)); return JsonSerializer.Deserialize>(json) ?? throw new InvalidOperationException("Failed to deserialize page token"); } From f4282a7e4b703e6d7cd6e2c96127d88c42a7deb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:38:22 +0000 Subject: [PATCH 12/16] Refactor code per review: rename method, move fields to top, remove unused code, unify sorting logic Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- .../UserCursorQueryConfiguration.cs | 2 - .../Controllers/UsersController.cs | 2 +- src/AutoQuery/CursorPagedResult.cs | 4 +- src/AutoQuery/Extensions/QueryExtensions.cs | 258 ++++++------------ .../Extensions/QueryExtensionsTests.cs | 44 +-- 5 files changed, 113 insertions(+), 197 deletions(-) diff --git a/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs b/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs index c8b6d48..34e09d2 100644 --- a/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs +++ b/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs @@ -9,10 +9,8 @@ public class UserCursorQueryConfiguration : IFilterQueryConfiguration 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) diff --git a/sample/AutoQueryApiDemo/Controllers/UsersController.cs b/sample/AutoQueryApiDemo/Controllers/UsersController.cs index ead0c75..e7ce5b5 100644 --- a/sample/AutoQueryApiDemo/Controllers/UsersController.cs +++ b/sample/AutoQueryApiDemo/Controllers/UsersController.cs @@ -40,7 +40,7 @@ public IActionResult Get(UserQueryOptions queryOptions) public IActionResult GetWithCursor(UserCursorQueryOptions queryOptions) { var result = users.AsQueryable() - .ApplyQueryCursorPaged(_queryProcessor, queryOptions); + .ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); return Ok(result); } } diff --git a/src/AutoQuery/CursorPagedResult.cs b/src/AutoQuery/CursorPagedResult.cs index c44dcce..4607bc5 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(IEnumerable Datas, string? NextPageToken, int Count); +public record CursorPagedResult(IQueryable Datas, string? NextPageToken, int Count); diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 4381f18..9e86c8e 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -1,4 +1,4 @@ -using AutoQuery.Abstractions; +using AutoQuery.Abstractions; using System.Collections.Concurrent; using System.Linq.Expressions; using System.Reflection; @@ -12,6 +12,10 @@ public static class QueryExtensions { private static readonly ConcurrentDictionary s_PropertysCache = new(); private static readonly ConcurrentDictionary s_PropertyCache = new(); + private static readonly ConcurrentDictionary _propertyCache = new(); + private static readonly ConcurrentDictionary _orderByMethodCache = new(); + private static readonly MethodInfo _stringCompareMethod = + typeof(string).GetMethod(nameof(string.Compare), new[] { typeof(string), typeof(string) })!; /// /// Applies query conditions. @@ -84,81 +88,6 @@ public static PagedResult ApplyQueryPagedResult(thi return new PagedResult(query, page, totalPages, count); } - /// - /// Applies sorting to the query results. - /// - /// The type of the entity being queried. - /// The query object. - /// The query options. - /// The sorted query results. - public static IQueryable ApplySort(this IQueryable query, IQueryOptions queryOption) - { - if (string.IsNullOrWhiteSpace(queryOption.Sort)) - return query; - - var sortFields = queryOption.Sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var isFirstSort = true; - - foreach (var sort in sortFields) - { - if (string.IsNullOrWhiteSpace(sort)) - continue; - - var descending = sort.StartsWith("-"); - var sortBy = descending ? sort[1..] : sort; - var cacheKey = $"{typeof(T).FullName}_{sortBy}"; - var propertyInfo = s_PropertyCache.GetOrAdd(cacheKey, _ => typeof(T).GetProperty(sortBy, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)); - - if (propertyInfo == null) - continue; - - var parameter = Expression.Parameter(typeof(T), "entity"); - var property = Expression.Property(parameter, propertyInfo); - var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), propertyInfo.PropertyType); - var lambda = Expression.Lambda(delegateType, property, parameter); - - string methodName = (isFirstSort, descending) switch - { - (true, true) => "OrderByDescending", - (true, false) => "OrderBy", - (false, true) => "ThenByDescending", - (false, false) => "ThenBy" - }; - - var resultExpression = Expression.Call( - typeof(Queryable), - methodName, - [typeof(T), propertyInfo.PropertyType], - query.Expression, - lambda - ); - - query = query.Provider.CreateQuery(resultExpression); - isFirstSort = false; - } - - return query; - } - - /// - /// Applies pagination to the query results. - /// - /// The type of the entity being queried. - /// The query object. - /// The query options. - /// The query object with pagination applied. - public static IQueryable ApplyPaging(this IQueryable query, IQueryPagedOptions queryOption) - { - if (queryOption.PageSize.HasValue) - { - var page = queryOption.Page.HasValue ? queryOption.Page.Value : 1; - int skip = (page - 1) * queryOption.PageSize.Value; - query = query.Skip(skip).Take(queryOption.PageSize.Value); - } - - return query; - } - /// /// Applies query conditions and cursor-based pagination. /// @@ -172,7 +101,7 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged /// The query processor. /// The query options. /// The cursor-based paginated result. - public static CursorPagedResult ApplyQueryCursorPaged( + public static CursorPagedResult ApplyQueryCursorPagedResult( this IQueryable query, IQueryProcessor queryProcessor, TQueryOptions queryOption) @@ -201,7 +130,7 @@ public static CursorPagedResult ApplyQueryCursorPaged ApplyQueryCursorPaged(items, nextPageToken, items.Count); + return new CursorPagedResult(items.AsQueryable(), nextPageToken, items.Count); } /// - /// Applies cursor-based filtering to the query. + /// Applies sorting to the query results. /// - private static IQueryable ApplyCursorFilter( - IQueryable query, - LambdaExpression cursorKeySelector, - string pageToken, - bool isDescending) + /// The type of the entity being queried. + /// The query object. + /// The query options. + /// The sorted query results. + public static IQueryable ApplySort(this IQueryable query, IQueryOptions queryOption) { - var returnType = ((cursorKeySelector.Body as MemberExpression)?.Type) - ?? ((cursorKeySelector.Body as UnaryExpression)?.Operand as MemberExpression)?.Type - ?? typeof(object); + if (string.IsNullOrWhiteSpace(queryOption.Sort)) + return query; - var decodeMethod = typeof(PageToken).GetMethod(nameof(PageToken.Decode))!.MakeGenericMethod(returnType); - var cursorValue = decodeMethod.Invoke(null, new object[] { pageToken }); + var sortFields = queryOption.Sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var isFirstSort = true; - if (cursorValue == null) - throw new InvalidOperationException("Decoded cursor value cannot be null."); + foreach (var sort in sortFields) + { + if (string.IsNullOrWhiteSpace(sort)) + continue; - // Build the filter expression: entity => entity.CursorKey > cursorValue (ascending) or entity => entity.CursorKey < cursorValue (descending) - var parameter = Expression.Parameter(typeof(TData), "entity"); - var cursorProperty = Expression.Invoke(cursorKeySelector, parameter); - var constant = Expression.Constant(cursorValue, returnType); - - // Use LessThan for descending order, GreaterThan for ascending order - var comparison = isDescending - ? Expression.LessThan(cursorProperty, constant) - : Expression.GreaterThan(cursorProperty, constant); - - var lambda = Expression.Lambda>(comparison, parameter); + var descending = sort.StartsWith("-"); + var sortBy = descending ? sort[1..] : sort; + var cacheKey = $"{typeof(T).FullName}_{sortBy}"; + var propertyInfo = s_PropertyCache.GetOrAdd(cacheKey, _ => typeof(T).GetProperty(sortBy, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)); - return query.Where(lambda); + if (propertyInfo == null) + continue; + + var parameter = Expression.Parameter(typeof(T), "entity"); + var property = Expression.Property(parameter, propertyInfo); + var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(typeof(T), propertyInfo.PropertyType), property, parameter); + + string methodName = (isFirstSort, descending) switch + { + (true, true) => "OrderByDescending", + (true, false) => "OrderBy", + (false, true) => "ThenByDescending", + (false, false) => "ThenBy" + }; + + var resultExpression = Expression.Call( + typeof(Queryable), + methodName, + [typeof(T), propertyInfo.PropertyType], + query.Expression, + lambda + ); + + query = query.Provider.CreateQuery(resultExpression); + isFirstSort = false; + } + + return query; } /// - /// Gets the cursor value from an entity. + /// Applies pagination to the query results. /// - private static object? GetCursorValue(TData entity, LambdaExpression cursorKeySelector) + /// The type of the entity being queried. + /// The query object. + /// The query options. + /// The query object with pagination applied. + public static IQueryable ApplyPaging(this IQueryable query, IQueryPagedOptions queryOption) { - var compiled = cursorKeySelector.Compile(); - return compiled.DynamicInvoke(entity); + if (queryOption.PageSize.HasValue) + { + var page = queryOption.Page.HasValue ? queryOption.Page.Value : 1; + int skip = (page - 1) * queryOption.PageSize.Value; + query = query.Skip(skip).Take(queryOption.PageSize.Value); + } + + return query; } /// - /// Determines if the cursor key is sorted in descending order. + /// Applies sort fields to query using same logic as ApplySort. /// - private static bool IsCursorKeyDescending(string? sortExpression, LambdaExpression cursorKeySelector) + private static IQueryable ApplySort(IQueryable query, List sortFields) { - if (string.IsNullOrWhiteSpace(sortExpression)) - return false; + if (sortFields.Count == 0) + return query; - // Get the cursor key property name - var cursorPropertyName = GetPropertyName(cursorKeySelector); - if (string.IsNullOrEmpty(cursorPropertyName)) - return false; + IOrderedQueryable? orderedQuery = null; - // Parse sort fields - var sortFields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var sortField in sortFields) { - if (string.IsNullOrWhiteSpace(sortField)) - continue; + var parameter = Expression.Parameter(typeof(TData), "entity"); + var property = Expression.Property(parameter, sortField.PropertyName); + var lambda = Expression.Lambda(property, parameter); - var isDescending = sortField.StartsWith("-"); - var fieldName = isDescending ? sortField[1..] : sortField; + var methodName = orderedQuery == null + ? (sortField.IsDescending ? "OrderByDescending" : "OrderBy") + : (sortField.IsDescending ? "ThenByDescending" : "ThenBy"); - // Check if this sort field matches the cursor key (case-insensitive) - if (string.Equals(fieldName, cursorPropertyName, StringComparison.OrdinalIgnoreCase)) - { - return isDescending; - } + var cacheKey = $"{methodName}_{typeof(TData).FullName}_{property.Type.FullName}"; + var method = _orderByMethodCache.GetOrAdd(cacheKey, _ => + typeof(Queryable).GetMethods() + .First(m => m.Name == methodName && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(TData), property.Type)); + + orderedQuery = (IOrderedQueryable)method.Invoke(null, new object[] { orderedQuery ?? query, lambda })!; } - // If cursor key is not in the sort expression, default to ascending - return false; + return orderedQuery ?? query; } /// @@ -358,42 +315,6 @@ private static List ParseSortFields(string? sortExpression) return sortFields; } - private static readonly System.Collections.Concurrent.ConcurrentDictionary _orderByMethodCache = new(); - - /// - /// Applies sort fields to query. - /// - private static IQueryable ApplySortFields(IQueryable query, List sortFields) - { - if (sortFields.Count == 0) - return query; - - IOrderedQueryable? orderedQuery = null; - - foreach (var sortField in sortFields) - { - var parameter = Expression.Parameter(typeof(TData), "x"); - var property = Expression.Property(parameter, sortField.PropertyName); - var lambda = Expression.Lambda(property, parameter); - - var methodName = orderedQuery == null - ? (sortField.IsDescending ? "OrderByDescending" : "OrderBy") - : (sortField.IsDescending ? "ThenByDescending" : "ThenBy"); - - var cacheKey = $"{methodName}_{typeof(TData).FullName}_{property.Type.FullName}"; - var method = _orderByMethodCache.GetOrAdd(cacheKey, _ => - typeof(Queryable).GetMethods() - .First(m => m.Name == methodName && m.GetParameters().Length == 2) - .MakeGenericMethod(typeof(TData), property.Type)); - - orderedQuery = (IOrderedQueryable)method.Invoke(null, new object[] { orderedQuery ?? query, lambda })!; - } - - return orderedQuery ?? query; - } - - private static readonly System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); - /// /// Creates a composite cursor token from the last item. /// @@ -421,9 +342,6 @@ private static string CreateCompositeCursorToken(TData lastItem, List /// Applies composite cursor filter to the query. /// diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index 7e1a1b5..139b27a 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -439,13 +439,13 @@ public QueryExtensionsCursorPaginationTests() } [Fact] - public void ApplyQueryCursorPaged_ShouldReturnFirstPage_WhenNoTokenProvided() + public void ApplyQueryCursorPagedResult_ShouldReturnFirstPage_WhenNoTokenProvided() { // Arrange var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); // Assert Assert.Equal(2, result.Count); @@ -454,11 +454,11 @@ public void ApplyQueryCursorPaged_ShouldReturnFirstPage_WhenNoTokenProvided() } [Fact] - public void ApplyQueryCursorPaged_ShouldReturnNextPage_WhenTokenProvided() + public void ApplyQueryCursorPagedResult_ShouldReturnNextPage_WhenTokenProvided() { // Arrange var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; - var firstPageResult = _testData.ApplyQueryCursorPaged(_queryProcessor, firstPageOptions); + var firstPageResult = _testData.ApplyQueryCursorPagedResult(_queryProcessor, firstPageOptions); var secondPageOptions = new TestCursorQueryOptions { @@ -467,7 +467,7 @@ public void ApplyQueryCursorPaged_ShouldReturnNextPage_WhenTokenProvided() }; // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, secondPageOptions); // Assert Assert.Equal(2, result.Count); @@ -475,13 +475,13 @@ public void ApplyQueryCursorPaged_ShouldReturnNextPage_WhenTokenProvided() } [Fact] - public void ApplyQueryCursorPaged_ShouldReturnNullNextToken_WhenNoMoreResults() + public void ApplyQueryCursorPagedResult_ShouldReturnNullNextToken_WhenNoMoreResults() { // Arrange var queryOptions = new TestCursorQueryOptions { PageSize = 10 }; // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); // Assert Assert.Equal(5, result.Count); @@ -489,7 +489,7 @@ public void ApplyQueryCursorPaged_ShouldReturnNullNextToken_WhenNoMoreResults() } [Fact] - public void ApplyQueryCursorPaged_ShouldThrowException_WhenCursorKeyNotConfigured() + public void ApplyQueryCursorPagedResult_ShouldThrowException_WhenCursorKeyNotConfigured() { // Arrange var queryProcessor = new QueryProcessor(); @@ -501,11 +501,11 @@ public void ApplyQueryCursorPaged_ShouldThrowException_WhenCursorKeyNotConfigure // Act & Assert Assert.Throws(() => - _testData.ApplyQueryCursorPaged(queryProcessor, queryOptions)); + _testData.ApplyQueryCursorPagedResult(queryProcessor, queryOptions)); } [Fact] - public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() + public void ApplyQueryCursorPagedResult_ShouldApplyFilters_WithCursorPagination() { // Arrange var queryOptions = new TestCursorQueryOptions @@ -515,7 +515,7 @@ public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() }; // Act - var result = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); // Assert Assert.Single(result.Datas); @@ -524,7 +524,7 @@ public void ApplyQueryCursorPaged_ShouldApplyFilters_WithCursorPagination() } [Fact] - public void ApplyQueryCursorPaged_ShouldWorkWithCustomSorting() + public void ApplyQueryCursorPagedResult_ShouldWorkWithCustomSorting() { // Arrange var queryOptions = new TestCursorQueryOptions @@ -534,14 +534,14 @@ public void ApplyQueryCursorPaged_ShouldWorkWithCustomSorting() }; // Act - var firstPage = _testData.ApplyQueryCursorPaged(_queryProcessor, queryOptions); + var firstPage = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); var secondPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "id", PageToken = firstPage.NextPageToken }; - var secondPage = _testData.ApplyQueryCursorPaged(_queryProcessor, secondPageOptions); + var secondPage = _testData.ApplyQueryCursorPagedResult(_queryProcessor, secondPageOptions); // Assert Assert.Equal(2, firstPage.Count); @@ -551,7 +551,7 @@ public void ApplyQueryCursorPaged_ShouldWorkWithCustomSorting() } [Fact] - public void ApplyQueryCursorPaged_WithLongCursorKey() + public void ApplyQueryCursorPagedResult_WithLongCursorKey() { // Arrange var queryProcessor = new QueryProcessor(); @@ -569,7 +569,7 @@ public void ApplyQueryCursorPaged_WithLongCursorKey() var queryOptions = new TestCursorQueryOptionsLong { PageSize = 2 }; // Act - var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); + var result = testData.ApplyQueryCursorPagedResult(queryProcessor, queryOptions); // Assert Assert.Equal(2, result.Count); @@ -577,7 +577,7 @@ public void ApplyQueryCursorPaged_WithLongCursorKey() } [Fact] - public void ApplyQueryCursorPaged_WithStringCursorKey() + public void ApplyQueryCursorPagedResult_WithStringCursorKey() { // Arrange var queryProcessor = new QueryProcessor(); @@ -595,7 +595,7 @@ public void ApplyQueryCursorPaged_WithStringCursorKey() var queryOptions = new TestCursorQueryOptionsString { PageSize = 2, Sort = "code" }; // Act - var result = testData.ApplyQueryCursorPaged(queryProcessor, queryOptions); + var result = testData.ApplyQueryCursorPagedResult(queryProcessor, queryOptions); // Assert Assert.Equal(2, result.Count); @@ -603,7 +603,7 @@ public void ApplyQueryCursorPaged_WithStringCursorKey() } [Fact] - public void ApplyQueryCursorPaged_WithDescendingSort() + public void ApplyQueryCursorPagedResult_WithDescendingSort() { // Arrange var queryProcessor = new QueryProcessor(); @@ -623,7 +623,7 @@ public void ApplyQueryCursorPaged_WithDescendingSort() var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "-id" }; // Act - First page - var firstPage = testData.ApplyQueryCursorPaged(queryProcessor, firstPageOptions); + var firstPage = testData.ApplyQueryCursorPagedResult(queryProcessor, firstPageOptions); // Assert - First page should have items 5 and 4 (descending order) Assert.Equal(2, firstPage.Count); @@ -638,7 +638,7 @@ public void ApplyQueryCursorPaged_WithDescendingSort() Sort = "-id", PageToken = firstPage.NextPageToken }; - var secondPage = testData.ApplyQueryCursorPaged(queryProcessor, secondPageOptions); + var secondPage = testData.ApplyQueryCursorPagedResult(queryProcessor, secondPageOptions); // Assert - Second page should have items 3 and 2 (descending order) Assert.Equal(2, secondPage.Count); @@ -653,7 +653,7 @@ public void ApplyQueryCursorPaged_WithDescendingSort() Sort = "-id", PageToken = secondPage.NextPageToken }; - var thirdPage = testData.ApplyQueryCursorPaged(queryProcessor, thirdPageOptions); + var thirdPage = testData.ApplyQueryCursorPagedResult(queryProcessor, thirdPageOptions); // Assert - Third page should have item 1 only Assert.Equal(1, thirdPage.Count); From 7dc22451dfc19feaf7823a8dd2692ab80a6c58f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:41:54 +0000 Subject: [PATCH 13/16] Address code review feedback: consolidate caches, rename method, add documentation Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/CursorPagedResult.cs | 5 ++++- src/AutoQuery/Extensions/QueryExtensions.cs | 13 ++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/AutoQuery/CursorPagedResult.cs b/src/AutoQuery/CursorPagedResult.cs index 4607bc5..416b292 100644 --- a/src/AutoQuery/CursorPagedResult.cs +++ b/src/AutoQuery/CursorPagedResult.cs @@ -4,7 +4,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 data collection of the paginated result, represented as for API consistency with other paged results. /// The page token for the next page, or null if there are no more results. /// The number of items in the current result set. +/// +/// Note: The data is materialized during pagination but exposed as IQueryable for consistency with . +/// public record CursorPagedResult(IQueryable Datas, string? NextPageToken, int Count); diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 9e86c8e..ea19618 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -12,7 +12,6 @@ public static class QueryExtensions { private static readonly ConcurrentDictionary s_PropertysCache = new(); private static readonly ConcurrentDictionary s_PropertyCache = new(); - private static readonly ConcurrentDictionary _propertyCache = new(); private static readonly ConcurrentDictionary _orderByMethodCache = new(); private static readonly MethodInfo _stringCompareMethod = typeof(string).GetMethod(nameof(string.Compare), new[] { typeof(string), typeof(string) })!; @@ -130,7 +129,7 @@ public static CursorPagedResult ApplyQueryCursorPagedResult ApplyPaging(this IQueryable query, IQueryPaged } /// - /// Applies sort fields to query using same logic as ApplySort. + /// Applies sort fields to query. /// - private static IQueryable ApplySort(IQueryable query, List sortFields) + /// + /// This private method provides the sorting implementation used by cursor pagination. + /// It accepts a structured list of sort fields rather than a string expression. + /// + private static IQueryable ApplySortFields(IQueryable query, List sortFields) { if (sortFields.Count == 0) return query; @@ -326,7 +329,7 @@ private static string CreateCompositeCursorToken(TData lastItem, List + var property = s_PropertyCache.GetOrAdd(cacheKey, _ => typeof(TData).GetProperty(sortField.PropertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)); From d858082f962474b3c89c9d78fe14e600b2418bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:43:45 +0000 Subject: [PATCH 14/16] Fix static field naming convention: use s_ prefix consistently Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/Extensions/QueryExtensions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index ea19618..7bd9e18 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -12,8 +12,8 @@ public static class QueryExtensions { private static readonly ConcurrentDictionary s_PropertysCache = new(); private static readonly ConcurrentDictionary s_PropertyCache = new(); - private static readonly ConcurrentDictionary _orderByMethodCache = new(); - private static readonly MethodInfo _stringCompareMethod = + private static readonly ConcurrentDictionary s_OrderByMethodCache = new(); + private static readonly MethodInfo s_StringCompareMethod = typeof(string).GetMethod(nameof(string.Compare), new[] { typeof(string), typeof(string) })!; /// @@ -250,7 +250,7 @@ private static IQueryable ApplySortFields(IQueryable query, : (sortField.IsDescending ? "ThenByDescending" : "ThenBy"); var cacheKey = $"{methodName}_{typeof(TData).FullName}_{property.Type.FullName}"; - var method = _orderByMethodCache.GetOrAdd(cacheKey, _ => + var method = s_OrderByMethodCache.GetOrAdd(cacheKey, _ => typeof(Queryable).GetMethods() .First(m => m.Name == methodName && m.GetParameters().Length == 2) .MakeGenericMethod(typeof(TData), property.Type)); @@ -389,7 +389,7 @@ private static IQueryable ApplyCompositeCursorFilter( if (currentProperty.Type == typeof(string)) { - var compareCall = Expression.Call(_stringCompareMethod, currentProperty, currentConstant); + var compareCall = Expression.Call(s_StringCompareMethod, currentProperty, currentConstant); var zero = Expression.Constant(0); comparison = currentField.IsDescending From d913919379a315db1db937a425672c648e1e53e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:55:48 +0000 Subject: [PATCH 15/16] Refactor for performance: eliminate ToList+AsQueryable, unify sorting logic, remove unused cache Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/Extensions/QueryExtensions.cs | 104 ++++++++------------ 1 file changed, 41 insertions(+), 63 deletions(-) diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 7bd9e18..8e57de2 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -12,7 +12,6 @@ public static class QueryExtensions { private static readonly ConcurrentDictionary s_PropertysCache = new(); private static readonly ConcurrentDictionary s_PropertyCache = new(); - private static readonly ConcurrentDictionary s_OrderByMethodCache = new(); private static readonly MethodInfo s_StringCompareMethod = typeof(string).GetMethod(nameof(string.Compare), new[] { typeof(string), typeof(string) })!; @@ -129,7 +128,7 @@ public static CursorPagedResult ApplyQueryCursorPagedResult ApplyQueryCursorPagedResult pageSize; + int count; - if (hasMore) + if (items.Count > pageSize) { - items.RemoveAt(items.Count - 1); - nextPageToken = CreateCompositeCursorToken(items[^1], sortFields); + count = pageSize; + nextPageToken = CreateCompositeCursorToken(items[pageSize - 1], sortFields); + var resultQuery = query.Take(pageSize); + return new CursorPagedResult(resultQuery, nextPageToken, count); } - return new CursorPagedResult(items.AsQueryable(), nextPageToken, items.Count); + count = items.Count; + return new CursorPagedResult(query.Take(count), nextPageToken, count); } /// @@ -163,47 +166,8 @@ public static IQueryable ApplySort(this IQueryable query, IQueryOptions if (string.IsNullOrWhiteSpace(queryOption.Sort)) return query; - var sortFields = queryOption.Sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var isFirstSort = true; - - foreach (var sort in sortFields) - { - if (string.IsNullOrWhiteSpace(sort)) - continue; - - var descending = sort.StartsWith("-"); - var sortBy = descending ? sort[1..] : sort; - var cacheKey = $"{typeof(T).FullName}_{sortBy}"; - var propertyInfo = s_PropertyCache.GetOrAdd(cacheKey, _ => typeof(T).GetProperty(sortBy, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)); - - if (propertyInfo == null) - continue; - - var parameter = Expression.Parameter(typeof(T), "entity"); - var property = Expression.Property(parameter, propertyInfo); - var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(typeof(T), propertyInfo.PropertyType), property, parameter); - - string methodName = (isFirstSort, descending) switch - { - (true, true) => "OrderByDescending", - (true, false) => "OrderBy", - (false, true) => "ThenByDescending", - (false, false) => "ThenBy" - }; - - var resultExpression = Expression.Call( - typeof(Queryable), - methodName, - [typeof(T), propertyInfo.PropertyType], - query.Expression, - lambda - ); - - query = query.Provider.CreateQuery(resultExpression); - isFirstSort = false; - } - - return query; + var sortFields = ParseSortFields(queryOption.Sort); + return ApplySort(query, sortFields); } /// @@ -232,33 +196,47 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged /// This private method provides the sorting implementation used by cursor pagination. /// It accepts a structured list of sort fields rather than a string expression. /// - private static IQueryable ApplySortFields(IQueryable query, List sortFields) + private static IQueryable ApplySort(IQueryable query, List sortFields) { if (sortFields.Count == 0) return query; - IOrderedQueryable? orderedQuery = null; + var isFirstSort = true; foreach (var sortField in sortFields) { + var cacheKey = $"{typeof(TData).FullName}_{sortField.PropertyName}"; + var propertyInfo = s_PropertyCache.GetOrAdd(cacheKey, _ => + typeof(TData).GetProperty(sortField.PropertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)); + + if (propertyInfo == null) + continue; + var parameter = Expression.Parameter(typeof(TData), "entity"); - var property = Expression.Property(parameter, sortField.PropertyName); - var lambda = Expression.Lambda(property, parameter); + var property = Expression.Property(parameter, propertyInfo); + var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(typeof(TData), propertyInfo.PropertyType), property, parameter); - var methodName = orderedQuery == null - ? (sortField.IsDescending ? "OrderByDescending" : "OrderBy") - : (sortField.IsDescending ? "ThenByDescending" : "ThenBy"); + string methodName = (isFirstSort, sortField.IsDescending) switch + { + (true, true) => "OrderByDescending", + (true, false) => "OrderBy", + (false, true) => "ThenByDescending", + (false, false) => "ThenBy" + }; - var cacheKey = $"{methodName}_{typeof(TData).FullName}_{property.Type.FullName}"; - var method = s_OrderByMethodCache.GetOrAdd(cacheKey, _ => - typeof(Queryable).GetMethods() - .First(m => m.Name == methodName && m.GetParameters().Length == 2) - .MakeGenericMethod(typeof(TData), property.Type)); + var resultExpression = Expression.Call( + typeof(Queryable), + methodName, + [typeof(TData), propertyInfo.PropertyType], + query.Expression, + lambda + ); - orderedQuery = (IOrderedQueryable)method.Invoke(null, new object[] { orderedQuery ?? query, lambda })!; + query = query.Provider.CreateQuery(resultExpression); + isFirstSort = false; } - return orderedQuery ?? query; + return query; } /// From 3a7287f4ec84d659cdb32435d1f251d033fd6c60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:59:34 +0000 Subject: [PATCH 16/16] Refactor for performance: unify sorting logic using Expression API, eliminate code duplication Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com> --- src/AutoQuery/Extensions/QueryExtensions.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 8e57de2..c64d645 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -136,22 +136,23 @@ public static CursorPagedResult ApplyQueryCursorPagedResult pageSize) { + items.RemoveAt(pageSize); count = pageSize; - nextPageToken = CreateCompositeCursorToken(items[pageSize - 1], sortFields); - var resultQuery = query.Take(pageSize); - return new CursorPagedResult(resultQuery, nextPageToken, count); + nextPageToken = CreateCompositeCursorToken(items[^1], sortFields); + } + else + { + count = items.Count; } - count = items.Count; - return new CursorPagedResult(query.Take(count), nextPageToken, count); + return new CursorPagedResult(items.AsQueryable(), nextPageToken, count); } ///