diff --git a/README.md b/README.md index c5f2c05..1e19c42 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,126 @@ 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 +- **Flexible Sorting**: Supports sorting by any field using composite cursors + +### How It Works + +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: + +- 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 + +The cursor key is automatically added as a tie-breaker if not already in the sort expression, ensuring deterministic ordering. + +### 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. diff --git a/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs b/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs new file mode 100644 index 0000000..34e09d2 --- /dev/null +++ b/sample/AutoQueryApiDemo/Configurations/UserCursorQueryConfiguration.cs @@ -0,0 +1,19 @@ +using AutoQuery; +using AutoQuery.Abstractions; +using AutoQuery.Extensions; +using AutoQueryApiDemo.Models; + +namespace AutoQueryApiDemo.Configurations; + +public class UserCursorQueryConfiguration : IFilterQueryConfiguration +{ + public void Configure(FilterQueryBuilder builder) + { + builder.HasCursorKey(d => d.Id); + + 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..e7ce5b5 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() + .ApplyQueryCursorPagedResult(_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/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..416b292 --- /dev/null +++ b/src/AutoQuery/CursorPagedResult.cs @@ -0,0 +1,13 @@ +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 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 3991f21..c64d645 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,8 @@ public static class QueryExtensions { private static readonly ConcurrentDictionary s_PropertysCache = new(); private static readonly ConcurrentDictionary s_PropertyCache = new(); + private static readonly MethodInfo s_StringCompareMethod = + typeof(string).GetMethod(nameof(string.Compare), new[] { typeof(string), typeof(string) })!; /// /// Applies query conditions. @@ -84,6 +86,75 @@ public static PagedResult ApplyQueryPagedResult(thi return new PagedResult(query, page, totalPages, count); } + /// + /// Applies query conditions and cursor-based pagination. + /// + /// + /// 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. + /// The query object. + /// The query processor. + /// The query options. + /// The cursor-based paginated result. + public static CursorPagedResult ApplyQueryCursorPagedResult( + 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); + + var cursorKeySelector = queryProcessor.GetCursorKeySelector(); + if (cursorKeySelector == null) + throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration."); + + var sortFields = ParseSortFields(queryOption.Sort); + var cursorPropertyName = GetPropertyName(cursorKeySelector); + + if (!string.IsNullOrEmpty(cursorPropertyName) && + !sortFields.Any(sf => string.Equals(sf.PropertyName, cursorPropertyName, StringComparison.OrdinalIgnoreCase))) + { + sortFields.Add(new SortField { PropertyName = cursorPropertyName, IsDescending = false }); + } + + query = ApplySort(query, sortFields); + + if (!string.IsNullOrWhiteSpace(queryOption.PageToken)) + { + query = ApplyCompositeCursorFilter(query, sortFields, queryOption.PageToken); + } + + var pageSize = queryOption.PageSize ?? 10; + var items = query.Take(pageSize + 1).ToList(); + + string? nextPageToken = null; + int count; + + if (items.Count > pageSize) + { + items.RemoveAt(pageSize); + count = pageSize; + nextPageToken = CreateCompositeCursorToken(items[^1], sortFields); + } + else + { + count = items.Count; + } + + return new CursorPagedResult(items.AsQueryable(), nextPageToken, count); + } + /// /// Applies sorting to the query results. /// @@ -96,28 +167,57 @@ 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; + var sortFields = ParseSortFields(queryOption.Sort); + return ApplySort(query, sortFields); + } - foreach (var sort in sortFields) + /// + /// 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) { - if (string.IsNullOrWhiteSpace(sort)) - continue; + var page = queryOption.Page.HasValue ? queryOption.Page.Value : 1; + int skip = (page - 1) * queryOption.PageSize.Value; + query = query.Skip(skip).Take(queryOption.PageSize.Value); + } - 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; + } + + /// + /// Applies sort fields to query. + /// + /// + /// 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 ApplySort(IQueryable query, List sortFields) + { + if (sortFields.Count == 0) + return query; + + 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(T), "entity"); + var parameter = Expression.Parameter(typeof(TData), "entity"); var property = Expression.Property(parameter, propertyInfo); - var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), propertyInfo.PropertyType); - var lambda = Expression.Lambda(delegateType, property, parameter); + var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(typeof(TData), propertyInfo.PropertyType), property, parameter); - string methodName = (isFirstSort, descending) switch + string methodName = (isFirstSort, sortField.IsDescending) switch { (true, true) => "OrderByDescending", (true, false) => "OrderBy", @@ -128,12 +228,12 @@ public static IQueryable ApplySort(this IQueryable query, IQueryOptions var resultExpression = Expression.Call( typeof(Queryable), methodName, - [typeof(T), propertyInfo.PropertyType], + [typeof(TData), propertyInfo.PropertyType], query.Expression, lambda ); - query = query.Provider.CreateQuery(resultExpression); + query = query.Provider.CreateQuery(resultExpression); isFirstSort = false; } @@ -141,21 +241,185 @@ public static IQueryable ApplySort(this IQueryable query, IQueryOptions } /// - /// Applies pagination to the query results. + /// Gets the property name from a lambda expression. /// - /// 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) + private static string? GetPropertyName(LambdaExpression expression) { - if (queryOption.PageSize.HasValue) + if (expression.Body is MemberExpression memberExpression) { - 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 memberExpression.Member.Name; + } + + if (expression.Body is UnaryExpression unaryExpression && + unaryExpression.Operand is MemberExpression operandMember) + { + return operandMember.Member.Name; + } + + 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) + { + if (string.IsNullOrWhiteSpace(sortExpression)) + return new List(); + + var fields = sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var sortFields = new List(fields.Length); + + 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; + } + + /// + /// Creates a composite cursor token from the last item. + /// + private static string CreateCompositeCursorToken(TData lastItem, List sortFields) + { + var cursorValues = new Dictionary(sortFields.Count); + var typeName = typeof(TData).FullName!; + + foreach (var sortField in sortFields) + { + var cacheKey = $"{typeName}.{sortField.PropertyName}"; + var property = s_PropertyCache.GetOrAdd(cacheKey, _ => + typeof(TData).GetProperty(sortField.PropertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)); + + if (property != null) + { + cursorValues[sortField.PropertyName] = property.GetValue(lastItem); + } + } + + 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"); + Expression? filterExpression = null; + + for (int i = 0; i < sortFields.Count; i++) + { + Expression? currentCondition = null; + + 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); + } + + 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; + + if (currentProperty.Type == typeof(string)) + { + var compareCall = Expression.Call(s_StringCompareMethod, 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; + + 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/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..a8b83f6 --- /dev/null +++ b/src/AutoQuery/PageToken.cs @@ -0,0 +1,96 @@ +using System.Text; +using System.Text.Json; + +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)); + + return Convert.ToBase64String(Encoding.UTF8.GetBytes(valueString)); + } + + /// + /// Encodes multiple cursor values into an opaque page token. + /// + /// 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); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + + /// + /// 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 valueString = Encoding.UTF8.GetString(Convert.FromBase64String(pageToken)); + var underlyingType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + 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) + { + 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 json = Encoding.UTF8.GetString(Convert.FromBase64String(pageToken)); + return JsonSerializer.Deserialize>(json) + ?? throw new InvalidOperationException("Failed to deserialize page token"); + } + 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(); + } } diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index b5f445a..139b27a 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -411,3 +411,296 @@ 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 ApplyQueryCursorPagedResult_ShouldReturnFirstPage_WhenNoTokenProvided() + { + // Arrange + var queryOptions = new TestCursorQueryOptions { PageSize = 2 }; + + // Act + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + Assert.Equal(1, result.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPagedResult_ShouldReturnNextPage_WhenTokenProvided() + { + // Arrange + var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 }; + var firstPageResult = _testData.ApplyQueryCursorPagedResult(_queryProcessor, firstPageOptions); + + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + PageToken = firstPageResult.NextPageToken + }; + + // Act + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, secondPageOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal(3, result.Datas.First().Id); + } + + [Fact] + public void ApplyQueryCursorPagedResult_ShouldReturnNullNextToken_WhenNoMoreResults() + { + // Arrange + var queryOptions = new TestCursorQueryOptions { PageSize = 10 }; + + // Act + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); + + // Assert + Assert.Equal(5, result.Count); + Assert.Null(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPagedResult_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.ApplyQueryCursorPagedResult(queryProcessor, queryOptions)); + } + + [Fact] + public void ApplyQueryCursorPagedResult_ShouldApplyFilters_WithCursorPagination() + { + // Arrange + var queryOptions = new TestCursorQueryOptions + { + PageSize = 2, + Name = "Item 3" + }; + + // Act + var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); + + // Assert + Assert.Single(result.Datas); + Assert.Equal("Item 3", result.Datas.First().Name); + Assert.Null(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPagedResult_ShouldWorkWithCustomSorting() + { + // Arrange + var queryOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "id" + }; + + // Act + var firstPage = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions); + var secondPageOptions = new TestCursorQueryOptions + { + PageSize = 2, + Sort = "id", + PageToken = firstPage.NextPageToken + }; + var secondPage = _testData.ApplyQueryCursorPagedResult(_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 ApplyQueryCursorPagedResult_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.ApplyQueryCursorPagedResult(queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPagedResult_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.ApplyQueryCursorPagedResult(queryProcessor, queryOptions); + + // Assert + Assert.Equal(2, result.Count); + Assert.NotNull(result.NextPageToken); + } + + [Fact] + public void ApplyQueryCursorPagedResult_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.ApplyQueryCursorPagedResult(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.ApplyQueryCursorPagedResult(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.ApplyQueryCursorPagedResult(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; } + 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; } diff --git a/test/AutoQuery.Tests/PageTokenTests.cs b/test/AutoQuery.Tests/PageTokenTests.cs new file mode 100644 index 0000000..83c33f2 --- /dev/null +++ b/test/AutoQuery.Tests/PageTokenTests.cs @@ -0,0 +1,126 @@ +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); + } + + [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); + } +}