diff --git a/Old description.txt b/Old description.txt new file mode 100644 index 00000000000..800506fb736 --- /dev/null +++ b/Old description.txt @@ -0,0 +1,50 @@ +## 🧭 Paging Cursor Enhancements: Nullable Keys and Null Ordering Support + +### Summary + +This PR introduces enhancements to the paging logic to correctly handle sorting and filtering when cursor keys are nullable. The main change is that the `WHERE` condition used in pagination must now adapt based on **three factors**: + +1. Whether the **cursor key** is nullable. +2. Whether the **actual cursor value** is `null` or not. +3. Whether the **database** sorts `null` values **first** or **last**. + +--- + +### πŸ” Why This Is Needed + +When performing cursor-based pagination on fields that can be `null`, it's essential to construct the `WHERE` clause correctly. Incorrect assumptions about `null` ordering or the presence of `null` values can result in missing or duplicated records across pages. + +--- + +### πŸ”§ Implementation Details + +To support this behavior, the following changes were made: + +- **Cursor Key Metadata** + - Cursor keys now include a new property (`IsNullable`) to indicate whether they are nullable. + +- **Null Ordering in Cursor** + - Cursors now carry a `NullsFirst` flag to indicate how `null` values are ordered. + - For subsequent pages, this flag is **inherited** from the previous cursor. + - For the **first(last) page**, the flag is **calculated** based on whether the **first(last) item** contains a `null` value: + - If a `null` is found β†’ we can determine the null ordering: nulls first(nulls last). + - If not β†’ we assume the opposite ordering. + - If this assumption is incorrect, it does **not affect correctness**, because it implies there are **no `null` values** in the dataset, making null ordering irrelevant. + +> The concept of handling nullable types in relative cursor-based pagination is inspired by [[this StackOverflow discussion](https://stackoverflow.com/questions/68971695/cursor-pagination-prev-next-with-null-values)]. + +--- + +### βœ… Benefits + +- Correct pagination over nullable fields. +- Eliminates edge cases where `null` records might be skipped or duplicated. +- Supports both `nulls first` and `nulls last` configurations in the database. + +--- + +### ⚠️ Side Effects / Limitations + +If our assumption about null ordering is incorrect, **secondary ordering keys** may appear to be sorted in reverse (opposite to the database's actual null handling behavior), particularly when paginating **backward from the last page**. + +This only affects the **visual or logical order** of `null` values relative to each other and **does not break pagination**β€”all items will still be included in the correct pages. diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index 8e74d9da3d5..76e92032abe 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -40,7 +40,7 @@ internal static class ExpressionHelpers /// /// If the number of keys does not match the number of values. /// - public static (Expression> WhereExpression, int Offset) BuildWhereExpression( + public static Expression> BuildWhereExpression( ReadOnlySpan keys, Cursor cursor, bool forward) @@ -58,50 +58,195 @@ public static (Expression> WhereExpression, int Offset) BuildWhere var cursorExpr = new Expression[cursor.Values.Length]; for (var i = 0; i < cursor.Values.Length; i++) { - cursorExpr[i] = CreateParameter(cursor.Values[i], keys[i].Expression.ReturnType); + cursorExpr[i] = CreateParameter(cursor.Values[i], keys[i].CompareMethod.Type); } - var handled = new List(); Expression? expression = null; var parameter = Expression.Parameter(typeof(T), "t"); var zero = Expression.Constant(0); - for (var i = 0; i < keys.Length; i++) + for (var i = keys.Length - 1; i >= 0; i--) { var key = keys[i]; - Expression? current = null; - Expression keyExpr; - for (var j = 0; j < handled.Count; j++) + var greaterThan = forward + ? key.Direction == CursorKeyDirection.Ascending + : key.Direction == CursorKeyDirection.Descending; + + Expression keyExpr = ReplaceParameter(key.Expression, parameter); + + if (key.IsNullable) + { + if (expression is null) + { + throw new ArgumentException("The last key must be non-nullable.", nameof(keys)); + } + + // To avoid skipping any rows, NULL values are significant for the primary sorting condition. + // For all secondary sorting conditions, NULL values are treated as last, + // ensuring consistent behavior across different databases. + if (i == 0 && cursor.NullsFirst) + { + expression = BuildNullsFirstExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo); + } + else + { + expression = BuildNullsLastExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo); + } + } + else { - var handledKey = handled[j]; + expression = BuildNonNullExpression(expression, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo); + } + } - keyExpr = Expression.Equal( - Expression.Call(ReplaceParameter(handledKey.Expression, parameter), handledKey.CompareMethod, - cursorExpr[j]), zero); + return Expression.Lambda>(expression!, parameter); - current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr); + static Expression BuildNullsFirstExpression( + Expression previousExpr, + object? keyValue, + Expression keyExpr, + Expression cursorExpr, + bool greaterThan, + MethodInfo compareMethod) + { + Expression mainKeyExpr, secondaryKeyExpr; + + var zero = Expression.Constant(0); + var nullConstant = Expression.Constant(null, keyExpr.Type); + + if (keyValue is null) + { + if (greaterThan) + { + mainKeyExpr = Expression.Equal(keyExpr, nullConstant); + + secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant); + + return Expression.OrElse(secondaryKeyExpr, Expression.AndAlso(mainKeyExpr, previousExpr)); + } + else + { + mainKeyExpr = Expression.Equal(keyExpr, nullConstant); + + return Expression.AndAlso(mainKeyExpr, previousExpr); + } } + else + { + var nonNullKeyExpr = Expression.Property(keyExpr, "Value"); + var isNullExpression = Expression.Equal(keyExpr, nullConstant); + + mainKeyExpr = greaterThan + ? Expression.GreaterThan( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero) + : Expression.OrElse( + Expression.LessThan( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero), isNullExpression); + + secondaryKeyExpr = greaterThan + ? Expression.GreaterThanOrEqual( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero) + : Expression.OrElse( + Expression.LessThanOrEqual( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero), isNullExpression); + + return Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr)); + } + } - var greaterThan = forward - ? key.Direction == CursorKeyDirection.Ascending - : key.Direction == CursorKeyDirection.Descending; + static Expression BuildNullsLastExpression( + Expression previousExpr, + object? keyValue, + Expression keyExpr, + Expression cursorExpr, + bool greaterThan, + MethodInfo compareMethod) + { + Expression mainKeyExpr, secondaryKeyExpr; + + var zero = Expression.Constant(0); + var nullConstant = Expression.Constant(null, keyExpr.Type); + + if (keyValue is null) + { + if (greaterThan) + { + mainKeyExpr = Expression.Equal(keyExpr, nullConstant); + + return Expression.AndAlso(mainKeyExpr, previousExpr); + } + else + { + mainKeyExpr = Expression.Equal(keyExpr, nullConstant); - keyExpr = greaterThan + secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant); + + return Expression.OrElse(secondaryKeyExpr, Expression.AndAlso(mainKeyExpr, previousExpr)); + } + } + else + { + var nonNullKeyExpr = Expression.Property(keyExpr, "Value"); + var isNullExpression = Expression.Equal(keyExpr, nullConstant); + + mainKeyExpr = greaterThan + ? Expression.OrElse( + Expression.GreaterThan( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero), isNullExpression) + : Expression.LessThan( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero); + + secondaryKeyExpr = greaterThan + ? Expression.OrElse( + Expression.GreaterThanOrEqual( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero), isNullExpression) + : Expression.LessThanOrEqual( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero); + + return Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr)); + } + } + + static Expression BuildNonNullExpression( + Expression? previousExpr, + object? keyValue, + Expression keyExpr, + Expression cursorExpr, + bool greaterThan, + MethodInfo compareMethod) + { + var zero = Expression.Constant(0); + Expression mainKeyExpr, secondaryKeyExpr; + + mainKeyExpr = greaterThan ? Expression.GreaterThan( - Expression.Call(ReplaceParameter(key.Expression, parameter), key.CompareMethod, cursorExpr[i]), - zero) + Expression.Call(keyExpr, compareMethod, cursorExpr), + zero) : Expression.LessThan( - Expression.Call(ReplaceParameter(key.Expression, parameter), key.CompareMethod, cursorExpr[i]), + Expression.Call(keyExpr, compareMethod, cursorExpr), + zero); + + secondaryKeyExpr = greaterThan + ? Expression.GreaterThanOrEqual( + Expression.Call(keyExpr, compareMethod, cursorExpr), + zero) + : Expression.LessThanOrEqual( + Expression.Call(keyExpr, compareMethod, cursorExpr), zero); - current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr); - expression = expression is null ? current : Expression.OrElse(expression, current); - handled.Add(key); + return previousExpr is null ? mainKeyExpr : + Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr)); } - - return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0); } /// @@ -113,12 +258,6 @@ public static (Expression> WhereExpression, int Offset) BuildWhere /// /// The key definitions that represent the cursor. /// - /// - /// The order expressions that are used to sort the dataset. - /// - /// - /// The order methods that are used to sort the dataset. - /// /// /// Defines how the dataset is sorted. /// @@ -138,8 +277,6 @@ public static (Expression> WhereExpression, int Offset) BuildWhere public static BatchExpression BuildBatchExpression( PagingArguments arguments, ReadOnlySpan keys, - ReadOnlySpan orderExpressions, - ReadOnlySpan orderMethods, bool forward, ref int requestedCount) { @@ -150,33 +287,10 @@ public static BatchExpression BuildBatchExpression( nameof(keys)); } - if (orderExpressions.Length != orderMethods.Length) - { - throw new ArgumentException( - "The number of order expressions must match the number of order methods.", - nameof(orderExpressions)); - } - var group = Expression.Parameter(typeof(IGrouping), "g"); var groupKey = Expression.Property(group, "Key"); Expression source = group; - for (var i = 0; i < orderExpressions.Length; i++) - { - var methodName = forward ? orderMethods[i] : ReverseOrder(orderMethods[i]); - var orderExpression = orderExpressions[i]; - var delegateType = typeof(Func<,>).MakeGenericType(typeof(TV), orderExpression.Body.Type); - var typedOrderExpression = - Expression.Lambda(delegateType, orderExpression.Body, orderExpression.Parameters); - - var method = GetEnumerableMethod(methodName, typeof(TV), typedOrderExpression); - - source = Expression.Call( - method, - source, - typedOrderExpression); - } - var offset = 0; var usesRelativeCursors = false; Cursor? cursor = null; @@ -184,9 +298,8 @@ public static BatchExpression BuildBatchExpression( if (arguments.After is not null) { cursor = CursorParser.Parse(arguments.After, keys); - var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, forward: true); - source = Expression.Call(typeof(Enumerable), "Where", [typeof(TV)], source, whereExpr); - offset = cursorOffset; + source = ApplyCursorPagination(source, keys, cursor, forward: true); + offset = cursor.Offset ?? 0; if (cursor.IsRelative) { @@ -204,9 +317,8 @@ public static BatchExpression BuildBatchExpression( } cursor = CursorParser.Parse(arguments.Before, keys); - var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, forward: false); - source = Expression.Call(typeof(Enumerable), "Where", [typeof(TV)], source, whereExpr); - offset = cursorOffset; + source = ApplyCursorPagination(source, keys, cursor, forward: false); + offset = cursor.Offset ?? 0; } if (arguments.First is not null) @@ -279,22 +391,6 @@ public static BatchExpression BuildBatchExpression( Expression.Lambda, Group>>(createGroup, group), arguments.Last is not null, cursor); - - static string ReverseOrder(string method) - => method switch - { - nameof(Queryable.OrderBy) => nameof(Queryable.OrderByDescending), - nameof(Queryable.OrderByDescending) => nameof(Queryable.OrderBy), - nameof(Queryable.ThenBy) => nameof(Queryable.ThenByDescending), - nameof(Queryable.ThenByDescending) => nameof(Queryable.ThenBy), - _ => method - }; - - static MethodInfo GetEnumerableMethod(string methodName, Type elementType, LambdaExpression keySelector) - => typeof(Enumerable) - .GetMethods(BindingFlags.Static | BindingFlags.Public) - .First(m => m.Name == methodName && m.GetParameters().Length == 2) - .MakeGenericMethod(elementType, keySelector.Body.Type); } /// @@ -339,6 +435,110 @@ private static Expression ReplaceParameter( return visitor.Visit(expression.Body); } + public static IQueryable CursorPaginate( + this PrunedQuery prunedQuery, + CursorKey[] keys, + Cursor cursor, + bool forward) + { + var cursorPaginatedExpression = ApplyCursorPagination(prunedQuery.Expression, keys, cursor, forward); + return prunedQuery.Provider.CreateQuery(cursorPaginatedExpression); + } + + public static Expression ApplyCursorPagination( + Expression expression, + ReadOnlySpan keys, + Cursor cursor, + bool forward) + { + var whereExpr = BuildWhereExpression(keys, cursor, forward); + expression = Expression.Call(typeof(Enumerable), "Where", [typeof(T)], expression, whereExpr); + return expression.ApplyCursorKeyOrdering(keys, cursor.NullsFirst, forward); + } + + public static Expression ApplyCursorKeyOrdering( + this Expression expression, + ReadOnlySpan keys, + bool nullFirst, + bool forward) + { + // TODO: This method is far from finished. + // Should rebuild the order conditions based on the keys. + + if (keys.Length == 0) + { + return expression; + } + + //static string ReverseOrder(string method) => method switch + //{ + // nameof(Queryable.OrderBy) => nameof(Queryable.OrderByDescending), + // nameof(Queryable.OrderByDescending) => nameof(Queryable.OrderBy), + // nameof(Queryable.ThenBy) => nameof(Queryable.ThenByDescending), + // nameof(Queryable.ThenByDescending) => nameof(Queryable.ThenBy), + // _ => method + //}; + + static MethodInfo GetEnumerableMethod(string methodName, Type elementType, LambdaExpression keySelector) + => typeof(Enumerable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .First(m => m.Name == methodName && m.GetParameters().Length == 2) + .MakeGenericMethod(elementType, keySelector.Body.Type); + + //for (var i = 0; i < orderExpressions.Length; i++) + //{ + // var methodName = forward ? orderMethods[i] : ReverseOrder(orderMethods[i]); + // var orderExpression = orderExpressions[i]; + // var delegateType = typeof(Func<,>).MakeGenericType(typeof(TV), orderExpression.Body.Type); + // var typedOrderExpression = + // Expression.Lambda(delegateType, orderExpression.Body, orderExpression.Parameters); + + // var method = GetEnumerableMethod(methodName, typeof(TV), typedOrderExpression); + + // source = Expression.Call( + // method, + // source, + // typedOrderExpression); + //} + + Expression? orderedExpression = null; + var parameter = Expression.Parameter(typeof(T), "t"); + + foreach (var key in keys) + { + var body = Expression.Invoke(key.Expression, parameter); + + if (key.IsNullable) + { + var nullCheck = Expression.Equal(body, Expression.Constant(null)); + var nullCheckLambda = Expression.Lambda(nullCheck, parameter); + + orderedExpression = orderedExpression == null + ? Expression.Call(typeof(Queryable), "OrderBy", [typeof(T), typeof(bool)], expression, nullCheckLambda) + : Expression.Call(typeof(Queryable), "ThenBy", [typeof(T), typeof(bool)], orderedExpression, nullCheckLambda); + } + + var methodName = key.Direction == CursorKeyDirection.Ascending ? "OrderBy" : "OrderByDescending"; + + var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), key.Expression.Body.Type); + var typedOrderExpression = Expression.Lambda(delegateType, key.Expression.Body, key.Expression.Parameters); + var method = GetEnumerableMethod(methodName, typeof(T), typedOrderExpression); + + orderedExpression = orderedExpression == null + ? Expression.Call(method, expression, typedOrderExpression) + : Expression.Call(method, orderedExpression, typedOrderExpression); + } + + return orderedExpression ?? expression; + } + + public class PrunedQuery(Expression expression, IQueryProvider provider) + { + public Expression Expression { get; } = expression; + + public IQueryProvider Provider { get; } = provider; + } + private class ReplaceParameterVisitor(ParameterExpression parameter, Expression replacement) : ExpressionVisitor { diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index aaeed0c4dd1..f389abc797b 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -79,7 +79,7 @@ public static async ValueTask> ToPageAsync( source = QueryHelpers.EnsureOrderPropsAreSelected(source); - var keys = ParseDataSetKeys(source); + var (keys, prounedQuery) = ExtractDataSetKeys(source); if (keys.Length == 0) { @@ -120,9 +120,8 @@ public static async ValueTask> ToPageAsync( if (arguments.After is not null) { cursor = CursorParser.Parse(arguments.After, keys); - var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, true); - source = source.Where(whereExpr); - offset = cursorOffset; + source = prounedQuery.CursorPaginate(keys, cursor, forward: true); + offset = cursor.Offset ?? 0; if (!includeTotalCount) { @@ -145,9 +144,8 @@ public static async ValueTask> ToPageAsync( } cursor = CursorParser.Parse(arguments.Before, keys); - var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, false); - source = source.Where(whereExpr); - offset = cursorOffset; + source = prounedQuery.CursorPaginate(keys, cursor, forward: false); + offset = cursor.Offset ?? 0; if (!includeTotalCount) { @@ -166,13 +164,6 @@ public static async ValueTask> ToPageAsync( } } - var isBackward = arguments.Last is not null; - - if (isBackward) - { - source = ReverseOrderExpressionRewriter.Rewrite(source); - } - var absOffset = Math.Abs(offset); if (absOffset > 0) @@ -228,14 +219,9 @@ public static async ValueTask> ToPageAsync( return Page.Empty; } - if (isBackward) - { - builder.Reverse(); - } - if (builder.Count > requestedCount) { - builder.RemoveAt(isBackward ? 0 : requestedCount); + builder.RemoveAt(requestedCount); } var pageIndex = CreateIndex(arguments, cursor, totalCount); @@ -412,7 +398,7 @@ public static async ValueTask>> ToBatchPageAsync>> ToBatchPageAsync? counts = null; if (includeTotalCount) { @@ -455,14 +436,12 @@ public static async ValueTask>> ToBatchPageAsync( arguments, keys, - ordering.OrderExpressions, - ordering.OrderMethods, forward, ref requestedCount); var map = new Dictionary>(); // we apply our new expression here. - source = source.Provider.CreateQuery(ordering.Expression); + source = source.Provider.CreateQuery(prounedQuery.Expression); TryGetQueryInterceptor()?.OnBeforeExecute(source.GroupBy(keySelector).Select(batchExpression.SelectExpression)); @@ -614,7 +593,7 @@ private static Page CreatePage( items, hasNext, hasPrevious, - (item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(o, p, c)), + (item, n, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(n, o, p, c)), index ?? 1, requestedPageSize.Value, totalCount.Value); @@ -672,11 +651,11 @@ private static Page CreatePage( return null; } - private static CursorKey[] ParseDataSetKeys(IQueryable source) + private static (CursorKey[] keys, PrunedQuery prunedQuery) ExtractDataSetKeys(IQueryable source) { - var parser = new CursorKeyParser(); - parser.Visit(source.Expression); - return [.. parser.Keys]; + var parser = new CursorKeyExtractor(); + var prunedExpression = parser.Visit(source.Expression); + return ([.. parser.Keys], new PrunedQuery(prunedExpression, source.Provider)); } private sealed class InterceptorHolder diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs index b8d79d9a91a..d1d3c835ec8 100644 --- a/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs +++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs @@ -14,7 +14,7 @@ public sealed class Page : IEnumerable private readonly ImmutableArray _items; private readonly bool _hasNextPage; private readonly bool _hasPreviousPage; - private readonly Func _createCursor; + private readonly Func _createCursor; private readonly int? _requestedPageSize; private readonly int? _index; private readonly int? _totalCount; @@ -47,7 +47,7 @@ public Page( _items = items; _hasNextPage = hasNextPage; _hasPreviousPage = hasPreviousPage; - _createCursor = (item, _, _, _) => createCursor(item); + _createCursor = (item, _, _, _, _) => createCursor(item); _totalCount = totalCount; } @@ -79,7 +79,7 @@ internal Page( ImmutableArray items, bool hasNextPage, bool hasPreviousPage, - Func createCursor, + Func createCursor, int index, int requestedPageSize, int totalCount) @@ -144,7 +144,7 @@ internal Page( /// /// Returns a cursor for the item. /// - public string CreateCursor(T item) => _createCursor(item, 0, 0, 0); + public string CreateCursor(T item) => _createCursor(item, false, 0, 0, 0); public string CreateCursor(T item, int offset) { @@ -153,7 +153,7 @@ public string CreateCursor(T item, int offset) throw new InvalidOperationException("This page does not allow relative cursors."); } - return _createCursor(item, offset, _index ?? 1, _totalCount ?? 0); + return _createCursor(item, false, offset, _index ?? 1, _totalCount ?? 0); } /// diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs index 0f5deee7605..cf02c7bbbff 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs @@ -19,8 +19,12 @@ namespace GreenDonut.Data.Cursors; /// /// The total number of items in the dataset, if known. Can be null if not available. /// +/// +/// Determines whether null values should appear first in the sort order. +/// public record Cursor( ImmutableArray Values, + bool NullsFirst = false, int? Offset = null, int? PageIndex = null, int? TotalCount = null) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorFormatter.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorFormatter.cs index b507e3ffd2c..44d6ab86fce 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorFormatter.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorFormatter.cs @@ -55,7 +55,7 @@ public static string Format(T entity, CursorKey[] keys, CursorPageInfo pageIn var totalWritten = 0; var first = true; - if (pageInfo.TotalCount == 0) + if (pageInfo.TotalCount == 0 && pageInfo.NullsFirst == false) { span[totalWritten++] = (byte)'{'; span[totalWritten++] = (byte)'}'; @@ -63,6 +63,8 @@ public static string Format(T entity, CursorKey[] keys, CursorPageInfo pageIn else { WriteCharacter('{', ref span, ref poolArray, ref totalWritten); + WriteNumber(pageInfo.NullsFirst ? 1 : 0, ref span, ref poolArray, ref totalWritten); + WriteCharacter('|', ref span, ref poolArray, ref totalWritten); WriteNumber(pageInfo.Offset, ref span, ref poolArray, ref totalWritten); WriteCharacter('|', ref span, ref poolArray, ref totalWritten); WriteNumber(pageInfo.PageIndex, ref span, ref poolArray, ref totalWritten); diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs index 522aaa939c9..f843f0139da 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs @@ -1,5 +1,4 @@ using System.Linq.Expressions; -using System.Reflection; using GreenDonut.Data.Cursors.Serializers; namespace GreenDonut.Data.Cursors; @@ -31,7 +30,12 @@ public sealed class CursorKey( /// /// Gets the compare method that is applicable to the key value. /// - public MethodInfo CompareMethod { get; } = serializer.GetCompareToMethod(expression.ReturnType); + public CursorKeyCompareMethod CompareMethod { get; } = serializer.GetCompareToMethod(expression.ReturnType); + + /// + /// Gets a value indicating whether the key value is nullable. + /// + public bool IsNullable { get; } = serializer.IsNullable(expression.ReturnType); /// /// Gets a value defining the sort direction of this key in dataset. @@ -66,7 +70,17 @@ public sealed class CursorKey( public bool TryFormat(object entity, Span buffer, out int written) => CursorKeySerializerHelper.TryFormat(GetValue(entity), serializer, buffer, out written); - private object? GetValue(object entity) + /// + /// Extracts the key value from the provided entity by compiling and invoking + /// the lambda expression associated with this cursor key. + /// + /// + /// The entity from which the key value should be extracted. + /// + /// + /// The extracted key value, or null if the value cannot be determined. + /// + public object? GetValue(object entity) { _compiled ??= Expression.Compile(); return _compiled.DynamicInvoke(entity); diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyCompareMethod.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyCompareMethod.cs new file mode 100644 index 00000000000..edfdd646901 --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyCompareMethod.cs @@ -0,0 +1,23 @@ +using System.Reflection; + +namespace GreenDonut.Data.Cursors; + +/// +/// Represents a method used to compare cursor keys. +/// +/// The for the comparison method. +/// The that the method belongs to. +public sealed class CursorKeyCompareMethod( + MethodInfo methodInfo, + Type type) +{ + /// + /// Gets the for the comparison method. + /// + public MethodInfo MethodInfo { get; } = methodInfo; + + /// + /// Gets the that the method belongs to. + /// + public Type Type { get; } = type; +} diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyExtractor.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyExtractor.cs new file mode 100644 index 00000000000..ac89cf0efeb --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyExtractor.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; + +namespace GreenDonut.Data.Cursors; + +/// +/// This expression visitor traverses a query expression, collects cursor keys, and removes OrderBy nodes. +/// If a cursor key cannot be generated, the OrderBy is still removed. +/// +public sealed class CursorKeyExtractor : ExpressionVisitor +{ + private readonly List _keys = []; + + public IReadOnlyList Keys => _keys; + + protected override Expression VisitExtension(Expression node) + => node.CanReduce ? base.VisitExtension(node) : node; + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (IsOrderBy(node) || IsThenBy(node)) + { + PushProperty(node); + return Visit(node.Arguments[0]); + } + else if (IsOrderByDescending(node) || IsThenByDescending(node)) + { + PushProperty(node, CursorKeyDirection.Descending); + return Visit(node.Arguments[0]); + } + + return base.VisitMethodCall(node); + } + + private static bool IsOrderBy(MethodCallExpression node) + => IsMethod(node, nameof(Queryable.OrderBy), typeof(Queryable)) + || IsMethod(node, nameof(Enumerable.OrderBy), typeof(Enumerable)); + + private static bool IsThenBy(MethodCallExpression node) + => IsMethod(node, nameof(Queryable.ThenBy), typeof(Queryable)) + || IsMethod(node, nameof(Enumerable.ThenBy), typeof(Enumerable)); + + private static bool IsOrderByDescending(MethodCallExpression node) + => IsMethod(node, nameof(Queryable.OrderByDescending), typeof(Queryable)) + || IsMethod(node, nameof(Enumerable.OrderByDescending), typeof(Enumerable)); + + private static bool IsThenByDescending(MethodCallExpression node) + => IsMethod(node, nameof(Queryable.ThenByDescending), typeof(Queryable)) + || IsMethod(node, nameof(Enumerable.ThenByDescending), typeof(Enumerable)); + + private static bool IsMethod(MethodCallExpression node, string name, Type declaringType) + => node.Method.DeclaringType == declaringType && node.Method.Name.Equals(name, StringComparison.Ordinal); + + private void PushProperty(MethodCallExpression node, CursorKeyDirection direction = CursorKeyDirection.Ascending) + { + if (TryExtractProperty(node, out var expression)) + { + var serializer = CursorKeySerializerRegistration.Find(expression.ReturnType); + _keys.Insert(0, new CursorKey(expression, serializer, direction)); + } + } + + private static bool TryExtractProperty( + MethodCallExpression node, + [NotNullWhen(true)] out LambdaExpression? expression) + { + if (node.Arguments is [_, UnaryExpression { Operand: LambdaExpression l }]) + { + expression = l; + return true; + } + + if (node.Arguments is [_, LambdaExpression l1]) + { + expression = l1; + return true; + } + + expression = null; + return false; + } +} diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs index 1d4f7e349d9..3771f9fc5e1 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs @@ -8,13 +8,23 @@ public readonly ref struct CursorPageInfo /// /// Initializes a new instance of the struct. /// + /// Determines whether null values should appear first in the sort order. + public CursorPageInfo(bool nullsFirst) + { + NullsFirst = nullsFirst; + } + + /// + /// Initializes a new instance of the struct. + /// + /// Determines whether null values should appear first in the sort order. /// Offset indicating the number of items/pages skipped. /// The zero-based index of the current page. /// Total number of items available in the dataset. /// /// Thrown if an offset greater than zero is specified with a totalCount of zero. /// - public CursorPageInfo(int offset, int pageIndex, int totalCount) + public CursorPageInfo(bool nullsFirst, int offset, int pageIndex, int totalCount) { ArgumentOutOfRangeException.ThrowIfNegative(pageIndex); ArgumentOutOfRangeException.ThrowIfNegative(totalCount); @@ -29,6 +39,7 @@ public CursorPageInfo(int offset, int pageIndex, int totalCount) Offset = offset; PageIndex = pageIndex; TotalCount = totalCount; + NullsFirst = nullsFirst; } /// @@ -47,24 +58,32 @@ public CursorPageInfo(int offset, int pageIndex, int totalCount) /// public int TotalCount { get; } + /// + /// Determines whether null values should appear first in the sort order. + /// + public bool NullsFirst { get; } + /// /// Deconstructs the into individual components. /// + /// The nulls first order if no valid data or false and nulls last order for true. /// The offset, or null if no valid data. /// The page number, or null if no valid data. /// The total count, or null if no valid data. - public void Deconstruct(out int? offset, out int? pageIndex, out int? totalCount) + public void Deconstruct(out bool nullsFirst, out int? offset, out int? pageIndex, out int? totalCount) { if (TotalCount == 0) { offset = null; pageIndex = null; totalCount = null; + nullsFirst = false; return; } offset = Offset; pageIndex = PageIndex; totalCount = TotalCount; + nullsFirst = NullsFirst; } } diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorParser.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorParser.cs index bdbf92a2f61..08a73027980 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorParser.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorParser.cs @@ -54,7 +54,7 @@ public static Cursor Parse(string cursor, ReadOnlySpan keys) var start = 0; var end = 0; var parsedCursor = new object?[keys.Length]; - var (offset, page, totalCount) = ParsePageInfo(ref bufferSpan); + var (nullesFirst, offset, page, totalCount) = ParsePageInfo(ref bufferSpan); for (var current = 0; current < bufferSpan.Length; current++) { @@ -83,7 +83,7 @@ public static Cursor Parse(string cursor, ReadOnlySpan keys) } ArrayPool.Shared.Return(buffer); - return new Cursor(parsedCursor.ToImmutableArray(), offset, page, totalCount); + return new Cursor(parsedCursor.ToImmutableArray(), nullesFirst, offset, page, totalCount); static bool CanParse(byte code, int pos, ReadOnlySpan buffer) { @@ -133,9 +133,14 @@ private static CursorPageInfo ParsePageInfo(ref Span span) var separatorIndex = ExpectSeparator(span, Separator); var part = span[..separatorIndex]; - ParseNumber(part, out var offset, out var consumed); + ParseNumber(part, out var nullsFirst, out var consumed); var start = separatorIndex + 1; + separatorIndex = ExpectSeparator(span[start..], Separator); + part = span.Slice(start, separatorIndex); + ParseNumber(part, out var offset, out consumed); + start += separatorIndex + 1; + separatorIndex = ExpectSeparator(span[start..], Separator); part = span.Slice(start, separatorIndex); ParseNumber(part, out var page, out consumed); @@ -149,7 +154,7 @@ private static CursorPageInfo ParsePageInfo(ref Span span) // Advance span beyond closing `}` span = span[start..]; - return new CursorPageInfo(offset, page, totalCount); + return new CursorPageInfo(nullsFirst > 0, offset, page, totalCount); static void ParseNumber(ReadOnlySpan span, out int value, out int consumed) { diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs index d7dfda0b1a2..98bae22f1dc 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class BoolCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(bool); + => type == typeof(bool) || type == typeof(bool?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(bool?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/CompareToResolver.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/CompareToResolver.cs index 84da6482632..94d76eaa3d4 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/CompareToResolver.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/CompareToResolver.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Reflection; using static System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes; namespace GreenDonut.Data.Cursors.Serializers; @@ -8,9 +7,9 @@ internal static class CompareToResolver { private const string CompareTo = "CompareTo"; - public static MethodInfo GetCompareToMethod<[DynamicallyAccessedMembers(PublicMethods)] T>() + public static CursorKeyCompareMethod GetCompareToMethod<[DynamicallyAccessedMembers(PublicMethods)] T>() => GetCompareToMethod(typeof(T)); - private static MethodInfo GetCompareToMethod([DynamicallyAccessedMembers(PublicMethods)] Type type) - => type.GetMethod(CompareTo, [type])!; + private static CursorKeyCompareMethod GetCompareToMethod([DynamicallyAccessedMembers(PublicMethods)] Type type) + => new CursorKeyCompareMethod(type.GetMethod(CompareTo, [type])!, type); } diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateOnlyCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateOnlyCursorKeySerializer.cs index 0f60548f71e..66e14d37bf9 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateOnlyCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateOnlyCursorKeySerializer.cs @@ -1,20 +1,22 @@ using System.Buffers; -using System.Reflection; using System.Text.Unicode; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DateOnlyCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); private const string DateFormat = "yyyyMMdd"; public bool IsSupported(Type type) - => type == typeof(DateOnly); + => type == typeof(DateOnly) || type == typeof(DateOnly?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(DateOnly?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeCursorKeySerializer.cs index 96e0b1bf929..b3270717955 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeCursorKeySerializer.cs @@ -1,21 +1,23 @@ using System.Buffers; using System.Globalization; -using System.Reflection; using System.Text.Unicode; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DateTimeCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); private const string DateTimeFormat = "yyyyMMddHHmmssfffffff"; public bool IsSupported(Type type) - => type == typeof(DateTime); + => type == typeof(DateTime) || type == typeof(DateTime?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(DateTime?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeOffsetCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeOffsetCursorKeySerializer.cs index 7f58f5a8611..1694aaab423 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeOffsetCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeOffsetCursorKeySerializer.cs @@ -1,22 +1,24 @@ using System.Buffers; using System.Globalization; -using System.Reflection; using System.Text.Unicode; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DateTimeOffsetCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); private const string DateTimeFormat = "yyyyMMddHHmmssfffffff"; private const string OffsetFormat = "hhmm"; public bool IsSupported(Type type) - => type == typeof(DateTimeOffset); + => type == typeof(DateTimeOffset) || type == typeof(DateTimeOffset?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(DateTimeOffset?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DecimalCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DecimalCursorKeySerializer.cs index 8a6988c30d7..50098901c22 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DecimalCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DecimalCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DecimalCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(decimal); + => type == typeof(decimal) || type == typeof(decimal?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(decimal?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DoubleCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DoubleCursorKeySerializer.cs index f2bbba889b6..6bd39ff5783 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DoubleCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DoubleCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DoubleCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(double); + => type == typeof(double) || type == typeof(double?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(double?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/FloatCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/FloatCursorKeySerializer.cs index c55f988d7a6..c305a54ddbd 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/FloatCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/FloatCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class FloatCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(float); + => type == typeof(float) || type == typeof(float?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(float?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/GuidCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/GuidCursorKeySerializer.cs index 9174ee67b3d..8f5f18ed84d 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/GuidCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/GuidCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class GuidCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(Guid); + => type == typeof(Guid) || type == typeof(Guid?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(Guid?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ICursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ICursorKeySerializer.cs index 1cdd665780a..3833ab4cfb3 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ICursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ICursorKeySerializer.cs @@ -1,12 +1,12 @@ -using System.Reflection; - namespace GreenDonut.Data.Cursors.Serializers; public interface ICursorKeySerializer { bool IsSupported(Type type); - MethodInfo GetCompareToMethod(Type type); + bool IsNullable(Type type); + + CursorKeyCompareMethod GetCompareToMethod(Type type); object Parse(ReadOnlySpan formattedKey); diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/IntCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/IntCursorKeySerializer.cs index d33eee4b2a2..408fc378149 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/IntCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/IntCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class IntCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(int); + => type == typeof(int) || type == typeof(int?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(int?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/LongCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/LongCursorKeySerializer.cs index c798c778fd6..86b4fbd12d7 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/LongCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/LongCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class LongCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(long); + => type == typeof(long) || type == typeof(long?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(long?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ShortCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ShortCursorKeySerializer.cs index e5e4208de6a..bc878b5c2c1 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ShortCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ShortCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class ShortCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(short); + => type == typeof(short) || type == typeof(short?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(short?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/StringCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/StringCursorKeySerializer.cs index 558c101dce6..828175d7e6c 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/StringCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/StringCursorKeySerializer.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Text; namespace GreenDonut.Data.Cursors.Serializers; @@ -6,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class StringCursorKeySerializer : ICursorKeySerializer { private static readonly Encoding s_encoding = Encoding.UTF8; - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) => type == typeof(string); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => false; + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/TimeOnlyCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/TimeOnlyCursorKeySerializer.cs index b3057c6c234..96e4a2087fe 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/TimeOnlyCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/TimeOnlyCursorKeySerializer.cs @@ -1,20 +1,22 @@ using System.Buffers; -using System.Reflection; using System.Text.Unicode; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class TimeOnlyCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); private const string TimeFormat = "HHmmssfffffff"; public bool IsSupported(Type type) - => type == typeof(TimeOnly); + => type == typeof(TimeOnly) || type == typeof(TimeOnly?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(TimeOnly?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UIntCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UIntCursorKeySerializer.cs index 668874d013e..3972f1744ec 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UIntCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UIntCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class UIntCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(uint); + => type == typeof(uint) || type == typeof(uint?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(uint?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ULongCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ULongCursorKeySerializer.cs index 4dd8f560246..81afa04b935 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ULongCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ULongCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class ULongCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(ulong); + => type == typeof(ulong) || type == typeof(ulong?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(ulong?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UShortCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UShortCursorKeySerializer.cs index b7db336fa36..49b69260d1e 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UShortCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UShortCursorKeySerializer.cs @@ -1,16 +1,18 @@ using System.Buffers.Text; -using System.Reflection; namespace GreenDonut.Data.Cursors.Serializers; internal sealed class UShortCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo s_compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod s_compareTo = CompareToResolver.GetCompareToMethod(); public bool IsSupported(Type type) - => type == typeof(ushort); + => type == typeof(ushort) || type == typeof(ushort?); - public MethodInfo GetCompareToMethod(Type type) + public bool IsNullable(Type type) + => type == typeof(ushort?); + + public CursorKeyCompareMethod GetCompareToMethod(Type type) => s_compareTo; public object Parse(ReadOnlySpan formattedKey) diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs index 4a15e6ba395..675fdec5287 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs @@ -48,6 +48,132 @@ 6. Futurova .MatchSnapshot(); } + [Fact] + public async Task Fetch_Second_Page_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara <- Page 2 - Item 1 + 4. Dynamova <- Page 2 - Item 2 + 5. Evolvance + 6. Futurova + */ + + Snapshot.Create() + .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 2, + "TotalCount": 20, + "Items": [ + "Celestara", + "Dynamova" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='01/02/2025' (DbType = Date) + -- @__value_2='2' + -- @__p_3='3' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" >= @__value_0 AND (b."CreatedOn" > @__value_0 OR ((b."ModifiedOn" >= @__value_1 OR b."ModifiedOn" IS NULL) AND (b."ModifiedOn" > @__value_1 OR b."ModifiedOn" IS NULL OR b."Id" > @__value_2))) + ORDER BY b."CreatedOn", b."ModifiedOn", b."Id" + LIMIT @__p_3 + --------------- + + """); + } + + [Fact] + public async Task Fetch_Fourth_Page_With_Offset_1_Ordering_By_Nullable_Date_Column() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = second.CreateCursor(second.Last!, 1) }; + var fourth = await context.Brands.OrderBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex + 3. Celestara + 4. Evolvance <- Cursor + 5. Futurova + 6. Glacient + 7. Innovexa <- Page 4 - Item 1 + 8. Joventra <- Page 4 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 4, + "TotalCount": 20, + "Items": [ + "Innovexa", + "Joventra" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/02/2025' (DbType = Date) + -- @__value_1='5' + -- @__p_3='3' + -- @__p_2='2' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE (b."ModifiedOn" >= @__value_0 OR b."ModifiedOn" IS NULL) AND (b."ModifiedOn" > @__value_0 OR b."ModifiedOn" IS NULL OR b."Id" > @__value_1) + ORDER BY b."ModifiedOn", b."Id" + LIMIT @__p_3 OFFSET @__p_2 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Second_Page() { @@ -85,6 +211,80 @@ 6. Futurova .MatchSnapshot(); } + [Fact] + public async Task BatchFetch_Second_Page_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var second = map[1]; + + // Assert + + /* + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara <- Page 2 - Item 1 + 4. Dynamova <- Page 2 - Item 2 + 5. Evolvance + 6. Futurova + */ + + Snapshot.Create() + .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 2, + "TotalCount": 20, + "Items": [ + "Celestara", + "Dynamova" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='01/02/2025' (DbType = Date) + -- @__value_2='2' + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" + FROM ( + SELECT b."GroupId" + FROM "Brands" AS b + WHERE b."GroupId" = 1 + GROUP BY b."GroupId" + ) AS b1 + LEFT JOIN ( + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" + FROM ( + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."CreatedOn", b0."ModifiedOn", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."CreatedOn" >= @__value_0 AND (b0."CreatedOn" > @__value_0 OR ((b0."ModifiedOn" >= @__value_1 OR b0."ModifiedOn" IS NULL) AND (b0."ModifiedOn" > @__value_1 OR b0."ModifiedOn" IS NULL OR b0."Id" > @__value_2))) + ) AS b2 + WHERE b2.row <= 3 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn", b3."ModifiedOn", b3."Id" + --------------- + + """); + } + [Fact] public async Task Fetch_Third_Page_With_Offset_1() { @@ -120,6 +320,68 @@ 6. Futurova <- Page 3 - Item 2 .MatchSnapshot(); } + [Fact] + public async Task Fetch_Third_Page_With_Offset_1_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + var second = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara + 4. Dynamova + 5. Evolvance <- Page 3 - Item 1 + 6. Futurova <- Page 3 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 3, + "TotalCount": 20, + "Items": [ + "Evolvance", + "Futurova" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='01/02/2025' (DbType = Date) + -- @__value_2='2' + -- @__p_4='3' + -- @__p_3='2' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" >= @__value_0 AND (b."CreatedOn" > @__value_0 OR ((b."ModifiedOn" >= @__value_1 OR b."ModifiedOn" IS NULL) AND (b."ModifiedOn" > @__value_1 OR b."ModifiedOn" IS NULL OR b."Id" > @__value_2))) + ORDER BY b."CreatedOn", b."ModifiedOn", b."Id" + LIMIT @__p_4 OFFSET @__p_3 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Third_Page_With_Offset_1() { @@ -157,6 +419,80 @@ 6. Futurova <- Page 3 - Item 2 .MatchSnapshot(); } + [Fact] + public async Task BatchFetch_Third_Page_With_Offset_1_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var second = map[1]; + + // Assert + + /* + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara + 4. Dynamova + 5. Evolvance <- Page 3 - Item 1 + 6. Futurova <- Page 3 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 3, + "TotalCount": 20, + "Items": [ + "Evolvance", + "Futurova" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='01/02/2025' (DbType = Date) + -- @__value_2='2' + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" + FROM ( + SELECT b."GroupId" + FROM "Brands" AS b + WHERE b."GroupId" = 1 + GROUP BY b."GroupId" + ) AS b1 + LEFT JOIN ( + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" + FROM ( + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."CreatedOn", b0."ModifiedOn", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."CreatedOn" >= @__value_0 AND (b0."CreatedOn" > @__value_0 OR ((b0."ModifiedOn" >= @__value_1 OR b0."ModifiedOn" IS NULL) AND (b0."ModifiedOn" > @__value_1 OR b0."ModifiedOn" IS NULL OR b0."Id" > @__value_2))) + ) AS b2 + WHERE 2 < b2.row AND b2.row <= 5 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn", b3."ModifiedOn", b3."Id" + --------------- + + """); + } + [Fact] public async Task Fetch_Fourth_Page_With_Offset_1() { @@ -196,6 +532,71 @@ 8. Hyperionix <- Page 4 - Item 2 .MatchSnapshot(); } + [Fact] + public async Task Fetch_Fourth_Page_With_Offset_1_Ordering_By_Nullable_Columns_NULL_Cursor() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = second.CreateCursor(second.Last!, 1) }; + var fourth = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex + 3. Celestara + 4. Dynamova <- NULL Cursor + 5. Evolvance + 6. Futurova + 7. Glacient <- Page 4 - Item 1 + 8. Hyperionix <- Page 4 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 4, + "TotalCount": 20, + "Items": [ + "Glacient", + "Hyperionix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='4' + -- @__p_3='3' + -- @__p_2='2' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" >= @__value_0 AND (b."CreatedOn" > @__value_0 OR (b."ModifiedOn" IS NULL AND b."Id" > @__value_1)) + ORDER BY b."CreatedOn", b."ModifiedOn", b."Id" + LIMIT @__p_3 OFFSET @__p_2 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Fourth_Page_With_Offset_1() { @@ -237,6 +638,83 @@ 8. Hyperionix <- Page 4 - Item 2 .MatchSnapshot(); } + [Fact] + public async Task BatchFetch_Fourth_Page_With_Offset_1_Ordering_By_Nullable_Columns_NULL_Cursor() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = second.CreateCursor(second.Last!, 1) }; + var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var fourth = map[1]; + + // Assert + + /* + 1. Aetherix + 2. Brightex + 3. Celestara + 4. Dynamova <- NULL Cursor + 5. Evolvance + 6. Futurova + 7. Glacient <- Page 4 - Item 1 + 8. Hyperionix <- Page 4 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 4, + "TotalCount": 20, + "Items": [ + "Glacient", + "Hyperionix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='4' + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" + FROM ( + SELECT b."GroupId" + FROM "Brands" AS b + WHERE b."GroupId" = 1 + GROUP BY b."GroupId" + ) AS b1 + LEFT JOIN ( + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" + FROM ( + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."CreatedOn", b0."ModifiedOn", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."CreatedOn" >= @__value_0 AND (b0."CreatedOn" > @__value_0 OR (b0."ModifiedOn" IS NULL AND b0."Id" > @__value_1)) + ) AS b2 + WHERE 2 < b2.row AND b2.row <= 5 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn", b3."ModifiedOn", b3."Id" + --------------- + + """); + } + [Fact] public async Task Fetch_Fourth_Page_With_Offset_2() { @@ -274,6 +752,142 @@ 8. Hyperionix <- Page 4 - Item 2 .MatchSnapshot(); } + [Fact] + public async Task Fetch_Second_To_Last_Page_Ordering_By_Nullable_Columns_NULL_Cursor() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(last: 5) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + var fourthToLast = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 11. Kinetiq <- Selected - Item 1 + 12. Luminara <- Selected - Item 2 + 13. Momentumix <- Selected - Item 3 + 14. Nebularis <- Selected - Item 4 + 15. Omniflex <- Selected - Item 5 + 16. Pulsarix <- NULL Cursor + 17. Quantumis + 18. Radiantum + 19. Synerflux + 20. Vertexis + */ + + Snapshot.Create() + .Add(new + { + Page = fourthToLast.Index, + fourthToLast.TotalCount, + Items = fourthToLast.Items.Select(t => t.Name).ToArray() + }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 3, + "TotalCount": 20, + "Items": [ + "Kinetiq", + "Luminara", + "Momentumix", + "Nebularis", + "Omniflex" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/04/2025' (DbType = Date) + -- @__value_1='16' + -- @__p_2='6' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" <= @__value_0 AND (b."CreatedOn" < @__value_0 OR b."ModifiedOn" IS NOT NULL OR (b."ModifiedOn" IS NULL AND b."Id" < @__value_1)) + ORDER BY b."CreatedOn" DESC, b."ModifiedOn" DESC, b."Id" DESC + LIMIT @__p_2 + --------------- + + """); + } + + [Fact] + public async Task Fetch_Fourth_Page_With_Offset_2_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = first.CreateCursor(first.Last!, 2) }; + var fourth = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara + 4. Dynamova + 5. Evolvance + 6. Futurova + 7. Glacient <- Page 4 - Item 1 + 8. Hyperionix <- Page 4 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 4, + "TotalCount": 20, + "Items": [ + "Glacient", + "Hyperionix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='01/02/2025' (DbType = Date) + -- @__value_2='2' + -- @__p_4='3' + -- @__p_3='4' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" >= @__value_0 AND (b."CreatedOn" > @__value_0 OR ((b."ModifiedOn" >= @__value_1 OR b."ModifiedOn" IS NULL) AND (b."ModifiedOn" > @__value_1 OR b."ModifiedOn" IS NULL OR b."Id" > @__value_2))) + ORDER BY b."CreatedOn", b."ModifiedOn", b."Id" + LIMIT @__p_4 OFFSET @__p_3 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Fourth_Page_With_Offset_2() { @@ -313,6 +927,82 @@ 8. Hyperionix <- Page 4 - Item 2 .MatchSnapshot(); } + [Fact] + public async Task BatchFetch_Fourth_Page_With_Offset_2_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { After = first.CreateCursor(first.Last!, 2) }; + var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var fourth = map[1]; + + // Assert + + /* + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara + 4. Dynamova + 5. Evolvance + 6. Futurova + 7. Glacient <- Page 4 - Item 1 + 8. Hyperionix <- Page 4 - Item 2 + */ + + Snapshot.Create() + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 4, + "TotalCount": 20, + "Items": [ + "Glacient", + "Hyperionix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/01/2025' (DbType = Date) + -- @__value_1='01/02/2025' (DbType = Date) + -- @__value_2='2' + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" + FROM ( + SELECT b."GroupId" + FROM "Brands" AS b + WHERE b."GroupId" = 1 + GROUP BY b."GroupId" + ) AS b1 + LEFT JOIN ( + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" + FROM ( + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."CreatedOn", b0."ModifiedOn", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."CreatedOn" >= @__value_0 AND (b0."CreatedOn" > @__value_0 OR ((b0."ModifiedOn" >= @__value_1 OR b0."ModifiedOn" IS NULL) AND (b0."ModifiedOn" > @__value_1 OR b0."ModifiedOn" IS NULL OR b0."Id" > @__value_2))) + ) AS b2 + WHERE 4 < b2.row AND b2.row <= 7 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn", b3."ModifiedOn", b3."Id" + --------------- + + """); + } + [Fact] public async Task Fetch_Second_To_Last_Page_Offset_0() { @@ -354,6 +1044,73 @@ 20. Vertexis .MatchSnapshot(); } + [Fact] + public async Task Fetch_Second_To_Last_Page_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + var secondToLast = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 14. Nebularis + 15. Omniflex + 16. Pulsarix + 17. Quantumis <- Selected - Item 1 + 18. Radiantum <- Selected - Item 2 + 19. Synerflux <- Cursor + 20. Vertexis + */ + + Snapshot.Create() + .Add(new + { + Page = secondToLast.Index, + secondToLast.TotalCount, + Items = secondToLast.Items.Select(t => t.Name).ToArray() + }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 9, + "TotalCount": 20, + "Items": [ + "Quantumis", + "Radiantum" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/05/2025' (DbType = Date) + -- @__value_1='01/06/2025' (DbType = Date) + -- @__value_2='19' + -- @__p_3='3' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" <= @__value_0 AND (b."CreatedOn" < @__value_0 OR (b."ModifiedOn" <= @__value_1 AND (b."ModifiedOn" < @__value_1 OR b."Id" < @__value_2))) + ORDER BY b."CreatedOn" DESC, b."ModifiedOn" DESC, b."Id" DESC + LIMIT @__p_3 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Second_To_Last_Page_Offset_0() { @@ -397,6 +1154,86 @@ 20. Vertexis .MatchSnapshot(); } + [Fact] + public async Task BatchFetch_Second_To_Last_Page_Offset_0_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var secondToLast = map[2]; + + // Assert + + /* + 14. Nebularis + 15. Omniflex + 16. Pulsarix + 17. Quantumis <- Selected - Item 1 + 18. Radiantum <- Selected - Item 2 + 19. Synerflux <- Cursor + 20. Vertexis + */ + + Snapshot.Create() + .Add(new + { + Page = secondToLast.Index, + secondToLast.TotalCount, + Items = secondToLast.Items.Select(t => t.Name).ToArray() + }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 9, + "TotalCount": 20, + "Items": [ + "Quantumis", + "Radiantum" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/05/2025' (DbType = Date) + -- @__value_1='01/06/2025' (DbType = Date) + -- @__value_2='19' + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" + FROM ( + SELECT b."GroupId" + FROM "Brands" AS b + WHERE b."GroupId" = 2 + GROUP BY b."GroupId" + ) AS b1 + LEFT JOIN ( + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" + FROM ( + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."CreatedOn" DESC, b0."ModifiedOn" DESC, b0."Id" DESC) AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 2 AND b0."CreatedOn" <= @__value_0 AND (b0."CreatedOn" < @__value_0 OR (b0."ModifiedOn" <= @__value_1 AND (b0."ModifiedOn" < @__value_1 OR b0."Id" < @__value_2))) + ) AS b2 + WHERE b2.row <= 3 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn" DESC, b3."ModifiedOn" DESC, b3."Id" DESC + --------------- + + """); + } + [Fact] public async Task Fetch_Third_To_Last_Page_Offset_Negative_1() { @@ -438,6 +1275,145 @@ 20. Vertexis .MatchSnapshot(); } + [Fact] + public async Task Fetch_Third_To_Last_Page_Offset_Negative_1_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { Before = last.CreateCursor(last.First!, -1) }; + var thirdToLast = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 14. Nebularis + 15. Omniflex <- Selected - Item 1 + 16. Pulsarix <- Selected - Item 2 + 17. Quantumis + 18. Radiantum + 19. Synerflux <- Cursor + 20. Vertexis + */ + + Snapshot.Create() + .Add(new + { + Page = thirdToLast.Index, + thirdToLast.TotalCount, + Items = thirdToLast.Items.Select(t => t.Name).ToArray() + }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 8, + "TotalCount": 20, + "Items": [ + "Omniflex", + "Pulsarix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/05/2025' (DbType = Date) + -- @__value_1='01/06/2025' (DbType = Date) + -- @__value_2='19' + -- @__p_4='3' + -- @__p_3='2' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."CreatedOn" <= @__value_0 AND (b."CreatedOn" < @__value_0 OR (b."ModifiedOn" <= @__value_1 AND (b."ModifiedOn" < @__value_1 OR b."Id" < @__value_2))) + ORDER BY b."CreatedOn" DESC, b."ModifiedOn" DESC, b."Id" DESC + LIMIT @__p_4 OFFSET @__p_3 + --------------- + + """); + } + + [Fact] + public async Task Fetch_Fourth_To_Last_Page_Offset_Negative_1_Ordering_By_Nullable_Date_Column() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + var secondToLast = await context.Brands.OrderBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!, -1) }; + var fourthToLast = await context.Brands.OrderBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 11. Nebularis + 12. Omniflex + 13. Quantumis <- Selected - Item 1 + 14. Radiantum <- Selected - Item 2 + 15. Synerflux + 16. Dynamova + 17. Hyperionix <- Cursor + 18. Luminara + 19. Pulsarix + 20. Vertexis + */ + + Snapshot.Create() + .Add(new + { + Page = fourthToLast.Index, + fourthToLast.TotalCount, + Items = fourthToLast.Items.Select(t => t.Name).ToArray() + }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 7, + "TotalCount": 20, + "Items": [ + "Quantumis", + "Radiantum" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='8' + -- @__p_2='3' + -- @__p_1='2' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."ModifiedOn" IS NOT NULL OR (b."ModifiedOn" IS NULL AND b."Id" < @__value_0) + ORDER BY b."ModifiedOn" DESC, b."Id" DESC + LIMIT @__p_2 OFFSET @__p_1 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1() { @@ -481,6 +1457,86 @@ 20. Vertexis .MatchSnapshot(); } + [Fact] + public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1_Ordering_By_Nullable_Columns() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Act + + using var capture = new CapturePagingQueryInterceptor(); + arguments = arguments with { Before = last.CreateCursor(last.First!, -1) }; + var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var thirdToLast = map[2]; + + // Assert + + /* + 14. Nebularis + 15. Omniflex <- Selected - Item 1 + 16. Pulsarix <- Selected - Item 2 + 17. Quantumis + 18. Radiantum + 19. Synerflux <- Cursor + 20. Vertexis + */ + + Snapshot.Create() + .Add(new + { + Page = thirdToLast.Index, + thirdToLast.TotalCount, + Items = thirdToLast.Items.Select(t => t.Name).ToArray() + }) + .AddSql(capture) + .MatchInline( + """ + --------------- + { + "Page": 8, + "TotalCount": 20, + "Items": [ + "Omniflex", + "Pulsarix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/05/2025' (DbType = Date) + -- @__value_1='01/06/2025' (DbType = Date) + -- @__value_2='19' + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" + FROM ( + SELECT b."GroupId" + FROM "Brands" AS b + WHERE b."GroupId" = 2 + GROUP BY b."GroupId" + ) AS b1 + LEFT JOIN ( + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" + FROM ( + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."CreatedOn" DESC, b0."ModifiedOn" DESC, b0."Id" DESC) AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 2 AND b0."CreatedOn" <= @__value_0 AND (b0."CreatedOn" < @__value_0 OR (b0."ModifiedOn" <= @__value_1 AND (b0."ModifiedOn" < @__value_1 OR b0."Id" < @__value_2))) + ) AS b2 + WHERE 2 < b2.row AND b2.row <= 5 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn" DESC, b3."ModifiedOn" DESC, b3."Id" DESC + --------------- + + """); + } + [Fact] public async Task Fetch_Fourth_To_Last_Page_Offset_Negative_2() { @@ -745,6 +1801,33 @@ public async Task RequestedSize_Not_Evenly_Divisible_By_TotalCount() Assert.Single(first.CreateRelativeForwardCursors()); } + [Fact] + public async Task Nullable_Fallback_Cursor() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.ModifiedOn).ToPageAsync(arguments); + + // Act + + arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + + async Task Error() + { + await using var ctx = new TestContext(connectionString); + await ctx.Brands.OrderBy(t => t.ModifiedOn).ToPageAsync(arguments); + } + + // Assert + + await Assert.ThrowsAsync(Error); + } + private static async Task SeedAsync(string connectionString) { await using var context = new TestContext(connectionString); @@ -773,27 +1856,27 @@ 19. Synerflux 20. Vertexis */ - context.Brands.Add(new Brand { Name = "Aetherix", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Brightex", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Celestara", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Dynamova", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Evolvance", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Futurova", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Glacient", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Hyperionix", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Innovexa", GroupId = 1 }); - context.Brands.Add(new Brand { Name = "Joventra", GroupId = 1 }); - - context.Brands.Add(new Brand { Name = "Kinetiq", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Luminara", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Momentumix", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Nebularis", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Omniflex", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Pulsarix", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Quantumis", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Radiantum", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Synerflux", GroupId = 2 }); - context.Brands.Add(new Brand { Name = "Vertexis", GroupId = 2 }); + context.Brands.Add(new Brand { Name = "Aetherix", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 1), ModifiedOn = new DateOnly(2025, 1, 1) }); + context.Brands.Add(new Brand { Name = "Brightex", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 1), ModifiedOn = new DateOnly(2025, 1, 2) }); + context.Brands.Add(new Brand { Name = "Celestara", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 1), ModifiedOn = new DateOnly(2025, 1, 2) }); + context.Brands.Add(new Brand { Name = "Dynamova", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 1) }); + context.Brands.Add(new Brand { Name = "Evolvance", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 2), ModifiedOn = new DateOnly(2025, 1, 2) }); + context.Brands.Add(new Brand { Name = "Futurova", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 2), ModifiedOn = new DateOnly(2025, 1, 3) }); + context.Brands.Add(new Brand { Name = "Glacient", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 2), ModifiedOn = new DateOnly(2025, 1, 3) }); + context.Brands.Add(new Brand { Name = "Hyperionix", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 2) }); + context.Brands.Add(new Brand { Name = "Innovexa", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 3), ModifiedOn = new DateOnly(2025, 1, 3) }); + context.Brands.Add(new Brand { Name = "Joventra", GroupId = 1, CreatedOn = new DateOnly(2025, 1, 3), ModifiedOn = new DateOnly(2025, 1, 4) }); + + context.Brands.Add(new Brand { Name = "Kinetiq", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 3), ModifiedOn = new DateOnly(2025, 1, 4) }); + context.Brands.Add(new Brand { Name = "Luminara", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 3) }); + context.Brands.Add(new Brand { Name = "Momentumix", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 4), ModifiedOn = new DateOnly(2025, 1, 4) }); + context.Brands.Add(new Brand { Name = "Nebularis", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 4), ModifiedOn = new DateOnly(2025, 1, 5) }); + context.Brands.Add(new Brand { Name = "Omniflex", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 4), ModifiedOn = new DateOnly(2025, 1, 5) }); + context.Brands.Add(new Brand { Name = "Pulsarix", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 4) }); + context.Brands.Add(new Brand { Name = "Quantumis", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 5), ModifiedOn = new DateOnly(2025, 1, 5) }); + context.Brands.Add(new Brand { Name = "Radiantum", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 5), ModifiedOn = new DateOnly(2025, 1, 6) }); + context.Brands.Add(new Brand { Name = "Synerflux", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 5), ModifiedOn = new DateOnly(2025, 1, 6) }); + context.Brands.Add(new Brand { Name = "Vertexis", GroupId = 2, CreatedOn = new DateOnly(2025, 1, 5) }); await context.SaveChangesAsync(); } @@ -815,6 +1898,10 @@ public class Brand public int Id { get; set; } [MaxLength(100)] public required string Name { get; set; } + + public DateOnly CreatedOn { get; set; } + + public DateOnly? ModifiedOn { get; set; } } } #endif diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor.md index 03779024056..37791e50acf 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor.md @@ -4,8 +4,8 @@ ```json { - "First": "ezB8MXwxMDB9MQ==", - "Last": "ezB8MXwxMDB9Mg==", + "First": "ezB8MHwxfDEwMH0x", + "Last": "ezB8MHwxfDEwMH0y", "Items": [ { "Id": 1, @@ -45,8 +45,8 @@ ```json { - "First": "ezB8MXwxMDB9MTAx", - "Last": "ezB8MXwxMDB9MTAy", + "First": "ezB8MHwxfDEwMH0xMDE=", + "Last": "ezB8MHwxfDEwMH0xMDI=", "Items": [ { "Id": 101, @@ -86,8 +86,8 @@ ```json { - "First": "ezB8MXwxMDB9MjAx", - "Last": "ezB8MXwxMDB9MjAy", + "First": "ezB8MHwxfDEwMH0yMDE=", + "Last": "ezB8MHwxfDEwMH0yMDI=", "Items": [ { "Id": 201, diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor_NET8_0.md index d0c923b24a6..19c8cdb4f30 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor_NET8_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.BatchPaging_With_Relative_Cursor_NET8_0.md @@ -4,8 +4,8 @@ ```json { - "First": "ezB8MXwxMDB9MQ==", - "Last": "ezB8MXwxMDB9Mg==", + "First": "ezB8MHwxfDEwMH0x", + "Last": "ezB8MHwxfDEwMH0y", "Items": [ { "Id": 1, @@ -45,8 +45,8 @@ ```json { - "First": "ezB8MXwxMDB9MTAx", - "Last": "ezB8MXwxMDB9MTAy", + "First": "ezB8MHwxfDEwMH0xMDE=", + "Last": "ezB8MHwxfDEwMH0xMDI=", "Items": [ { "Id": 101, @@ -86,8 +86,8 @@ ```json { - "First": "ezB8MXwxMDB9MjAx", - "Last": "ezB8MXwxMDB9MjAy", + "First": "ezB8MHwxfDEwMH0yMDE=", + "Last": "ezB8MHwxfDEwMH0yMDI=", "Items": [ { "Id": 201, diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET8_0.md index 04f7903b579..40401f2c55b 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET8_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET8_0.md @@ -8,7 +8,7 @@ -- @__p_2='6' SELECT b."Id", b."AlwaysNull", b."DisplayName", b."Name", b."BrandDetails_Country_Name" FROM "Brands" AS b -WHERE b."Name" > @__value_0 OR (b."Name" = @__value_0 AND b."Id" > @__value_1) +WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) ORDER BY b."Name", b."Id" LIMIT @__p_2 ``` @@ -16,7 +16,7 @@ LIMIT @__p_2 ## Expression 0 ```text -[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) > 0) OrElse ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) == 0) AndAlso (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) > 0)))).Take(6) +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) >= 0) AndAlso ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) > 0) OrElse (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) > 0)))).Take(6) ``` ## Result 3 diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET9_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET9_0.md index 04f7903b579..40401f2c55b 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET9_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_After_Id_13_NET9_0.md @@ -8,7 +8,7 @@ -- @__p_2='6' SELECT b."Id", b."AlwaysNull", b."DisplayName", b."Name", b."BrandDetails_Country_Name" FROM "Brands" AS b -WHERE b."Name" > @__value_0 OR (b."Name" = @__value_0 AND b."Id" > @__value_1) +WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) ORDER BY b."Name", b."Id" LIMIT @__p_2 ``` @@ -16,7 +16,7 @@ LIMIT @__p_2 ## Expression 0 ```text -[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) > 0) OrElse ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) == 0) AndAlso (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) > 0)))).Take(6) +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) >= 0) AndAlso ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) > 0) OrElse (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) > 0)))).Take(6) ``` ## Result 3 diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET8_0.md index ec12ab52cce..fc195437324 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET8_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET8_0.md @@ -8,7 +8,7 @@ -- @__p_2='6' SELECT b."Id", b."AlwaysNull", b."DisplayName", b."Name", b."BrandDetails_Country_Name" FROM "Brands" AS b -WHERE b."Name" < @__value_0 OR (b."Name" = @__value_0 AND b."Id" < @__value_1) +WHERE b."Name" <= @__value_0 AND (b."Name" < @__value_0 OR b."Id" < @__value_1) ORDER BY b."Name" DESC, b."Id" DESC LIMIT @__p_2 ``` @@ -16,7 +16,7 @@ LIMIT @__p_2 ## Expression 0 ```text -[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) < 0) OrElse ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) == 0) AndAlso (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) < 0)))).Take(6) +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) <= 0) AndAlso ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) < 0) OrElse (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) < 0)))).Take(6) ``` ## Result 3 diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET9_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET9_0.md index ec12ab52cce..fc195437324 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET9_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/PagingHelperIntegrationTests.Paging_First_5_Before_Id_96_NET9_0.md @@ -8,7 +8,7 @@ -- @__p_2='6' SELECT b."Id", b."AlwaysNull", b."DisplayName", b."Name", b."BrandDetails_Country_Name" FROM "Brands" AS b -WHERE b."Name" < @__value_0 OR (b."Name" = @__value_0 AND b."Id" < @__value_1) +WHERE b."Name" <= @__value_0 AND (b."Name" < @__value_0 OR b."Id" < @__value_1) ORDER BY b."Name" DESC, b."Id" DESC LIMIT @__p_2 ``` @@ -16,7 +16,7 @@ LIMIT @__p_2 ## Expression 0 ```text -[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) < 0) OrElse ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) == 0) AndAlso (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) < 0)))).Take(6) +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Where(t => ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) <= 0) AndAlso ((t.Name.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) < 0) OrElse (t.Id.CompareTo(value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value) < 0)))).Take(6) ``` ## Result 3 diff --git a/src/GreenDonut/test/GreenDonut.Data.Tests/Cursors/CursorFormatterTests.cs b/src/GreenDonut/test/GreenDonut.Data.Tests/Cursors/CursorFormatterTests.cs index 3fcf2a183ac..f04673baf3b 100644 --- a/src/GreenDonut/test/GreenDonut.Data.Tests/Cursors/CursorFormatterTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.Tests/Cursors/CursorFormatterTests.cs @@ -82,6 +82,43 @@ public void Format_Two_Keys_With_Colon() Assert.Equal("{}test\\:345:description\\:123", Encoding.UTF8.GetString(Convert.FromBase64String(result))); } + [Fact] + public void Format_And_Parse_Two_Keys_With_PageInfo() + { + // arrange + var entity = new MyClass { Name = "test:345", Description = null }; + Expression> selector1 = x => x.Name; + Expression> selector2 = x => x.Description; + var serializer = new StringCursorKeySerializer(); + var expectedNullsFirst = true; + var expectedOffset = 12; + var expectedPageIndex = 1; + var expectedTotalCount = 20; + + // act + var formatted = CursorFormatter.Format( + entity, + [ + new CursorKey(selector1, serializer), + new CursorKey(selector2, serializer) + ], + new CursorPageInfo(expectedNullsFirst, expectedOffset, expectedPageIndex, expectedTotalCount)); + var parsed = CursorParser.Parse( + formatted, + [ + new CursorKey(selector1, serializer), + new CursorKey(selector2, serializer) + ]); + + // assert + Assert.Equal(expectedNullsFirst, parsed.NullsFirst); + Assert.Equal(expectedOffset, parsed.Offset); + Assert.Equal(expectedPageIndex, parsed.PageIndex); + Assert.Equal(expectedTotalCount, parsed.TotalCount); + Assert.Equal("test:345", parsed.Values[0]); + Assert.Null(parsed.Values[1]); + } + [Fact] public void Format_And_Parse_Two_Keys_With_Colon() { diff --git a/src/HotChocolate/Data/src/EntityFramework/Pagination/EfQueryableCursorPagingHandler.cs b/src/HotChocolate/Data/src/EntityFramework/Pagination/EfQueryableCursorPagingHandler.cs index 7d2fd5cff87..9b1afbb6256 100644 --- a/src/HotChocolate/Data/src/EntityFramework/Pagination/EfQueryableCursorPagingHandler.cs +++ b/src/HotChocolate/Data/src/EntityFramework/Pagination/EfQueryableCursorPagingHandler.cs @@ -46,14 +46,14 @@ private async ValueTask SliceAsync( if (arguments.After is not null) { var cursor = CursorParser.Parse(arguments.After, keys); - var (whereExpr, _) = ExpressionHelpers.BuildWhereExpression(keys, cursor, true); + var whereExpr = ExpressionHelpers.BuildWhereExpression(keys, cursor, true); query = query.Where(whereExpr); } if (arguments.Before is not null) { var cursor = CursorParser.Parse(arguments.Before, keys); - var (whereExpr, _) = ExpressionHelpers.BuildWhereExpression(keys, cursor, false); + var whereExpr = ExpressionHelpers.BuildWhereExpression(keys, cursor, false); query = query.Where(whereExpr); }