diff --git a/Old description.txt b/Old description.txt
new file mode 100644
index 00000000000..800506fb736
--- /dev/null
+++ b/Old description.txt
@@ -0,0 +1,50 @@
+## π§ Paging Cursor Enhancements: Nullable Keys and Null Ordering Support
+
+### Summary
+
+This PR introduces enhancements to the paging logic to correctly handle sorting and filtering when cursor keys are nullable. The main change is that the `WHERE` condition used in pagination must now adapt based on **three factors**:
+
+1. Whether the **cursor key** is nullable.
+2. Whether the **actual cursor value** is `null` or not.
+3. Whether the **database** sorts `null` values **first** or **last**.
+
+---
+
+### π Why This Is Needed
+
+When performing cursor-based pagination on fields that can be `null`, it's essential to construct the `WHERE` clause correctly. Incorrect assumptions about `null` ordering or the presence of `null` values can result in missing or duplicated records across pages.
+
+---
+
+### π§ Implementation Details
+
+To support this behavior, the following changes were made:
+
+- **Cursor Key Metadata**
+ - Cursor keys now include a new property (`IsNullable`) to indicate whether they are nullable.
+
+- **Null Ordering in Cursor**
+ - Cursors now carry a `NullsFirst` flag to indicate how `null` values are ordered.
+ - For subsequent pages, this flag is **inherited** from the previous cursor.
+ - For the **first(last) page**, the flag is **calculated** based on whether the **first(last) item** contains a `null` value:
+ - If a `null` is found β we can determine the null ordering: nulls first(nulls last).
+ - If not β we assume the opposite ordering.
+ - If this assumption is incorrect, it does **not affect correctness**, because it implies there are **no `null` values** in the dataset, making null ordering irrelevant.
+
+> The concept of handling nullable types in relative cursor-based pagination is inspired by [[this StackOverflow discussion](https://stackoverflow.com/questions/68971695/cursor-pagination-prev-next-with-null-values)].
+
+---
+
+### β
Benefits
+
+- Correct pagination over nullable fields.
+- Eliminates edge cases where `null` records might be skipped or duplicated.
+- Supports both `nulls first` and `nulls last` configurations in the database.
+
+---
+
+### β οΈ Side Effects / Limitations
+
+If our assumption about null ordering is incorrect, **secondary ordering keys** may appear to be sorted in reverse (opposite to the database's actual null handling behavior), particularly when paginating **backward from the last page**.
+
+This only affects the **visual or logical order** of `null` values relative to each other and **does not break pagination**βall items will still be included in the correct pages.
diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs
index 8e74d9da3d5..76e92032abe 100644
--- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs
+++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs
@@ -40,7 +40,7 @@ internal static class ExpressionHelpers
///
/// If the number of keys does not match the number of values.
///
- public static (Expression> WhereExpression, int Offset) BuildWhereExpression(
+ public static Expression> BuildWhereExpression(
ReadOnlySpan keys,
Cursor cursor,
bool forward)
@@ -58,50 +58,195 @@ public static (Expression> WhereExpression, int Offset) BuildWhere
var cursorExpr = new Expression[cursor.Values.Length];
for (var i = 0; i < cursor.Values.Length; i++)
{
- cursorExpr[i] = CreateParameter(cursor.Values[i], keys[i].Expression.ReturnType);
+ cursorExpr[i] = CreateParameter(cursor.Values[i], keys[i].CompareMethod.Type);
}
- var handled = new List();
Expression? expression = null;
var parameter = Expression.Parameter(typeof(T), "t");
var zero = Expression.Constant(0);
- for (var i = 0; i < keys.Length; i++)
+ for (var i = keys.Length - 1; i >= 0; i--)
{
var key = keys[i];
- Expression? current = null;
- Expression keyExpr;
- for (var j = 0; j < handled.Count; j++)
+ var greaterThan = forward
+ ? key.Direction == CursorKeyDirection.Ascending
+ : key.Direction == CursorKeyDirection.Descending;
+
+ Expression keyExpr = ReplaceParameter(key.Expression, parameter);
+
+ if (key.IsNullable)
+ {
+ if (expression is null)
+ {
+ throw new ArgumentException("The last key must be non-nullable.", nameof(keys));
+ }
+
+ // To avoid skipping any rows, NULL values are significant for the primary sorting condition.
+ // For all secondary sorting conditions, NULL values are treated as last,
+ // ensuring consistent behavior across different databases.
+ if (i == 0 && cursor.NullsFirst)
+ {
+ expression = BuildNullsFirstExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo);
+ }
+ else
+ {
+ expression = BuildNullsLastExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo);
+ }
+ }
+ else
{
- var handledKey = handled[j];
+ expression = BuildNonNullExpression(expression, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo);
+ }
+ }
- keyExpr = Expression.Equal(
- Expression.Call(ReplaceParameter(handledKey.Expression, parameter), handledKey.CompareMethod,
- cursorExpr[j]), zero);
+ return Expression.Lambda>(expression!, parameter);
- current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr);
+ static Expression BuildNullsFirstExpression(
+ Expression previousExpr,
+ object? keyValue,
+ Expression keyExpr,
+ Expression cursorExpr,
+ bool greaterThan,
+ MethodInfo compareMethod)
+ {
+ Expression mainKeyExpr, secondaryKeyExpr;
+
+ var zero = Expression.Constant(0);
+ var nullConstant = Expression.Constant(null, keyExpr.Type);
+
+ if (keyValue is null)
+ {
+ if (greaterThan)
+ {
+ mainKeyExpr = Expression.Equal(keyExpr, nullConstant);
+
+ secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant);
+
+ return Expression.OrElse(secondaryKeyExpr, Expression.AndAlso(mainKeyExpr, previousExpr));
+ }
+ else
+ {
+ mainKeyExpr = Expression.Equal(keyExpr, nullConstant);
+
+ return Expression.AndAlso(mainKeyExpr, previousExpr);
+ }
}
+ else
+ {
+ var nonNullKeyExpr = Expression.Property(keyExpr, "Value");
+ var isNullExpression = Expression.Equal(keyExpr, nullConstant);
+
+ mainKeyExpr = greaterThan
+ ? Expression.GreaterThan(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero)
+ : Expression.OrElse(
+ Expression.LessThan(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero), isNullExpression);
+
+ secondaryKeyExpr = greaterThan
+ ? Expression.GreaterThanOrEqual(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero)
+ : Expression.OrElse(
+ Expression.LessThanOrEqual(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero), isNullExpression);
+
+ return Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr));
+ }
+ }
- var greaterThan = forward
- ? key.Direction == CursorKeyDirection.Ascending
- : key.Direction == CursorKeyDirection.Descending;
+ static Expression BuildNullsLastExpression(
+ Expression previousExpr,
+ object? keyValue,
+ Expression keyExpr,
+ Expression cursorExpr,
+ bool greaterThan,
+ MethodInfo compareMethod)
+ {
+ Expression mainKeyExpr, secondaryKeyExpr;
+
+ var zero = Expression.Constant(0);
+ var nullConstant = Expression.Constant(null, keyExpr.Type);
+
+ if (keyValue is null)
+ {
+ if (greaterThan)
+ {
+ mainKeyExpr = Expression.Equal(keyExpr, nullConstant);
+
+ return Expression.AndAlso(mainKeyExpr, previousExpr);
+ }
+ else
+ {
+ mainKeyExpr = Expression.Equal(keyExpr, nullConstant);
- keyExpr = greaterThan
+ secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant);
+
+ return Expression.OrElse(secondaryKeyExpr, Expression.AndAlso(mainKeyExpr, previousExpr));
+ }
+ }
+ else
+ {
+ var nonNullKeyExpr = Expression.Property(keyExpr, "Value");
+ var isNullExpression = Expression.Equal(keyExpr, nullConstant);
+
+ mainKeyExpr = greaterThan
+ ? Expression.OrElse(
+ Expression.GreaterThan(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero), isNullExpression)
+ : Expression.LessThan(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero);
+
+ secondaryKeyExpr = greaterThan
+ ? Expression.OrElse(
+ Expression.GreaterThanOrEqual(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero), isNullExpression)
+ : Expression.LessThanOrEqual(
+ Expression.Call(nonNullKeyExpr, compareMethod, cursorExpr),
+ zero);
+
+ return Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr));
+ }
+ }
+
+ static Expression BuildNonNullExpression(
+ Expression? previousExpr,
+ object? keyValue,
+ Expression keyExpr,
+ Expression cursorExpr,
+ bool greaterThan,
+ MethodInfo compareMethod)
+ {
+ var zero = Expression.Constant(0);
+ Expression mainKeyExpr, secondaryKeyExpr;
+
+ mainKeyExpr = greaterThan
? Expression.GreaterThan(
- Expression.Call(ReplaceParameter(key.Expression, parameter), key.CompareMethod, cursorExpr[i]),
- zero)
+ Expression.Call(keyExpr, compareMethod, cursorExpr),
+ zero)
: Expression.LessThan(
- Expression.Call(ReplaceParameter(key.Expression, parameter), key.CompareMethod, cursorExpr[i]),
+ Expression.Call(keyExpr, compareMethod, cursorExpr),
+ zero);
+
+ secondaryKeyExpr = greaterThan
+ ? Expression.GreaterThanOrEqual(
+ Expression.Call(keyExpr, compareMethod, cursorExpr),
+ zero)
+ : Expression.LessThanOrEqual(
+ Expression.Call(keyExpr, compareMethod, cursorExpr),
zero);
- current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr);
- expression = expression is null ? current : Expression.OrElse(expression, current);
- handled.Add(key);
+ return previousExpr is null ? mainKeyExpr :
+ Expression.AndAlso(secondaryKeyExpr, Expression.OrElse(mainKeyExpr, previousExpr));
}
-
- return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0);
}
///
@@ -113,12 +258,6 @@ public static (Expression> WhereExpression, int Offset) BuildWhere
///
/// The key definitions that represent the cursor.
///
- ///
- /// The order expressions that are used to sort the dataset.
- ///
- ///
- /// The order methods that are used to sort the dataset.
- ///
///
/// Defines how the dataset is sorted.
///
@@ -138,8 +277,6 @@ public static (Expression> WhereExpression, int Offset) BuildWhere
public static BatchExpression BuildBatchExpression(
PagingArguments arguments,
ReadOnlySpan keys,
- ReadOnlySpan orderExpressions,
- ReadOnlySpan orderMethods,
bool forward,
ref int requestedCount)
{
@@ -150,33 +287,10 @@ public static BatchExpression BuildBatchExpression(
nameof(keys));
}
- if (orderExpressions.Length != orderMethods.Length)
- {
- throw new ArgumentException(
- "The number of order expressions must match the number of order methods.",
- nameof(orderExpressions));
- }
-
var group = Expression.Parameter(typeof(IGrouping), "g");
var groupKey = Expression.Property(group, "Key");
Expression source = group;
- for (var i = 0; i < orderExpressions.Length; i++)
- {
- var methodName = forward ? orderMethods[i] : ReverseOrder(orderMethods[i]);
- var orderExpression = orderExpressions[i];
- var delegateType = typeof(Func<,>).MakeGenericType(typeof(TV), orderExpression.Body.Type);
- var typedOrderExpression =
- Expression.Lambda(delegateType, orderExpression.Body, orderExpression.Parameters);
-
- var method = GetEnumerableMethod(methodName, typeof(TV), typedOrderExpression);
-
- source = Expression.Call(
- method,
- source,
- typedOrderExpression);
- }
-
var offset = 0;
var usesRelativeCursors = false;
Cursor? cursor = null;
@@ -184,9 +298,8 @@ public static BatchExpression BuildBatchExpression(
if (arguments.After is not null)
{
cursor = CursorParser.Parse(arguments.After, keys);
- var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, forward: true);
- source = Expression.Call(typeof(Enumerable), "Where", [typeof(TV)], source, whereExpr);
- offset = cursorOffset;
+ source = ApplyCursorPagination(source, keys, cursor, forward: true);
+ offset = cursor.Offset ?? 0;
if (cursor.IsRelative)
{
@@ -204,9 +317,8 @@ public static BatchExpression BuildBatchExpression(
}
cursor = CursorParser.Parse(arguments.Before, keys);
- var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, forward: false);
- source = Expression.Call(typeof(Enumerable), "Where", [typeof(TV)], source, whereExpr);
- offset = cursorOffset;
+ source = ApplyCursorPagination(source, keys, cursor, forward: false);
+ offset = cursor.Offset ?? 0;
}
if (arguments.First is not null)
@@ -279,22 +391,6 @@ public static BatchExpression BuildBatchExpression(
Expression.Lambda, Group>>(createGroup, group),
arguments.Last is not null,
cursor);
-
- static string ReverseOrder(string method)
- => method switch
- {
- nameof(Queryable.OrderBy) => nameof(Queryable.OrderByDescending),
- nameof(Queryable.OrderByDescending) => nameof(Queryable.OrderBy),
- nameof(Queryable.ThenBy) => nameof(Queryable.ThenByDescending),
- nameof(Queryable.ThenByDescending) => nameof(Queryable.ThenBy),
- _ => method
- };
-
- static MethodInfo GetEnumerableMethod(string methodName, Type elementType, LambdaExpression keySelector)
- => typeof(Enumerable)
- .GetMethods(BindingFlags.Static | BindingFlags.Public)
- .First(m => m.Name == methodName && m.GetParameters().Length == 2)
- .MakeGenericMethod(elementType, keySelector.Body.Type);
}
///
@@ -339,6 +435,110 @@ private static Expression ReplaceParameter(
return visitor.Visit(expression.Body);
}
+ public static IQueryable CursorPaginate(
+ this PrunedQuery prunedQuery,
+ CursorKey[] keys,
+ Cursor cursor,
+ bool forward)
+ {
+ var cursorPaginatedExpression = ApplyCursorPagination(prunedQuery.Expression, keys, cursor, forward);
+ return prunedQuery.Provider.CreateQuery(cursorPaginatedExpression);
+ }
+
+ public static Expression ApplyCursorPagination(
+ Expression expression,
+ ReadOnlySpan keys,
+ Cursor cursor,
+ bool forward)
+ {
+ var whereExpr = BuildWhereExpression(keys, cursor, forward);
+ expression = Expression.Call(typeof(Enumerable), "Where", [typeof(T)], expression, whereExpr);
+ return expression.ApplyCursorKeyOrdering(keys, cursor.NullsFirst, forward);
+ }
+
+ public static Expression ApplyCursorKeyOrdering(
+ this Expression expression,
+ ReadOnlySpan keys,
+ bool nullFirst,
+ bool forward)
+ {
+ // TODO: This method is far from finished.
+ // Should rebuild the order conditions based on the keys.
+
+ if (keys.Length == 0)
+ {
+ return expression;
+ }
+
+ //static string ReverseOrder(string method) => method switch
+ //{
+ // nameof(Queryable.OrderBy) => nameof(Queryable.OrderByDescending),
+ // nameof(Queryable.OrderByDescending) => nameof(Queryable.OrderBy),
+ // nameof(Queryable.ThenBy) => nameof(Queryable.ThenByDescending),
+ // nameof(Queryable.ThenByDescending) => nameof(Queryable.ThenBy),
+ // _ => method
+ //};
+
+ static MethodInfo GetEnumerableMethod(string methodName, Type elementType, LambdaExpression keySelector)
+ => typeof(Enumerable)
+ .GetMethods(BindingFlags.Static | BindingFlags.Public)
+ .First(m => m.Name == methodName && m.GetParameters().Length == 2)
+ .MakeGenericMethod(elementType, keySelector.Body.Type);
+
+ //for (var i = 0; i < orderExpressions.Length; i++)
+ //{
+ // var methodName = forward ? orderMethods[i] : ReverseOrder(orderMethods[i]);
+ // var orderExpression = orderExpressions[i];
+ // var delegateType = typeof(Func<,>).MakeGenericType(typeof(TV), orderExpression.Body.Type);
+ // var typedOrderExpression =
+ // Expression.Lambda(delegateType, orderExpression.Body, orderExpression.Parameters);
+
+ // var method = GetEnumerableMethod(methodName, typeof(TV), typedOrderExpression);
+
+ // source = Expression.Call(
+ // method,
+ // source,
+ // typedOrderExpression);
+ //}
+
+ Expression? orderedExpression = null;
+ var parameter = Expression.Parameter(typeof(T), "t");
+
+ foreach (var key in keys)
+ {
+ var body = Expression.Invoke(key.Expression, parameter);
+
+ if (key.IsNullable)
+ {
+ var nullCheck = Expression.Equal(body, Expression.Constant(null));
+ var nullCheckLambda = Expression.Lambda(nullCheck, parameter);
+
+ orderedExpression = orderedExpression == null
+ ? Expression.Call(typeof(Queryable), "OrderBy", [typeof(T), typeof(bool)], expression, nullCheckLambda)
+ : Expression.Call(typeof(Queryable), "ThenBy", [typeof(T), typeof(bool)], orderedExpression, nullCheckLambda);
+ }
+
+ var methodName = key.Direction == CursorKeyDirection.Ascending ? "OrderBy" : "OrderByDescending";
+
+ var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), key.Expression.Body.Type);
+ var typedOrderExpression = Expression.Lambda(delegateType, key.Expression.Body, key.Expression.Parameters);
+ var method = GetEnumerableMethod(methodName, typeof(T), typedOrderExpression);
+
+ orderedExpression = orderedExpression == null
+ ? Expression.Call(method, expression, typedOrderExpression)
+ : Expression.Call(method, orderedExpression, typedOrderExpression);
+ }
+
+ return orderedExpression ?? expression;
+ }
+
+ public class PrunedQuery(Expression expression, IQueryProvider provider)
+ {
+ public Expression Expression { get; } = expression;
+
+ public IQueryProvider Provider { get; } = provider;
+ }
+
private class ReplaceParameterVisitor(ParameterExpression parameter, Expression replacement)
: ExpressionVisitor
{
diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs
index aaeed0c4dd1..f389abc797b 100644
--- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs
+++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs
@@ -79,7 +79,7 @@ public static async ValueTask> ToPageAsync(
source = QueryHelpers.EnsureOrderPropsAreSelected(source);
- var keys = ParseDataSetKeys(source);
+ var (keys, prounedQuery) = ExtractDataSetKeys(source);
if (keys.Length == 0)
{
@@ -120,9 +120,8 @@ public static async ValueTask> ToPageAsync(
if (arguments.After is not null)
{
cursor = CursorParser.Parse(arguments.After, keys);
- var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, true);
- source = source.Where(whereExpr);
- offset = cursorOffset;
+ source = prounedQuery.CursorPaginate(keys, cursor, forward: true);
+ offset = cursor.Offset ?? 0;
if (!includeTotalCount)
{
@@ -145,9 +144,8 @@ public static async ValueTask> ToPageAsync(
}
cursor = CursorParser.Parse(arguments.Before, keys);
- var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, false);
- source = source.Where(whereExpr);
- offset = cursorOffset;
+ source = prounedQuery.CursorPaginate(keys, cursor, forward: false);
+ offset = cursor.Offset ?? 0;
if (!includeTotalCount)
{
@@ -166,13 +164,6 @@ public static async ValueTask> ToPageAsync(
}
}
- var isBackward = arguments.Last is not null;
-
- if (isBackward)
- {
- source = ReverseOrderExpressionRewriter.Rewrite(source);
- }
-
var absOffset = Math.Abs(offset);
if (absOffset > 0)
@@ -228,14 +219,9 @@ public static async ValueTask> ToPageAsync(
return Page.Empty;
}
- if (isBackward)
- {
- builder.Reverse();
- }
-
if (builder.Count > requestedCount)
{
- builder.RemoveAt(isBackward ? 0 : requestedCount);
+ builder.RemoveAt(requestedCount);
}
var pageIndex = CreateIndex(arguments, cursor, totalCount);
@@ -412,7 +398,7 @@ public static async ValueTask>> ToBatchPageAsync>> ToBatchPageAsync? counts = null;
if (includeTotalCount)
{
@@ -455,14 +436,12 @@ public static async ValueTask>> ToBatchPageAsync(
arguments,
keys,
- ordering.OrderExpressions,
- ordering.OrderMethods,
forward,
ref requestedCount);
var map = new Dictionary>();
// we apply our new expression here.
- source = source.Provider.CreateQuery(ordering.Expression);
+ source = source.Provider.CreateQuery(prounedQuery.Expression);
TryGetQueryInterceptor()?.OnBeforeExecute(source.GroupBy(keySelector).Select(batchExpression.SelectExpression));
@@ -614,7 +593,7 @@ private static Page CreatePage(
items,
hasNext,
hasPrevious,
- (item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(o, p, c)),
+ (item, n, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(n, o, p, c)),
index ?? 1,
requestedPageSize.Value,
totalCount.Value);
@@ -672,11 +651,11 @@ private static Page CreatePage(
return null;
}
- private static CursorKey[] ParseDataSetKeys(IQueryable source)
+ private static (CursorKey[] keys, PrunedQuery prunedQuery) ExtractDataSetKeys(IQueryable source)
{
- var parser = new CursorKeyParser();
- parser.Visit(source.Expression);
- return [.. parser.Keys];
+ var parser = new CursorKeyExtractor();
+ var prunedExpression = parser.Visit(source.Expression);
+ return ([.. parser.Keys], new PrunedQuery(prunedExpression, source.Provider));
}
private sealed class InterceptorHolder
diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs
index b8d79d9a91a..d1d3c835ec8 100644
--- a/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs
+++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs
@@ -14,7 +14,7 @@ public sealed class Page : IEnumerable
private readonly ImmutableArray _items;
private readonly bool _hasNextPage;
private readonly bool _hasPreviousPage;
- private readonly Func _createCursor;
+ private readonly Func _createCursor;
private readonly int? _requestedPageSize;
private readonly int? _index;
private readonly int? _totalCount;
@@ -47,7 +47,7 @@ public Page(
_items = items;
_hasNextPage = hasNextPage;
_hasPreviousPage = hasPreviousPage;
- _createCursor = (item, _, _, _) => createCursor(item);
+ _createCursor = (item, _, _, _, _) => createCursor(item);
_totalCount = totalCount;
}
@@ -79,7 +79,7 @@ internal Page(
ImmutableArray items,
bool hasNextPage,
bool hasPreviousPage,
- Func createCursor,
+ Func createCursor,
int index,
int requestedPageSize,
int totalCount)
@@ -144,7 +144,7 @@ internal Page(
///
/// Returns a cursor for the item.
///
- public string CreateCursor(T item) => _createCursor(item, 0, 0, 0);
+ public string CreateCursor(T item) => _createCursor(item, false, 0, 0, 0);
public string CreateCursor(T item, int offset)
{
@@ -153,7 +153,7 @@ public string CreateCursor(T item, int offset)
throw new InvalidOperationException("This page does not allow relative cursors.");
}
- return _createCursor(item, offset, _index ?? 1, _totalCount ?? 0);
+ return _createCursor(item, false, offset, _index ?? 1, _totalCount ?? 0);
}
///
diff --git a/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs b/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs
index 0f5deee7605..cf02c7bbbff 100644
--- a/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs
+++ b/src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs
@@ -19,8 +19,12 @@ namespace GreenDonut.Data.Cursors;
///
/// The total number of items in the dataset, if known. Can be null if not available.
///
+///
+/// Determines whether null values should appear first in the sort order.
+///
public record Cursor(
ImmutableArray