Skip to content

Fixed relative cursors to handle nullable types. #8230

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,50 +58,195 @@ public static (Expression<Func<T, bool>> 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<CursorKey>();
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 (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
{
expression = BuildNonNullExpression(expression!, cursor.Values[i], keyExpr, cursorExpr[i], greaterThan, key.CompareMethod.MethodInfo);
}
}

return (Expression.Lambda<Func<T, bool>>(expression!, parameter), cursor.Offset ?? 0);

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));
}
}

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)
{
var handledKey = handled[j];
if (greaterThan)
{
mainKeyExpr = Expression.Equal(keyExpr, nullConstant);

return Expression.AndAlso(mainKeyExpr, previousExpr);
}
else
{
mainKeyExpr = Expression.Equal(keyExpr, nullConstant);

keyExpr = Expression.Equal(
Expression.Call(ReplaceParameter(handledKey.Expression, parameter), handledKey.CompareMethod,
cursorExpr[j]), zero);
secondaryKeyExpr = Expression.NotEqual(keyExpr, nullConstant);

current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr);
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));
}
}

var greaterThan = forward
? key.Direction == CursorKeyDirection.Ascending
: key.Direction == CursorKeyDirection.Descending;
static Expression BuildNonNullExpression(
Expression? previousExpr,
object? keyValue,
Expression keyExpr,
Expression cursorExpr,
bool greaterThan,
MethodInfo compareMethod)
{
var zero = Expression.Constant(0);
Expression mainKeyExpr, secondaryKeyExpr;

keyExpr = greaterThan
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<Func<T, bool>>(expression!, parameter), cursor.Offset ?? 0);
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ public static async ValueTask<Page<T>> ToPageAsync<T>(
}

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);
}

/// <summary>
Expand Down Expand Up @@ -503,6 +503,7 @@ public static async ValueTask<Dictionary<TKey, Page<TValue>>> ToBatchPageAsync<T
builder.ToImmutable(),
arguments,
keys,
batchExpression.Cursor?.NullsFirst,
item.Items.Count,
pageIndex,
requestedCount,
Expand Down Expand Up @@ -572,34 +573,38 @@ private static Page<T> CreatePage<T>(
ImmutableArray<T> items,
PagingArguments arguments,
CursorKey[] keys,
bool? previousNullsFirst,
int fetchCount,
int? index,
int? requestedPageSize,
int? totalCount)
{
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 ?? !AnyNullKeyValue(items.Last(), keys);
}

// 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 ?? AnyNullKeyValue(items.First(), keys);
}

// if we fetched anything before an item we know that here is at least one more item.
Expand All @@ -614,18 +619,21 @@ private static Page<T> CreatePage<T>(
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<T>(
items,
hasNext,
hasPrevious,
item => CursorFormatter.Format(item, keys),
item => CursorFormatter.Format(item, keys, new CursorPageInfo(nullsFirst)),
totalCount);

static bool AnyNullKeyValue(T item, IReadOnlyList<CursorKey> keys)
=> keys.Any(key => key.IsNullable && item != null && key.GetValue(item) == null);
}

private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount)
Expand Down
4 changes: 4 additions & 0 deletions src/GreenDonut/src/GreenDonut.Data/Cursors/Cursor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ namespace GreenDonut.Data.Cursors;
/// <param name="TotalCount">
/// The total number of items in the dataset, if known. Can be <c>null</c> if not available.
/// </param>
/// <param name="NullsFirst">
/// Defines if null values should be considered first in the ordering.
/// </param>
public record Cursor(
ImmutableArray<object?> Values,
bool NullsFirst = false,
int? Offset = null,
int? PageIndex = null,
int? TotalCount = null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ public static string Format<T>(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)'}';
}
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);
Expand Down
20 changes: 17 additions & 3 deletions src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKey.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Linq.Expressions;
using System.Reflection;
using GreenDonut.Data.Cursors.Serializers;

namespace GreenDonut.Data.Cursors;
Expand Down Expand Up @@ -31,7 +30,12 @@ public sealed class CursorKey(
/// <summary>
/// Gets the compare method that is applicable to the key value.
/// </summary>
public MethodInfo CompareMethod { get; } = serializer.GetCompareToMethod(expression.ReturnType);
public CursorKeyCompareMethod CompareMethod { get; } = serializer.GetCompareToMethod(expression.ReturnType);

/// <summary>
/// Gets a value indicating whether the key value is nullable.
/// </summary>
public bool IsNullable { get; } = serializer.IsNullable(expression.ReturnType);

/// <summary>
/// Gets a value defining the sort direction of this key in dataset.
Expand Down Expand Up @@ -66,7 +70,17 @@ public sealed class CursorKey(
public bool TryFormat(object entity, Span<byte> buffer, out int written)
=> CursorKeySerializerHelper.TryFormat(GetValue(entity), serializer, buffer, out written);

private object? GetValue(object entity)
/// <summary>
/// Extracts the key value from the provided entity by compiling and invoking
/// the lambda expression associated with this cursor key.
/// </summary>
/// <param name="entity">
/// The entity from which the key value should be extracted.
/// </param>
/// <returns>
/// The extracted key value, or <c>null</c> if the value cannot be determined.
/// </returns>
public object? GetValue(object entity)
{
_compiled ??= Expression.Compile();
return _compiled.DynamicInvoke(entity);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Reflection;

namespace GreenDonut.Data.Cursors;

/// <summary>
/// Represents a method used to compare cursor keys.
/// </summary>
/// <param name="methodInfo">The <see cref="MethodInfo"/> for the comparison method.</param>
/// <param name="type">The <see cref="Type"/> that the method belongs to.</param>
public sealed class CursorKeyCompareMethod(
MethodInfo methodInfo,
Type type)
{
/// <summary>
/// Gets the <see cref="MethodInfo"/> for the comparison method.
/// </summary>
public MethodInfo MethodInfo { get; } = methodInfo;

/// <summary>
/// Gets the <see cref="Type"/> that the method belongs to.
/// </summary>
public Type Type { get; } = type;
}
Loading
Loading