From b68dc30bed5a4e65bb6446fcf9a7c9f83aec9e81 Mon Sep 17 00:00:00 2001 From: sleepertassly Date: Sun, 6 Apr 2025 21:25:04 +0300 Subject: [PATCH 1/6] Fixed relative cursors to handle nullable types. One test intentionally fails to highlight a flaw in the implementation. The algorithm expects that null values are smaller than any other value when ordering. --- .../Expressions/ExpressionHelpers.cs | 102 ++++-- .../src/GreenDonut.Data/Cursors/CursorKey.cs | 8 +- .../Cursors/CursorKeyCompareMethod.cs | 23 ++ .../Serializers/BoolCursorKeySerializer.cs | 9 +- .../Cursors/Serializers/CompareToResolver.cs | 7 +- .../DateOnlyCursorKeySerializer.cs | 9 +- .../DateTimeCursorKeySerializer.cs | 9 +- .../DateTimeOffsetCursorKeySerializer.cs | 9 +- .../Serializers/DecimalCursorKeySerializer.cs | 9 +- .../Serializers/DoubleCursorKeySerializer.cs | 9 +- .../Serializers/FloatCursorKeySerializer.cs | 9 +- .../Serializers/GuidCursorKeySerializer.cs | 9 +- .../Serializers/ICursorKeySerializer.cs | 4 +- .../Serializers/IntCursorKeySerializer.cs | 9 +- .../Serializers/LongCursorKeySerializer.cs | 9 +- .../Serializers/ShortCursorKeySerializer.cs | 9 +- .../Serializers/StringCursorKeySerializer.cs | 7 +- .../TimeOnlyCursorKeySerializer.cs | 9 +- .../Serializers/UIntCursorKeySerializer.cs | 9 +- .../Serializers/ULongCursorKeySerializer.cs | 9 +- .../Serializers/UShortCursorKeySerializer.cs | 9 +- .../RelativeCursorTests.cs | 319 ++++++++++++++---- ...gHelperTests.Paging_First_5_After_Id_13.md | 4 +- ...Tests.Paging_First_5_After_Id_13_NET8_0.md | 4 +- ...HelperTests.Paging_First_5_Before_Id_96.md | 4 +- ...ests.Paging_First_5_Before_Id_96_NET8_0.md | 4 +- 26 files changed, 464 insertions(+), 157 deletions(-) create mode 100644 src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyCompareMethod.cs diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index e037e1ed18d..03f23755c95 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -58,47 +58,97 @@ 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; + Expression keyExpr, mainKeyExpr, secondaryKeyExpr; - for (var j = 0; j < handled.Count; j++) + var greaterThan = forward + ? key.Direction == CursorKeyDirection.Ascending + : key.Direction == CursorKeyDirection.Descending; + + keyExpr = ReplaceParameter(key.Expression, parameter); + if (key.IsNullable) { - var handledKey = handled[j]; + if (expression is null) + { + throw new ArgumentException("The last key must be non-nullable.", nameof(keys)); + } - keyExpr = Expression.Equal( - Expression.Call(ReplaceParameter(handledKey.Expression, parameter), handledKey.CompareMethod, - cursorExpr[j]), zero); + var nullConstant = Expression.Constant(null, keyExpr.Type); - current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr); - } + if (cursor.Values[i] is null) + { + if (greaterThan) + { + mainKeyExpr = Expression.Equal(keyExpr, nullConstant); - var greaterThan = forward - ? key.Direction == CursorKeyDirection.Ascending - : key.Direction == CursorKeyDirection.Descending; + secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant); + + expression = Expression.OrElse(secondaryKeyExpr, Expression.AndAlso(mainKeyExpr, expression!)); + } + else + { + mainKeyExpr = Expression.Equal(keyExpr, nullConstant); - keyExpr = greaterThan - ? Expression.GreaterThan( - Expression.Call(ReplaceParameter(key.Expression, parameter), key.CompareMethod, cursorExpr[i]), - zero) - : Expression.LessThan( - Expression.Call(ReplaceParameter(key.Expression, parameter), key.CompareMethod, cursorExpr[i]), - zero); - - current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr); - expression = expression is null ? current : Expression.OrElse(expression, current); - handled.Add(key); + expression = Expression.AndAlso(mainKeyExpr, expression!); + } + } + else + { + var nonNullKeyExpr = Expression.Property(keyExpr, "Value"); + var isNullExpression = Expression.Equal(keyExpr, nullConstant); + + mainKeyExpr = greaterThan + ? Expression.GreaterThan( + Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero) + : Expression.OrElse( + Expression.LessThan( + Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero), isNullExpression); + + secondaryKeyExpr = greaterThan + ? Expression.GreaterThanOrEqual( + Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero) + : Expression.OrElse( + Expression.LessThanOrEqual( + Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero), isNullExpression); + + expression = Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, expression!)); + } + } + else + { + mainKeyExpr = greaterThan + ? Expression.GreaterThan( + Expression.Call(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero) + : Expression.LessThan( + Expression.Call(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero); + + secondaryKeyExpr = greaterThan + ? Expression.GreaterThanOrEqual( + Expression.Call(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero) + : Expression.LessThanOrEqual( + Expression.Call(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + zero); + + expression = expression is null ? mainKeyExpr : + Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, expression)); + } } return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0); diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs index 522aaa939c9..0c7350dbb9a 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. 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/Serializers/BoolCursorKeySerializer.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs index b24db4c9d62..58dcda47e70 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/BoolCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class BoolCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 b33b7bcf8c9..6ef03050ae8 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 13c3b0dfa84..3660c48a25a 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateOnlyCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateOnlyCursorKeySerializer.cs @@ -6,15 +6,18 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DateOnlyCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = + private static readonly CursorKeyCompareMethod _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) => _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 db079e1af4d..49e7bf1bb7a 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeCursorKeySerializer.cs @@ -7,15 +7,18 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DateTimeCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = + private static readonly CursorKeyCompareMethod _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) => _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 66f1c3ac92a..3f1b85a00cf 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeOffsetCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DateTimeOffsetCursorKeySerializer.cs @@ -7,16 +7,19 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DateTimeOffsetCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = + private static readonly CursorKeyCompareMethod _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) => _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 fc3969317bc..3a361bd0de6 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DecimalCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DecimalCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DecimalCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 0a37c28c38f..028ccb83b8c 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DoubleCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/DoubleCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class DoubleCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 7d222ffd19d..4673f87d6be 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/FloatCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/FloatCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class FloatCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 f5ff752bdf1..3105b1d3282 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/GuidCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/GuidCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class GuidCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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..0fb9059d34d 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ICursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ICursorKeySerializer.cs @@ -6,7 +6,9 @@ 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 cf03b1ec54f..935dd756e4a 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/IntCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/IntCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class IntCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 1c370238a11..7a377ef1b25 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/LongCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/LongCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class LongCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 3377137bbd1..1a416fc9dec 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ShortCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ShortCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class ShortCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 cb0a336ea06..eed1e1e7958 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/StringCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/StringCursorKeySerializer.cs @@ -6,12 +6,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class StringCursorKeySerializer : ICursorKeySerializer { private static readonly Encoding _encoding = Encoding.UTF8; - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 19907f5276d..e48164cc8d6 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/TimeOnlyCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/TimeOnlyCursorKeySerializer.cs @@ -6,15 +6,18 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class TimeOnlyCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = + private static readonly CursorKeyCompareMethod _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) => _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 c87890324dc..d69e84da1bf 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UIntCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UIntCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class UIntCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 c4fa6118f96..d1ae67100bd 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ULongCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/ULongCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class ULongCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 a93d7c2be01..8e5040825ea 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UShortCursorKeySerializer.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Serializers/UShortCursorKeySerializer.cs @@ -5,12 +5,15 @@ namespace GreenDonut.Data.Cursors.Serializers; internal sealed class UShortCursorKeySerializer : ICursorKeySerializer { - private static readonly MethodInfo _compareTo = CompareToResolver.GetCompareToMethod(); + private static readonly CursorKeyCompareMethod _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) => _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 9ecbbfb8b37..16d5b3c7a0b 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs @@ -63,9 +63,9 @@ SQL 0 -- @__value_0='Brightex' -- @__value_1='2' -- @__p_2='3' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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 --------------- @@ -124,7 +124,7 @@ SQL 0 --------------- -- @__value_0='Brightex' -- @__value_1='2' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -132,11 +132,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND (b0."Name" > @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" > @__value_1)) + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) ) AS b2 WHERE b2.row <= 3 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -197,9 +197,9 @@ SQL 0 -- @__value_1='2' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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_3 OFFSET @__p_2 --------------- @@ -258,7 +258,7 @@ SQL 0 --------------- -- @__value_0='Brightex' -- @__value_1='2' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -266,11 +266,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND (b0."Name" > @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" > @__value_1)) + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) ) AS b2 WHERE 2 < b2.row AND b2.row <= 5 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -335,9 +335,9 @@ SQL 0 -- @__value_1='4' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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_3 OFFSET @__p_2 --------------- @@ -345,6 +345,154 @@ LIMIT @__p_3 OFFSET @__p_2 """); } + [Fact] + public async Task Fetch_Fourth_Page_With_Offset_1_Order_By_DisplayName() + { + // 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.DisplayName).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.DisplayName).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.DisplayName).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex + 3. Celestara + 4. Dynamova <- 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='Dynamova' + -- @__value_1='4' + -- @__p_3='3' + -- @__p_2='2' + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + FROM "Brands" AS b + WHERE b."DisplayName" >= @__value_0 AND (b."DisplayName" > @__value_0 OR b."Id" > @__value_1) + ORDER BY b."DisplayName", b."Id" + LIMIT @__p_3 OFFSET @__p_2 + --------------- + + """); + } + + [Fact] + public async Task Fetch_Second_Page_Order_By_FoundedDate() + { + // Arrange + + var connectionString = CreateConnectionString(); + await SeedAsync(connectionString); + + await using var context = new TestContext(connectionString); + var arguments = new PagingArguments(context.Brands.Count() / 2) { EnableRelativeCursors = true }; + var first = await context.Brands.OrderBy(t => t.FoundedDate).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.FoundedDate).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + + 1. Dynamova + 2. Evolvance + 3. Futurova + 4. Glacient + 5. Hyperionix + 6. Innovexa + 7. Joventra + 8. Nebularis + 9. Omniflex + 10. Pulsarix <- Cursor + 11. Quantumis <- Page 2 - Item 1 + 12. Radiantum <- Page 2 - Item 2 + 13. Synerflux <- Page 2 - Item 3 + 14. Vertexis <- Page 2 - Item 4 + 15. Aetherix <- Page 2 - Item 5 + 16. Brightex <- Page 2 - Item 6 + 17. Celestara <- Page 2 - Item 7 + 18. Kinetiq <- Page 2 - Item 8 + 19. Luminara <- Page 2 - Item 9 + 20. Momentumix <- Page 2 - Item 10 + */ + + 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": [ + "Quantumis", + "Radiantum", + "Synerflux", + "Vertexis", + "Aetherix", + "Brightex", + "Celestara", + "Kinetiq", + "Luminara", + "Momentumix" + ] + } + --------------- + + SQL 0 + --------------- + -- @__value_0='01/10/2025' (DbType = Date) + -- @__value_1='16' + -- @__p_2='11' + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + FROM "Brands" AS b + WHERE b."FoundedDate" >= @__value_0 AND (b."FoundedDate" > @__value_0 OR b."Id" > @__value_1) + ORDER BY b."FoundedDate" IS NULL, b."FoundedDate", b."Id" + LIMIT @__p_2 + --------------- + + """); + } + [Fact] public async Task BatchFetch_Fourth_Page_With_Offset_1() { @@ -400,7 +548,7 @@ SQL 0 --------------- -- @__value_0='Dynamova' -- @__value_1='4' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -408,11 +556,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND (b0."Name" > @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" > @__value_1)) + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) ) AS b2 WHERE 2 < b2.row AND b2.row <= 5 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -475,9 +623,9 @@ SQL 0 -- @__value_1='2' -- @__p_3='3' -- @__p_2='4' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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_3 OFFSET @__p_2 --------------- @@ -538,7 +686,7 @@ SQL 0 --------------- -- @__value_0='Brightex' -- @__value_1='2' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -546,11 +694,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND (b0."Name" > @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" > @__value_1)) + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) ) AS b2 WHERE 4 < b2.row AND b2.row <= 7 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -616,9 +764,9 @@ SQL 0 -- @__value_0='Synerflux' -- @__value_1='19' -- @__p_2='3' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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 --------------- @@ -683,7 +831,7 @@ SQL 0 --------------- -- @__value_0='Synerflux' -- @__value_1='19' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -691,11 +839,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 2 AND (b0."Name" < @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" < @__value_1)) + WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 WHERE b2.row <= 3 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -762,9 +910,9 @@ SQL 0 -- @__value_1='19' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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_3 OFFSET @__p_2 --------------- @@ -829,7 +977,7 @@ SQL 0 --------------- -- @__value_0='Synerflux' -- @__value_1='19' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -837,11 +985,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 2 AND (b0."Name" < @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" < @__value_1)) + WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 WHERE 2 < b2.row AND b2.row <= 5 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -911,9 +1059,9 @@ SQL 0 -- @__value_1='19' -- @__p_3='3' -- @__p_2='4' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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_3 OFFSET @__p_2 --------------- @@ -981,7 +1129,7 @@ SQL 0 --------------- -- @__value_0='Synerflux' -- @__value_1='19' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -989,11 +1137,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 2 AND (b0."Name" < @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" < @__value_1)) + WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 WHERE 4 < b2.row AND b2.row <= 7 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -1065,9 +1213,9 @@ SQL 0 -- @__value_1='17' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."GroupId", b."Name" + SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."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_3 OFFSET @__p_2 --------------- @@ -1137,7 +1285,7 @@ SQL 0 --------------- -- @__value_0='Quantumis' -- @__value_1='17' - SELECT b1."GroupId", b3."Id", b3."GroupId", b3."Name" + SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -1145,11 +1293,11 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."GroupId", b2."Name" + SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" FROM ( - SELECT b0."Id", b0."GroupId", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 2 AND (b0."Name" < @__value_0 OR (b0."Name" = @__value_0 AND b0."Id" < @__value_1)) + WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 WHERE 2 < b2.row AND b2.row <= 5 ) AS b3 ON b1."GroupId" = b3."GroupId" @@ -1222,6 +1370,33 @@ await ctx.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.I await Assert.ThrowsAsync(Error); } + [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.FoundedDate).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.FoundedDate).ToPageAsync(arguments); + } + + // Assert + + await Assert.ThrowsAsync(Error); + } + private static async Task SeedAsync(string connectionString) { await using var context = new TestContext(connectionString); @@ -1250,27 +1425,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, DisplayName = "Aetherix" }); + context.Brands.Add(new Brand { Name = "Brightex", GroupId = 1, DisplayName = "Brightex", IsActive = false }); + context.Brands.Add(new Brand { Name = "Celestara", GroupId = 1, DisplayName = "Celestara", IsActive = true }); + context.Brands.Add(new Brand { Name = "Dynamova", GroupId = 1, DisplayName = "Dynamova", IsActive = true, FoundedDate = new DateOnly(2025, 1, 1) }); + context.Brands.Add(new Brand { Name = "Evolvance", GroupId = 1, DisplayName = "Evolvance", IsActive = true, FoundedDate = new DateOnly(2025, 1, 2) }); + context.Brands.Add(new Brand { Name = "Futurova", GroupId = 1, DisplayName = "Futurova", IsActive = true, FoundedDate = new DateOnly(2025, 1, 3) }); + context.Brands.Add(new Brand { Name = "Glacient", GroupId = 1, DisplayName = "Glacient", IsActive = true, FoundedDate = new DateOnly(2025, 1, 4) }); + context.Brands.Add(new Brand { Name = "Hyperionix", GroupId = 1, DisplayName = "Hyperionix", IsActive = true, FoundedDate = new DateOnly(2025, 1, 5) }); + context.Brands.Add(new Brand { Name = "Innovexa", GroupId = 1, DisplayName = "Innovexa", IsActive = true, FoundedDate = new DateOnly(2025, 1, 6) }); + context.Brands.Add(new Brand { Name = "Joventra", GroupId = 1, DisplayName = "Joventra", IsActive = true, FoundedDate = new DateOnly(2025, 1, 7) }); + + context.Brands.Add(new Brand { Name = "Kinetiq", GroupId = 2, DisplayName = "Kinetiq" }); + context.Brands.Add(new Brand { Name = "Luminara", GroupId = 2, DisplayName = "Luminara", IsActive = false }); + context.Brands.Add(new Brand { Name = "Momentumix", GroupId = 2, DisplayName = "Momentumix", IsActive = true }); + context.Brands.Add(new Brand { Name = "Nebularis", GroupId = 2, DisplayName = "Nebularis", IsActive = true, FoundedDate = new DateOnly(2025, 1, 8) }); + context.Brands.Add(new Brand { Name = "Omniflex", GroupId = 2, DisplayName = "Omniflex", IsActive = true, FoundedDate = new DateOnly(2025, 1, 9) }); + context.Brands.Add(new Brand { Name = "Pulsarix", GroupId = 2, DisplayName = "Pulsarix", IsActive = true, FoundedDate = new DateOnly(2025, 1, 10) }); + context.Brands.Add(new Brand { Name = "Quantumis", GroupId = 2, DisplayName = "Quantumis", IsActive = true, FoundedDate = new DateOnly(2025, 1, 11) }); + context.Brands.Add(new Brand { Name = "Radiantum", GroupId = 2, DisplayName = "Radiantum", IsActive = true, FoundedDate = new DateOnly(2025, 1, 12) }); + context.Brands.Add(new Brand { Name = "Synerflux", GroupId = 2, DisplayName = "Synerflux", IsActive = true, FoundedDate = new DateOnly(2025, 1, 13) }); + context.Brands.Add(new Brand { Name = "Vertexis", GroupId = 2, DisplayName = "Vertexis", IsActive = true, FoundedDate = new DateOnly(2025, 1, 14) }); await context.SaveChangesAsync(); } @@ -1292,6 +1467,12 @@ public class Brand public int Id { get; set; } [MaxLength(100)] public required string Name { get; set; } + + public string? DisplayName { get; set; } = default!; + + public bool? IsActive { get; set; } + + public DateOnly? FoundedDate { get; set; } } } #endif diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.md index 04f7903b579..40401f2c55b 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.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__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13_NET8_0.md index 04f7903b579..40401f2c55b 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13_NET8_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.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__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.md index ec12ab52cce..fc195437324 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.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__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96_NET8_0.md index ec12ab52cce..fc195437324 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96_NET8_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.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 From 808ad5f0ed21216ab07a1b382b4fe8d74081391a Mon Sep 17 00:00:00 2001 From: sleepertassly Date: Tue, 8 Apr 2025 23:49:28 +0300 Subject: [PATCH 2/6] Added NullsFirst flag to the Cursors. This flag determines whether null values are ordered first or last. --- .../Expressions/ExpressionHelpers.cs | 193 +++++++++++++----- .../Extensions/PagingQueryableExtensions.cs | 28 ++- .../src/GreenDonut.Data/Cursors/Cursor.cs | 4 + .../Cursors/CursorFormatter.cs | 4 +- .../src/GreenDonut.Data/Cursors/CursorKey.cs | 12 +- .../GreenDonut.Data/Cursors/CursorPageInfo.cs | 23 ++- .../GreenDonut.Data/Cursors/CursorParser.cs | 13 +- .../RelativeCursorTests.cs | 4 +- ...BatchPaging_With_Relative_Cursor_NET8_0.md | 12 +- ...BatchPaging_With_Relative_Cursor_NET9_0.md | 12 +- .../Cursors/CursorFormatterTests.cs | 37 ++++ 11 files changed, 261 insertions(+), 81 deletions(-) diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index 03f23755c95..1f7eec2a29d 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -69,13 +69,13 @@ public static (Expression> WhereExpression, int Offset) BuildWhere for (var i = keys.Length - 1; i >= 0; i--) { var key = keys[i]; - Expression keyExpr, mainKeyExpr, secondaryKeyExpr; var greaterThan = forward ? key.Direction == CursorKeyDirection.Ascending : key.Direction == CursorKeyDirection.Descending; - keyExpr = ReplaceParameter(key.Expression, parameter); + Expression keyExpr = ReplaceParameter(key.Expression, parameter); + if (key.IsNullable) { if (expression is null) @@ -83,75 +83,170 @@ public static (Expression> WhereExpression, int Offset) BuildWhere throw new ArgumentException("The last key must be non-nullable.", nameof(keys)); } - var nullConstant = Expression.Constant(null, keyExpr.Type); - - if (cursor.Values[i] is null) + // 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) { - if (greaterThan) - { - mainKeyExpr = Expression.Equal(keyExpr, nullConstant); + 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 + { + expression = BuildNonNullExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo); + } + } - secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant); + return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0); - expression = Expression.OrElse(secondaryKeyExpr, Expression.AndAlso(mainKeyExpr, expression!)); - } - else - { - mainKeyExpr = Expression.Equal(keyExpr, nullConstant); + static Expression BuildNullsFirstExpression( + Expression previousExpr, + object? keyValue, + Expression keyExpr, + Expression cursorExpr, + bool greaterThan, + MethodInfo compareMethod) + { + Expression mainKeyExpr, secondaryKeyExpr; - expression = Expression.AndAlso(mainKeyExpr, expression!); - } + 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 { - var nonNullKeyExpr = Expression.Property(keyExpr, "Value"); - var isNullExpression = Expression.Equal(keyExpr, nullConstant); - - mainKeyExpr = greaterThan - ? Expression.GreaterThan( - Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero) - : Expression.OrElse( - Expression.LessThan( - Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero), isNullExpression); - - secondaryKeyExpr = greaterThan - ? Expression.GreaterThanOrEqual( - Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero) - : Expression.OrElse( - Expression.LessThanOrEqual( - Expression.Call(nonNullKeyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero), isNullExpression); - - expression = Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, expression!)); + 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(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero) - : Expression.LessThan( - Expression.Call(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero); + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero) + : Expression.OrElse( + Expression.LessThan( + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero), isNullExpression); secondaryKeyExpr = greaterThan ? Expression.GreaterThanOrEqual( - Expression.Call(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), + 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)); + } + } + + 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); + + 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(keyExpr, key.CompareMethod.MethodInfo, cursorExpr[i]), - zero); + Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr), + zero); - expression = expression is null ? mainKeyExpr : - Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, expression)); + return Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr)); } } - return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0); + 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(keyExpr, compareMethod, cursorExpr), + zero) + : Expression.LessThan( + Expression.Call(keyExpr, compareMethod, cursorExpr), + zero); + + secondaryKeyExpr = greaterThan + ? Expression.GreaterThanOrEqual( + Expression.Call(keyExpr, compareMethod, cursorExpr), + zero) + : Expression.LessThanOrEqual( + Expression.Call(keyExpr, compareMethod, cursorExpr), + zero); + + return previousExpr is null ? mainKeyExpr : + Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr)); + } } diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index 706028694d1..bc42b325fbb 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -239,7 +239,7 @@ public static async ValueTask> ToPageAsync( } var pageIndex = CreateIndex(arguments, cursor, totalCount); - return CreatePage(builder.ToImmutable(), arguments, keys, fetchCount, pageIndex, requestedCount, totalCount); + return CreatePage(builder.ToImmutable(), arguments, keys, cursor?.NullsFirst, fetchCount, pageIndex, requestedCount, totalCount); } /// @@ -503,6 +503,7 @@ public static async ValueTask>> ToBatchPageAsync CreatePage( ImmutableArray items, PagingArguments arguments, CursorKey[] keys, + bool? previousNullsFirst, int fetchCount, int? index, int? requestedPageSize, @@ -579,27 +581,30 @@ private static Page CreatePage( { var hasPrevious = false; var hasNext = false; + var nullsFirst = false; // if we skipped over an item, and we have fetched some items // than we have a previous page as we skipped over at least // one item. - if (arguments.After is not null && fetchCount > 0) + if (arguments.After is not null) { - hasPrevious = true; + hasPrevious = fetchCount > 0; } // if we required the last 5 items of a dataset and over-fetch by 1 // than we have a previous page. - if (arguments.Last is not null && fetchCount > arguments.Last) + if (arguments.Last is not null) { - hasPrevious = true; + hasPrevious = fetchCount > arguments.Last; + nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.Last(), keys.Last()); } // if we request the first 5 items of a dataset with or without cursor // and we over-fetched by 1 item we have a next page. - if (arguments.First is not null && fetchCount > arguments.First) + if (arguments.First is not null) { - hasNext = true; + hasNext = fetchCount > arguments.First; + nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.First(), keys.First()); } // if we fetched anything before an item we know that here is at least one more item. @@ -614,18 +619,21 @@ private static Page CreatePage( items, hasNext, hasPrevious, - (item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(o, p, c)), + (item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst, o, p, c)), index ?? 1, requestedPageSize.Value, - totalCount.Value); + totalCount.Value); } return new Page( items, hasNext, hasPrevious, - item => CursorFormatter.Format(item, keys), + item => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst)), totalCount); + + static bool GetInitialNullsFirst(T item, CursorKey key) + => key.IsNullable && item != null && key.GetValue(item) == null; } private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount) diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs index 0f5deee7605..bbd3c3ef845 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. /// +/// +/// Defines if null values should be considered first in the ordering. +/// 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 666e368ddbd..e6600c5f635 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorFormatter.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorFormatter.cs @@ -58,7 +58,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)'}'; @@ -66,6 +66,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 0c7350dbb9a..f843f0139da 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs @@ -70,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/CursorPageInfo.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs index 1d4f7e349d9..04a42be796e 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. /// + /// Defines if null values should be considered first in the ordering. + public CursorPageInfo(bool nullsFirst) + { + NullsFirst = nullsFirst; + } + + /// + /// Initializes a new instance of the struct. + /// + /// Defines if null values should be considered first in the ordering. /// 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; } + /// + /// Defines if null values should be considered first in the ordering. + /// + 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 b5ce7d667ae..1bf73ffbc6a 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorParser.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorParser.cs @@ -58,7 +58,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++) { @@ -87,7 +87,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) { @@ -137,9 +137,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); @@ -153,7 +158,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/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs index 16d5b3c7a0b..f762b8a6f00 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs @@ -485,8 +485,8 @@ SQL 0 -- @__p_2='11' SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" FROM "Brands" AS b - WHERE b."FoundedDate" >= @__value_0 AND (b."FoundedDate" > @__value_0 OR b."Id" > @__value_1) - ORDER BY b."FoundedDate" IS NULL, b."FoundedDate", b."Id" + WHERE (b."FoundedDate" >= @__value_0 OR b."FoundedDate" IS NULL) AND (b."FoundedDate" > @__value_0 OR b."FoundedDate" IS NULL OR b."Id" > @__value_1) + ORDER BY b."FoundedDate", b."Id" LIMIT @__p_2 --------------- diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET8_0.md index d0c923b24a6..19c8cdb4f30 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET8_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.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__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET9_0.md b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET9_0.md index 03779024056..37791e50acf 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET9_0.md +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.BatchPaging_With_Relative_Cursor_NET9_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.Tests/Cursors/CursorFormatterTests.cs b/src/GreenDonut/test/GreenDonut.Data.Tests/Cursors/CursorFormatterTests.cs index 0047c00b5e1..a823478f008 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() { From facc38523497aa5dc827e5722ad725361c491f92 Mon Sep 17 00:00:00 2001 From: sleepertassly Date: Sat, 12 Apr 2025 22:55:58 +0300 Subject: [PATCH 3/6] Added tests. --- .../RelativeCursorTests.cs | 1592 +++++++++++++---- 1 file changed, 1249 insertions(+), 343 deletions(-) diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs index f762b8a6f00..f2a64ecc292 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs @@ -63,7 +63,7 @@ SQL 0 -- @__value_0='Brightex' -- @__value_1='2' -- @__p_2='3' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" FROM "Brands" AS b WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) ORDER BY b."Name", b."Id" @@ -74,7 +74,7 @@ LIMIT @__p_2 } [Fact] - public async Task BatchFetch_Second_Page() + public async Task Fetch_Second_Page_Ordering_By_Nullable_Columns() { // Arrange @@ -83,15 +83,13 @@ public async Task BatchFetch_Second_Page() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; - var first = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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.Name).ThenBy(t => t.Id) - .ToBatchPageAsync(t => t.GroupId, arguments); - var second = map[1]; + var second = await context.Brands.OrderBy(t => t.CreatedOn).ThenBy(t => t.ModifiedOn).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -122,32 +120,22 @@ 6. Futurova SQL 0 --------------- - -- @__value_0='Brightex' - -- @__value_1='2' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", 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."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" - FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row - FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) - ) AS b2 - WHERE b2.row <= 3 - ) AS b3 ON b1."GroupId" = b3."GroupId" - ORDER BY b1."GroupId", b3."GroupId", b3."Name", b3."Id" + -- @__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_Third_Page_With_Offset_1() + public async Task Fetch_Fourth_Page_With_Offset_1_Ordering_By_Nullable_Date_Column() { // Arrange @@ -156,51 +144,55 @@ public async Task Fetch_Third_Page_With_Offset_1() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; - var first = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 = first.CreateCursor(first.Last!, 1) }; - var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 <- Cursor - 3. Celestara - 4. Dynamova - 5. Evolvance <- Page 3 - Item 1 - 6. Futurova <- Page 3 - Item 2 + 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 = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 3, + "Page": 4, "TotalCount": 20, "Items": [ - "Evolvance", - "Futurova" + "Innovexa", + "Joventra" ] } --------------- SQL 0 --------------- - -- @__value_0='Brightex' - -- @__value_1='2' + -- @__value_0='01/02/2025' (DbType = Date) + -- @__value_1='5' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" FROM "Brands" AS b - WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) - ORDER BY b."Name", b."Id" + 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 --------------- @@ -208,7 +200,7 @@ LIMIT @__p_3 OFFSET @__p_2 } [Fact] - public async Task BatchFetch_Third_Page_With_Offset_1() + public async Task BatchFetch_Second_Page() { // Arrange @@ -222,7 +214,7 @@ public async Task BatchFetch_Third_Page_With_Offset_1() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var second = map[1]; @@ -232,10 +224,10 @@ public async Task BatchFetch_Third_Page_With_Offset_1() /* 1. Aetherix 2. Brightex <- Cursor - 3. Celestara - 4. Dynamova - 5. Evolvance <- Page 3 - Item 1 - 6. Futurova <- Page 3 - Item 2 + 3. Celestara <- Page 2 - Item 1 + 4. Dynamova <- Page 2 - Item 2 + 5. Evolvance + 6. Futurova */ Snapshot.Create() @@ -245,11 +237,11 @@ 6. Futurova <- Page 3 - Item 2 """ --------------- { - "Page": 3, + "Page": 2, "TotalCount": 20, "Items": [ - "Evolvance", - "Futurova" + "Celestara", + "Dynamova" ] } --------------- @@ -258,7 +250,7 @@ SQL 0 --------------- -- @__value_0='Brightex' -- @__value_1='2' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -266,13 +258,13 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row FROM "Brands" AS b0 WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) ) AS b2 - WHERE 2 < b2.row AND b2.row <= 5 + WHERE b2.row <= 3 ) AS b3 ON b1."GroupId" = b3."GroupId" ORDER BY b1."GroupId", b3."GroupId", b3."Name", b3."Id" --------------- @@ -281,7 +273,7 @@ LEFT JOIN ( } [Fact] - public async Task Fetch_Fourth_Page_With_Offset_1() + public async Task BatchFetch_Second_Page_Ordering_By_Nullable_Columns() { // Arrange @@ -290,63 +282,72 @@ public async Task Fetch_Fourth_Page_With_Offset_1() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; - var first = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; - var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 = second.CreateCursor(second.Last!, 1) }; - var fourth = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 - 3. Celestara - 4. Dynamova <- Cursor + 2. Brightex <- Cursor + 3. Celestara <- Page 2 - Item 1 + 4. Dynamova <- Page 2 - Item 2 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() }) + .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 4, + "Page": 2, "TotalCount": 20, "Items": [ - "Glacient", - "Hyperionix" + "Celestara", + "Dynamova" ] } --------------- SQL 0 --------------- - -- @__value_0='Dynamova' - -- @__value_1='4' - -- @__p_3='3' - -- @__p_2='2' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" - FROM "Brands" AS b - WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) - ORDER BY b."Name", b."Id" - LIMIT @__p_3 OFFSET @__p_2 + -- @__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_Fourth_Page_With_Offset_1_Order_By_DisplayName() + public async Task Fetch_Third_Page_With_Offset_1() { // Arrange @@ -355,55 +356,51 @@ public async Task Fetch_Fourth_Page_With_Offset_1_Order_By_DisplayName() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; - var first = await context.Brands.OrderBy(t => t.DisplayName).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; - var second = await context.Brands.OrderBy(t => t.DisplayName).ThenBy(t => t.Id).ToPageAsync(arguments); + var first = await context.Brands.OrderBy(t => t.Name).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.DisplayName).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert /* 1. Aetherix - 2. Brightex + 2. Brightex <- Cursor 3. Celestara - 4. Dynamova <- Cursor - 5. Evolvance - 6. Futurova - 7. Glacient <- Page 4 - Item 1 - 8. Hyperionix <- Page 4 - Item 2 + 4. Dynamova + 5. Evolvance <- Page 3 - Item 1 + 6. Futurova <- Page 3 - Item 2 */ Snapshot.Create() - .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 4, + "Page": 3, "TotalCount": 20, "Items": [ - "Glacient", - "Hyperionix" + "Evolvance", + "Futurova" ] } --------------- SQL 0 --------------- - -- @__value_0='Dynamova' - -- @__value_1='4' + -- @__value_0='Brightex' + -- @__value_1='2' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" FROM "Brands" AS b - WHERE b."DisplayName" >= @__value_0 AND (b."DisplayName" > @__value_0 OR b."Id" > @__value_1) - ORDER BY b."DisplayName", b."Id" + WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) + ORDER BY b."Name", b."Id" LIMIT @__p_3 OFFSET @__p_2 --------------- @@ -411,7 +408,7 @@ LIMIT @__p_3 OFFSET @__p_2 } [Fact] - public async Task Fetch_Second_Page_Order_By_FoundedDate() + public async Task Fetch_Third_Page_With_Offset_1_Ordering_By_Nullable_Columns() { // Arrange @@ -419,39 +416,24 @@ public async Task Fetch_Second_Page_Order_By_FoundedDate() await SeedAsync(connectionString); await using var context = new TestContext(connectionString); - var arguments = new PagingArguments(context.Brands.Count() / 2) { EnableRelativeCursors = true }; - var first = await context.Brands.OrderBy(t => t.FoundedDate).ThenBy(t => t.Id).ToPageAsync(arguments); + 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.FoundedDate).ThenBy(t => t.Id).ToPageAsync(arguments); + 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. Dynamova - 2. Evolvance - 3. Futurova - 4. Glacient - 5. Hyperionix - 6. Innovexa - 7. Joventra - 8. Nebularis - 9. Omniflex - 10. Pulsarix <- Cursor - 11. Quantumis <- Page 2 - Item 1 - 12. Radiantum <- Page 2 - Item 2 - 13. Synerflux <- Page 2 - Item 3 - 14. Vertexis <- Page 2 - Item 4 - 15. Aetherix <- Page 2 - Item 5 - 16. Brightex <- Page 2 - Item 6 - 17. Celestara <- Page 2 - Item 7 - 18. Kinetiq <- Page 2 - Item 8 - 19. Luminara <- Page 2 - Item 9 - 20. Momentumix <- Page 2 - Item 10 + 1. Aetherix + 2. Brightex <- Cursor + 3. Celestara + 4. Dynamova + 5. Evolvance <- Page 3 - Item 1 + 6. Futurova <- Page 3 - Item 2 */ Snapshot.Create() @@ -461,40 +443,34 @@ 20. Momentumix <- Page 2 - Item 10 """ --------------- { - "Page": 2, + "Page": 3, "TotalCount": 20, "Items": [ - "Quantumis", - "Radiantum", - "Synerflux", - "Vertexis", - "Aetherix", - "Brightex", - "Celestara", - "Kinetiq", - "Luminara", - "Momentumix" + "Evolvance", + "Futurova" ] } --------------- SQL 0 --------------- - -- @__value_0='01/10/2025' (DbType = Date) - -- @__value_1='16' - -- @__p_2='11' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + -- @__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."FoundedDate" >= @__value_0 OR b."FoundedDate" IS NULL) AND (b."FoundedDate" > @__value_0 OR b."FoundedDate" IS NULL OR b."Id" > @__value_1) - ORDER BY b."FoundedDate", b."Id" - LIMIT @__p_2 + 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_1() + public async Task BatchFetch_Third_Page_With_Offset_1() { // Arrange @@ -504,74 +480,990 @@ public async Task BatchFetch_Fourth_Page_With_Offset_1() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; var first = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; - var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = second.CreateCursor(second.Last!, 1) }; + arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); - var fourth = map[1]; + var second = map[1]; // Assert /* 1. Aetherix - 2. Brightex + 2. Brightex <- Cursor 3. Celestara - 4. Dynamova <- Cursor - 5. Evolvance - 6. Futurova - 7. Glacient <- Page 4 - Item 1 - 8. Hyperionix <- Page 4 - Item 2 + 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='Brightex' + -- @__value_1='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."Name", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR 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."Name", b3."Id" + --------------- + + """); + } + + [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() + { + // 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.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.Name).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.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + + // Assert + + /* + 1. Aetherix + 2. Brightex + 3. Celestara + 4. Dynamova <- 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='Dynamova' + -- @__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."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) + ORDER BY b."Name", b."Id" + LIMIT @__p_3 OFFSET @__p_2 + --------------- + + """); + } + + [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() + { + // 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.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + var second = await context.Brands.OrderBy(t => t.Name).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.Name).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var fourth = map[1]; + + // Assert + + /* + 1. Aetherix + 2. Brightex + 3. Celestara + 4. Dynamova <- 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='Dynamova' + -- @__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."Name", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR 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."Name", b3."Id" + --------------- + + """); + } + + [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() + { + // 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.Name).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.Name).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='Brightex' + -- @__value_1='2' + -- @__p_3='3' + -- @__p_2='4' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) + ORDER BY b."Name", b."Id" + LIMIT @__p_3 OFFSET @__p_2 + --------------- + + """); + } + + [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() + { + // 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.Name).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.Name).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='Brightex' + -- @__value_1='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."Name", b0."Id") AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) + ) AS b2 + WHERE 4 < b2.row AND b2.row <= 7 + ) AS b3 ON b1."GroupId" = b3."GroupId" + ORDER BY b1."GroupId", b3."GroupId", b3."Name", b3."Id" + --------------- + + """); + } + + [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() + { + // 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.Name).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.Name).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='Synerflux' + -- @__value_1='19' + -- @__p_2='3' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + 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 + --------------- + + """); + } + + [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() + { + // 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.Name).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.Name).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 = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new + { + Page = secondToLast.Index, + secondToLast.TotalCount, + Items = secondToLast.Items.Select(t => t.Name).ToArray() + }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 4, + "Page": 9, "TotalCount": 20, "Items": [ - "Glacient", - "Hyperionix" + "Quantumis", + "Radiantum" ] } --------------- SQL 0 --------------- - -- @__value_0='Dynamova' - -- @__value_1='4' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" + -- @__value_0='Synerflux' + -- @__value_1='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" = 1 + WHERE b."GroupId" = 2 GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) + WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 - WHERE 2 < b2.row AND b2.row <= 5 + WHERE b2.row <= 3 ) AS b3 ON b1."GroupId" = b3."GroupId" - ORDER BY b1."GroupId", b3."GroupId", b3."Name", b3."Id" + ORDER BY b1."GroupId", b3."GroupId", b3."Name" DESC, b3."Id" DESC --------------- """); } [Fact] - public async Task Fetch_Fourth_Page_With_Offset_2() + public async Task BatchFetch_Second_To_Last_Page_Offset_0_Ordering_By_Nullable_Columns() { // Arrange @@ -579,62 +1471,79 @@ public async Task Fetch_Fourth_Page_With_Offset_2() 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.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 { After = first.CreateCursor(first.Last!, 2) }; - var fourth = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 /* - 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 + 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 = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new + { + Page = secondToLast.Index, + secondToLast.TotalCount, + Items = secondToLast.Items.Select(t => t.Name).ToArray() + }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 4, + "Page": 9, "TotalCount": 20, "Items": [ - "Glacient", - "Hyperionix" + "Quantumis", + "Radiantum" ] } --------------- SQL 0 --------------- - -- @__value_0='Brightex' - -- @__value_1='2' - -- @__p_3='3' - -- @__p_2='4' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" - FROM "Brands" AS b - WHERE b."Name" >= @__value_0 AND (b."Name" > @__value_0 OR b."Id" > @__value_1) - ORDER BY b."Name", b."Id" - LIMIT @__p_3 OFFSET @__p_2 + -- @__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 BatchFetch_Fourth_Page_With_Offset_2() + public async Task Fetch_Third_To_Last_Page_Offset_Negative_1() { // Arrange @@ -642,74 +1551,66 @@ public async Task BatchFetch_Fourth_Page_With_Offset_2() 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.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; + var last = await context.Brands.OrderBy(t => t.Name).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.Name).ThenBy(t => t.Id) - .ToBatchPageAsync(t => t.GroupId, arguments); - var fourth = map[1]; + arguments = arguments with { Before = last.CreateCursor(last.First!, -1) }; + var thirdToLast = await context.Brands.OrderBy(t => t.Name).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 + 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 = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new + { + Page = thirdToLast.Index, + thirdToLast.TotalCount, + Items = thirdToLast.Items.Select(t => t.Name).ToArray() + }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 4, + "Page": 8, "TotalCount": 20, "Items": [ - "Glacient", - "Hyperionix" + "Omniflex", + "Pulsarix" ] } --------------- SQL 0 --------------- - -- @__value_0='Brightex' - -- @__value_1='2' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", 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."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" - FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name", b0."Id") AS row - FROM "Brands" AS b0 - WHERE b0."GroupId" = 1 AND b0."Name" >= @__value_0 AND (b0."Name" > @__value_0 OR b0."Id" > @__value_1) - ) AS b2 - WHERE 4 < b2.row AND b2.row <= 7 - ) AS b3 ON b1."GroupId" = b3."GroupId" - ORDER BY b1."GroupId", b3."GroupId", b3."Name", b3."Id" + -- @__value_0='Synerflux' + -- @__value_1='19' + -- @__p_3='3' + -- @__p_2='2' + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" + FROM "Brands" AS b + 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_3 OFFSET @__p_2 --------------- """); } [Fact] - public async Task Fetch_Second_To_Last_Page_Offset_0() + public async Task Fetch_Third_To_Last_Page_Offset_Negative_1_Ordering_By_Nullable_Columns() { // Arrange @@ -718,22 +1619,22 @@ public async Task Fetch_Second_To_Last_Page_Offset_0() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; - var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 - 16. Pulsarix - 17. Quantumis <- Selected - Item 1 - 18. Radiantum <- Selected - Item 2 + 15. Omniflex <- Selected - Item 1 + 16. Pulsarix <- Selected - Item 2 + 17. Quantumis + 18. Radiantum 19. Synerflux <- Cursor 20. Vertexis */ @@ -741,41 +1642,43 @@ 20. Vertexis Snapshot.Create() .Add(new { - Page = secondToLast.Index, - secondToLast.TotalCount, - Items = secondToLast.Items.Select(t => t.Name).ToArray() + Page = thirdToLast.Index, + thirdToLast.TotalCount, + Items = thirdToLast.Items.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 9, + "Page": 8, "TotalCount": 20, "Items": [ - "Quantumis", - "Radiantum" + "Omniflex", + "Pulsarix" ] } --------------- SQL 0 --------------- - -- @__value_0='Synerflux' - -- @__value_1='19' - -- @__p_2='3' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + -- @__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."Name" <= @__value_0 AND (b."Name" < @__value_0 OR b."Id" < @__value_1) - ORDER BY b."Name" DESC, b."Id" DESC - LIMIT @__p_2 + 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 BatchFetch_Second_To_Last_Page_Offset_0() + public async Task Fetch_Fourth_To_Last_Page_Offset_Negative_1_Ordering_By_Nullable_Date_Column() { // Arrange @@ -784,41 +1687,44 @@ public async Task BatchFetch_Second_To_Last_Page_Offset_0() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; - var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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 = last.CreateCursor(last.First!, 0) }; - var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.Id) - .ToBatchPageAsync(t => t.GroupId, arguments); - var secondToLast = map[2]; + 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 /* - 14. Nebularis - 15. Omniflex - 16. Pulsarix - 17. Quantumis <- Selected - Item 1 - 18. Radiantum <- Selected - Item 2 - 19. Synerflux <- Cursor + 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 = secondToLast.Index, - secondToLast.TotalCount, - Items = secondToLast.Items.Select(t => t.Name).ToArray() + Page = fourthToLast.Index, + fourthToLast.TotalCount, + Items = fourthToLast.Items.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchInline( """ --------------- { - "Page": 9, + "Page": 7, "TotalCount": 20, "Items": [ "Quantumis", @@ -829,32 +1735,21 @@ 20. Vertexis SQL 0 --------------- - -- @__value_0='Synerflux' - -- @__value_1='19' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", 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."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" - FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row - FROM "Brands" AS b0 - WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) - ) AS b2 - WHERE b2.row <= 3 - ) AS b3 ON b1."GroupId" = b3."GroupId" - ORDER BY b1."GroupId", b3."GroupId", b3."Name" DESC, b3."Id" DESC + -- @__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 Fetch_Third_To_Last_Page_Offset_Negative_1() + public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1() { // Arrange @@ -869,7 +1764,9 @@ public async Task Fetch_Third_To_Last_Page_Offset_Negative_1() using var capture = new CapturePagingQueryInterceptor(); arguments = arguments with { Before = last.CreateCursor(last.First!, -1) }; - var thirdToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.Id) + .ToBatchPageAsync(t => t.GroupId, arguments); + var thirdToLast = map[2]; // Assert @@ -908,20 +1805,30 @@ SQL 0 --------------- -- @__value_0='Synerflux' -- @__value_1='19' - -- @__p_3='3' - -- @__p_2='2' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" - FROM "Brands" AS b - 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_3 OFFSET @__p_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" = 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."Name" DESC, b0."Id" DESC) AS row + FROM "Brands" AS b0 + WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR 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."Name" DESC, b3."Id" DESC --------------- """); } [Fact] - public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1() + public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1_Ordering_By_Nullable_Columns() { // Arrange @@ -930,13 +1837,13 @@ public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; - var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); + 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.Name).ThenBy(t => t.Id) + 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]; @@ -975,9 +1882,10 @@ 20. Vertexis SQL 0 --------------- - -- @__value_0='Synerflux' - -- @__value_1='19' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" + -- @__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 @@ -985,15 +1893,15 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + 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."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) + 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."Name" DESC, b3."Id" DESC + ORDER BY b1."GroupId", b3."GroupId", b3."CreatedOn" DESC, b3."ModifiedOn" DESC, b3."Id" DESC --------------- """); @@ -1059,7 +1967,7 @@ SQL 0 -- @__value_1='19' -- @__p_3='3' -- @__p_2='4' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" FROM "Brands" AS b WHERE b."Name" <= @__value_0 AND (b."Name" < @__value_0 OR b."Id" < @__value_1) ORDER BY b."Name" DESC, b."Id" DESC @@ -1129,7 +2037,7 @@ SQL 0 --------------- -- @__value_0='Synerflux' -- @__value_1='19' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -1137,9 +2045,9 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 @@ -1213,7 +2121,7 @@ SQL 0 -- @__value_1='17' -- @__p_3='3' -- @__p_2='2' - SELECT b."Id", b."DisplayName", b."FoundedDate", b."GroupId", b."IsActive", b."Name" + SELECT b."Id", b."CreatedOn", b."GroupId", b."ModifiedOn", b."Name" FROM "Brands" AS b WHERE b."Name" <= @__value_0 AND (b."Name" < @__value_0 OR b."Id" < @__value_1) ORDER BY b."Name" DESC, b."Id" DESC @@ -1285,7 +2193,7 @@ SQL 0 --------------- -- @__value_0='Quantumis' -- @__value_1='17' - SELECT b1."GroupId", b3."Id", b3."DisplayName", b3."FoundedDate", b3."GroupId", b3."IsActive", b3."Name" + SELECT b1."GroupId", b3."Id", b3."CreatedOn", b3."GroupId", b3."ModifiedOn", b3."Name" FROM ( SELECT b."GroupId" FROM "Brands" AS b @@ -1293,9 +2201,9 @@ SELECT b."GroupId" GROUP BY b."GroupId" ) AS b1 LEFT JOIN ( - SELECT b2."Id", b2."DisplayName", b2."FoundedDate", b2."GroupId", b2."IsActive", b2."Name" + SELECT b2."Id", b2."CreatedOn", b2."GroupId", b2."ModifiedOn", b2."Name" FROM ( - SELECT b0."Id", b0."DisplayName", b0."FoundedDate", b0."GroupId", b0."IsActive", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row + SELECT b0."Id", b0."CreatedOn", b0."GroupId", b0."ModifiedOn", b0."Name", ROW_NUMBER() OVER(PARTITION BY b0."GroupId" ORDER BY b0."Name" DESC, b0."Id" DESC) AS row FROM "Brands" AS b0 WHERE b0."GroupId" = 2 AND b0."Name" <= @__value_0 AND (b0."Name" < @__value_0 OR b0."Id" < @__value_1) ) AS b2 @@ -1380,7 +2288,7 @@ public async Task Nullable_Fallback_Cursor() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; - var first = await context.Brands.OrderBy(t => t.FoundedDate).ToPageAsync(arguments); + var first = await context.Brands.OrderBy(t => t.ModifiedOn).ToPageAsync(arguments); // Act @@ -1389,7 +2297,7 @@ public async Task Nullable_Fallback_Cursor() async Task Error() { await using var ctx = new TestContext(connectionString); - await ctx.Brands.OrderBy(t => t.FoundedDate).ToPageAsync(arguments); + await ctx.Brands.OrderBy(t => t.ModifiedOn).ToPageAsync(arguments); } // Assert @@ -1425,27 +2333,27 @@ 19. Synerflux 20. Vertexis */ - context.Brands.Add(new Brand { Name = "Aetherix", GroupId = 1, DisplayName = "Aetherix" }); - context.Brands.Add(new Brand { Name = "Brightex", GroupId = 1, DisplayName = "Brightex", IsActive = false }); - context.Brands.Add(new Brand { Name = "Celestara", GroupId = 1, DisplayName = "Celestara", IsActive = true }); - context.Brands.Add(new Brand { Name = "Dynamova", GroupId = 1, DisplayName = "Dynamova", IsActive = true, FoundedDate = new DateOnly(2025, 1, 1) }); - context.Brands.Add(new Brand { Name = "Evolvance", GroupId = 1, DisplayName = "Evolvance", IsActive = true, FoundedDate = new DateOnly(2025, 1, 2) }); - context.Brands.Add(new Brand { Name = "Futurova", GroupId = 1, DisplayName = "Futurova", IsActive = true, FoundedDate = new DateOnly(2025, 1, 3) }); - context.Brands.Add(new Brand { Name = "Glacient", GroupId = 1, DisplayName = "Glacient", IsActive = true, FoundedDate = new DateOnly(2025, 1, 4) }); - context.Brands.Add(new Brand { Name = "Hyperionix", GroupId = 1, DisplayName = "Hyperionix", IsActive = true, FoundedDate = new DateOnly(2025, 1, 5) }); - context.Brands.Add(new Brand { Name = "Innovexa", GroupId = 1, DisplayName = "Innovexa", IsActive = true, FoundedDate = new DateOnly(2025, 1, 6) }); - context.Brands.Add(new Brand { Name = "Joventra", GroupId = 1, DisplayName = "Joventra", IsActive = true, FoundedDate = new DateOnly(2025, 1, 7) }); - - context.Brands.Add(new Brand { Name = "Kinetiq", GroupId = 2, DisplayName = "Kinetiq" }); - context.Brands.Add(new Brand { Name = "Luminara", GroupId = 2, DisplayName = "Luminara", IsActive = false }); - context.Brands.Add(new Brand { Name = "Momentumix", GroupId = 2, DisplayName = "Momentumix", IsActive = true }); - context.Brands.Add(new Brand { Name = "Nebularis", GroupId = 2, DisplayName = "Nebularis", IsActive = true, FoundedDate = new DateOnly(2025, 1, 8) }); - context.Brands.Add(new Brand { Name = "Omniflex", GroupId = 2, DisplayName = "Omniflex", IsActive = true, FoundedDate = new DateOnly(2025, 1, 9) }); - context.Brands.Add(new Brand { Name = "Pulsarix", GroupId = 2, DisplayName = "Pulsarix", IsActive = true, FoundedDate = new DateOnly(2025, 1, 10) }); - context.Brands.Add(new Brand { Name = "Quantumis", GroupId = 2, DisplayName = "Quantumis", IsActive = true, FoundedDate = new DateOnly(2025, 1, 11) }); - context.Brands.Add(new Brand { Name = "Radiantum", GroupId = 2, DisplayName = "Radiantum", IsActive = true, FoundedDate = new DateOnly(2025, 1, 12) }); - context.Brands.Add(new Brand { Name = "Synerflux", GroupId = 2, DisplayName = "Synerflux", IsActive = true, FoundedDate = new DateOnly(2025, 1, 13) }); - context.Brands.Add(new Brand { Name = "Vertexis", GroupId = 2, DisplayName = "Vertexis", IsActive = true, FoundedDate = new DateOnly(2025, 1, 14) }); + 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(); } @@ -1468,11 +2376,9 @@ public class Brand [MaxLength(100)] public required string Name { get; set; } - public string? DisplayName { get; set; } = default!; - - public bool? IsActive { get; set; } + public DateOnly CreatedOn { get; set; } - public DateOnly? FoundedDate { get; set; } + public DateOnly? ModifiedOn { get; set; } } } #endif From 0fba235b82ddaa471f2e4ec56f7434a31daf9a4d Mon Sep 17 00:00:00 2001 From: sleepertassly Date: Sun, 13 Apr 2025 01:25:10 +0300 Subject: [PATCH 4/6] Bugfix: The NullFirst flag must be determined based on all nullable keys. --- .../Expressions/ExpressionHelpers.cs | 2 +- .../Extensions/PagingQueryableExtensions.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index 1f7eec2a29d..9cc0b4854ce 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -86,7 +86,7 @@ public static (Expression> WhereExpression, int Offset) BuildWhere // 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) + if (cursor.NullsFirst) { expression = BuildNullsFirstExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo); } diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index bc42b325fbb..5456fd707de 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -596,7 +596,7 @@ private static Page CreatePage( if (arguments.Last is not null) { hasPrevious = fetchCount > arguments.Last; - nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.Last(), keys.Last()); + nullsFirst = previousNullsFirst ?? !AnyNullKeyValue(items.Last(), keys); } // if we request the first 5 items of a dataset with or without cursor @@ -604,7 +604,7 @@ private static Page CreatePage( if (arguments.First is not null) { hasNext = fetchCount > arguments.First; - nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.First(), keys.First()); + nullsFirst = previousNullsFirst ?? AnyNullKeyValue(items.First(), keys); } // if we fetched anything before an item we know that here is at least one more item. @@ -632,8 +632,8 @@ private static Page CreatePage( item => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst)), totalCount); - static bool GetInitialNullsFirst(T item, CursorKey key) - => key.IsNullable && item != null && key.GetValue(item) == null; + static bool AnyNullKeyValue(T item, IReadOnlyList keys) + => keys.Any(key => key.IsNullable && item != null && key.GetValue(item) == null); } private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount) From 9636c9fc6725bd219ddf7c79550bf47b76894ac6 Mon Sep 17 00:00:00 2001 From: sleepertassly Date: Fri, 9 May 2025 21:08:08 +0300 Subject: [PATCH 5/6] Revert "Bugfix: The NullFirst flag must be determined based on all nullable keys." This reverts commit 0fba235b82ddaa471f2e4ec56f7434a31daf9a4d. --- .../Expressions/ExpressionHelpers.cs | 2 +- .../Extensions/PagingQueryableExtensions.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index 9cc0b4854ce..1f7eec2a29d 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -86,7 +86,7 @@ public static (Expression> WhereExpression, int Offset) BuildWhere // 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 (cursor.NullsFirst) + if (i == 0 && cursor.NullsFirst) { expression = BuildNullsFirstExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo); } diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index 5456fd707de..bc42b325fbb 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -596,7 +596,7 @@ private static Page CreatePage( if (arguments.Last is not null) { hasPrevious = fetchCount > arguments.Last; - nullsFirst = previousNullsFirst ?? !AnyNullKeyValue(items.Last(), keys); + nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.Last(), keys.Last()); } // if we request the first 5 items of a dataset with or without cursor @@ -604,7 +604,7 @@ private static Page CreatePage( if (arguments.First is not null) { hasNext = fetchCount > arguments.First; - nullsFirst = previousNullsFirst ?? AnyNullKeyValue(items.First(), keys); + nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.First(), keys.First()); } // if we fetched anything before an item we know that here is at least one more item. @@ -632,8 +632,8 @@ private static Page CreatePage( item => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst)), totalCount); - static bool AnyNullKeyValue(T item, IReadOnlyList keys) - => keys.Any(key => key.IsNullable && item != null && key.GetValue(item) == null); + static bool GetInitialNullsFirst(T item, CursorKey key) + => key.IsNullable && item != null && key.GetValue(item) == null; } private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount) From 80babe9e2caa80b0da2ba0876418b5d6ca0f5f49 Mon Sep 17 00:00:00 2001 From: sleepertassly Date: Sun, 15 Jun 2025 23:54:15 +0300 Subject: [PATCH 6/6] WIP - the ExpressionHelpers.ApplyCursorKeyOrdering is not finished. --- Old description.txt | 50 ++++++ .../Expressions/ExpressionHelpers.cs | 166 ++++++++++++------ .../Extensions/PagingQueryableExtensions.cs | 73 +++----- .../src/GreenDonut.Data.Primitives/Page.cs | 10 +- .../src/GreenDonut.Data/Cursors/Cursor.cs | 2 +- .../Cursors/CursorKeyExtractor.cs | 82 +++++++++ .../GreenDonut.Data/Cursors/CursorPageInfo.cs | 6 +- 7 files changed, 274 insertions(+), 115 deletions(-) create mode 100644 Old description.txt create mode 100644 src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyExtractor.cs 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 1f7eec2a29d..d43090c6a82 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; using System.Runtime.InteropServices; @@ -40,7 +41,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) @@ -101,7 +102,7 @@ public static (Expression> WhereExpression, int Offset) BuildWhere } } - return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0); + return Expression.Lambda>(expression!, parameter); static Expression BuildNullsFirstExpression( Expression previousExpr, @@ -259,12 +260,6 @@ static Expression BuildNonNullExpression( /// /// 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. /// @@ -284,8 +279,6 @@ static Expression BuildNonNullExpression( public static BatchExpression BuildBatchExpression( PagingArguments arguments, ReadOnlySpan keys, - ReadOnlySpan orderExpressions, - ReadOnlySpan orderMethods, bool forward, ref int requestedCount) { @@ -296,33 +289,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; @@ -330,9 +300,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) { @@ -350,9 +319,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) @@ -425,22 +393,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); } /// @@ -488,6 +440,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", new Type[] { typeof(T), typeof(bool) }, expression, nullCheckLambda) + : Expression.Call(typeof(Queryable), "ThenBy", new Type[] { 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 bc42b325fbb..7db033f6542 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,18 +219,13 @@ 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); - return CreatePage(builder.ToImmutable(), arguments, keys, cursor?.NullsFirst, fetchCount, pageIndex, requestedCount, totalCount); + return CreatePage(builder.ToImmutable(), arguments, keys, fetchCount, pageIndex, requestedCount, 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)); @@ -503,7 +482,6 @@ public static async ValueTask>> ToBatchPageAsync CreatePage( ImmutableArray items, PagingArguments arguments, CursorKey[] keys, - bool? previousNullsFirst, int fetchCount, int? index, int? requestedPageSize, @@ -581,30 +558,27 @@ private static Page CreatePage( { var hasPrevious = false; var hasNext = false; - var nullsFirst = false; // if we skipped over an item, and we have fetched some items // than we have a previous page as we skipped over at least // one item. - if (arguments.After is not null) + if (arguments.After is not null && fetchCount > 0) { - hasPrevious = fetchCount > 0; + hasPrevious = true; } // if we required the last 5 items of a dataset and over-fetch by 1 // than we have a previous page. - if (arguments.Last is not null) + if (arguments.Last is not null && fetchCount > arguments.Last) { - hasPrevious = fetchCount > arguments.Last; - nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.Last(), keys.Last()); + hasPrevious = true; } // if we request the first 5 items of a dataset with or without cursor // and we over-fetched by 1 item we have a next page. - if (arguments.First is not null) + if (arguments.First is not null && fetchCount > arguments.First) { - hasNext = fetchCount > arguments.First; - nullsFirst = previousNullsFirst ?? GetInitialNullsFirst(items.First(), keys.First()); + hasNext = true; } // if we fetched anything before an item we know that here is at least one more item. @@ -619,21 +593,18 @@ private static Page CreatePage( items, hasNext, hasPrevious, - (item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst, o, p, c)), + (item, n, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(n, o, p, c)), index ?? 1, requestedPageSize.Value, - totalCount.Value); + totalCount.Value); } return new Page( items, hasNext, hasPrevious, - item => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst)), + item => CursorFormatter.Format(item, keys), totalCount); - - static bool GetInitialNullsFirst(T item, CursorKey key) - => key.IsNullable && item != null && key.GetValue(item) == null; } private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount) @@ -680,11 +651,11 @@ static bool GetInitialNullsFirst(T item, CursorKey key) 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 147eae62796..00e5a923188 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 bbd3c3ef845..cf02c7bbbff 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs @@ -20,7 +20,7 @@ namespace GreenDonut.Data.Cursors; /// The total number of items in the dataset, if known. Can be null if not available. /// /// -/// Defines if null values should be considered first in the ordering. +/// Determines whether null values should appear first in the sort order. /// public record Cursor( ImmutableArray Values, 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..7a36f755193 --- /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 = new(); + + 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 04a42be796e..3771f9fc5e1 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/CursorPageInfo.cs @@ -8,7 +8,7 @@ public readonly ref struct CursorPageInfo /// /// Initializes a new instance of the struct. /// - /// Defines if null values should be considered first in the ordering. + /// Determines whether null values should appear first in the sort order. public CursorPageInfo(bool nullsFirst) { NullsFirst = nullsFirst; @@ -17,7 +17,7 @@ public CursorPageInfo(bool nullsFirst) /// /// Initializes a new instance of the struct. /// - /// Defines if null values should be considered first in the ordering. + /// 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. @@ -59,7 +59,7 @@ public CursorPageInfo(bool nullsFirst, int offset, int pageIndex, int totalCount public int TotalCount { get; } /// - /// Defines if null values should be considered first in the ordering. + /// Determines whether null values should appear first in the sort order. /// public bool NullsFirst { get; }