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. 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/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..c44dcce --- /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(IEnumerable Datas, string? NextPageToken, int Count); diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 3991f21..6927313 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -158,4 +158,102 @@ 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); + + // 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 sorting - cursor pagination requires consistent ordering + query = query.ApplySort(queryOption); + + // 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); + if (cursorValue == null) + throw new InvalidOperationException($"Cursor key value cannot be null for {typeof(TData).Name}."); + nextPageToken = PageToken.Encode(cursorValue); + } + + return new CursorPagedResult(items, nextPageToken, items.Count); + } + + /// + /// Applies cursor-based filtering to the query. + /// + private static IQueryable ApplyCursorFilter( + IQueryable query, + LambdaExpression cursorKeySelector, + string pageToken) + { + 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) + throw new InvalidOperationException("Decoded cursor value cannot be null."); + + // 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..1e4eb1a --- /dev/null +++ b/src/AutoQuery/PageToken.cs @@ -0,0 +1,68 @@ +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); + + 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) + { + 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..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; } 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); + } +}