diff --git a/src/All.slnx b/src/All.slnx
index fd64fbad71f..ba1e1f3ac86 100644
--- a/src/All.slnx
+++ b/src/All.slnx
@@ -21,11 +21,13 @@
+
+
@@ -92,8 +94,8 @@
-
+
@@ -318,4 +320,4 @@
-
+
\ No newline at end of file
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index d719d1d4214..b31c416cf82 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -19,7 +19,7 @@
-
+
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExpressionHelpers.cs
new file mode 100644
index 00000000000..d94081f2d09
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExpressionHelpers.cs
@@ -0,0 +1,185 @@
+using System.Collections.Concurrent;
+using System.Linq.Expressions;
+using System.Reflection;
+using GreenDonut.Data.Cursors;
+
+namespace GreenDonut.Data.Expressions;
+
+///
+/// This class provides helper methods to build slicing where clauses.
+///
+internal static class ExpressionHelpers
+{
+ private static readonly MethodInfo _createAndConvert = typeof(ExpressionHelpers)
+ .GetMethod(nameof(CreateAndConvertParameter), BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly ConcurrentDictionary> _cachedConverters = new();
+
+ ///
+ /// Builds a where expression that can be used to slice a dataset.
+ ///
+ ///
+ /// The key definitions that represent the cursor.
+ ///
+ ///
+ /// The key values that represent the cursor.
+ ///
+ ///
+ /// Defines how the dataset is sorted.
+ ///
+ ///
+ /// The entity type.
+ ///
+ ///
+ /// Returns a where expression that can be used to slice a dataset.
+ ///
+ ///
+ /// If or is null.
+ ///
+ ///
+ /// If the number of keys does not match the number of values.
+ ///
+ public static (Expression> WhereExpression, int Offset) BuildWhereExpression(
+ ReadOnlySpan keys,
+ Cursor cursor,
+ bool forward)
+ {
+ if (keys.Length == 0)
+ {
+ throw new ArgumentException("At least one key must be specified.", nameof(keys));
+ }
+
+ if (keys.Length != cursor.Values.Length)
+ {
+ throw new ArgumentException("The number of keys must match the number of values.", nameof(cursor.Values));
+ }
+
+ 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);
+ }
+
+ var handled = new List();
+ Expression? expression = null;
+
+ var parameter = Expression.Parameter(typeof(T), "t");
+
+ for (var i = 0; i < keys.Length; i++)
+ {
+ var key = keys[i];
+ Expression? current = null;
+ Expression keyExpr;
+
+ // Handle previously processed keys (AND conditions)
+ foreach (var handledKey in handled)
+ {
+ keyExpr = Expression.Equal(
+ ReplaceParameter(handledKey.Expression, parameter),
+ cursorExpr[handled.IndexOf(handledKey)]
+ );
+
+ current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr);
+ }
+
+ // Determine the direction of the comparison (greater or less than)
+ var greaterThan = forward
+ ? key.Direction == CursorKeyDirection.Ascending
+ : key.Direction == CursorKeyDirection.Descending;
+
+ keyExpr = key.Expression.ReturnType switch
+ {
+ { } t when t == typeof(string) => BuildStringComparison(key, parameter, cursorExpr[i], greaterThan),
+ { } t when t == typeof(bool) => BuildBooleanComparison(key, parameter, cursorExpr[i], greaterThan),
+ { } t when t == typeof(DateTime) => throw new NotSupportedException(
+ "DateTime comparisons are not supported."),
+ { } t when t == typeof(ulong) =>
+ throw new NotSupportedException("ulong comparisons are not supported."),
+ { } t when t == typeof(ushort) => throw new NotSupportedException(
+ "ushort comparisons are not supported."),
+ _ => greaterThan
+ ? Expression.GreaterThan(ReplaceParameter(key.Expression, parameter), cursorExpr[i])
+ : Expression.LessThan(ReplaceParameter(key.Expression, parameter), cursorExpr[i])
+ };
+
+ current = current is null ? keyExpr : Expression.AndAlso(current, keyExpr);
+ expression = expression is null ? current : Expression.OrElse(expression, current);
+
+ handled.Add(key);
+ }
+
+ return (Expression.Lambda>(expression!, parameter), cursor.Offset ?? 0);
+ }
+
+ ///
+ /// Helper method to build string comparison using string.Compare
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static Expression BuildStringComparison(CursorKey key, ParameterExpression parameter, Expression cursorValue, bool greaterThan)
+ {
+ var memberExpr = ReplaceParameter(key.Expression, parameter);
+ var compareMethod = typeof(string).GetMethod(nameof(string.Compare), [typeof(string), typeof(string)])!;
+
+ // Call string.Compare(memberExpr, cursorValue)
+ var compareCall = Expression.Call(null, compareMethod, memberExpr, cursorValue);
+
+ return greaterThan
+ ? Expression.GreaterThan(compareCall, Expression.Constant(0))
+ : Expression.LessThan(compareCall, Expression.Constant(0));
+ }
+
+ ///
+ /// Helper method to build boolean comparison
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static Expression BuildBooleanComparison(CursorKey key, ParameterExpression parameter, Expression cursorValue, bool greaterThan)
+ {
+ var memberExpr = ReplaceParameter(key.Expression, parameter);
+
+ return Expression.Equal(memberExpr, cursorValue);
+ }
+
+ private static Expression CreateParameter(object? value, Type type)
+ {
+ var converter = _cachedConverters.GetOrAdd(
+ type,
+ t =>
+ {
+ var method = _createAndConvert.MakeGenericMethod(t);
+ return v => (Expression)method.Invoke(null, [v])!;
+ });
+
+ return converter(value);
+ }
+
+ private static Expression CreateAndConvertParameter(T value)
+ {
+ Expression> lambda = () => value;
+ return lambda.Body;
+ }
+
+ private static Expression ReplaceParameter(
+ LambdaExpression expression,
+ ParameterExpression replacement)
+ {
+ var visitor = new ReplaceParameterVisitor(expression.Parameters[0], replacement);
+ return visitor.Visit(expression.Body);
+ }
+
+ private class ReplaceParameterVisitor(ParameterExpression parameter, Expression replacement)
+ : ExpressionVisitor
+ {
+ protected override Expression VisitParameter(ParameterExpression node)
+ {
+ return node == parameter ? replacement : base.VisitParameter(node);
+ }
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExtractOrderPropertiesVisitor.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExtractOrderPropertiesVisitor.cs
new file mode 100644
index 00000000000..5d33ea9cda1
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExtractOrderPropertiesVisitor.cs
@@ -0,0 +1,53 @@
+using System.Linq.Expressions;
+
+namespace GreenDonut.Data.Expressions;
+
+internal sealed class ExtractOrderPropertiesVisitor : ExpressionVisitor
+{
+ private const string _orderByMethod = "OrderBy";
+ private const string _thenByMethod = "ThenBy";
+ private const string _orderByDescendingMethod = "OrderByDescending";
+ private const string _thenByDescendingMethod = "ThenByDescending";
+ private bool _isOrderScope;
+
+ public List OrderProperties { get; } = [];
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ if (node.Method.Name == _orderByMethod ||
+ node.Method.Name == _thenByMethod ||
+ node.Method.Name == _orderByDescendingMethod ||
+ node.Method.Name == _thenByDescendingMethod)
+ {
+ _isOrderScope = true;
+
+ var lambda = StripQuotes(node.Arguments[1]);
+ Visit(lambda.Body);
+
+ _isOrderScope = false;
+ }
+
+ return base.VisitMethodCall(node);
+ }
+
+ protected override Expression VisitMember(MemberExpression node)
+ {
+ if (_isOrderScope)
+ {
+ // we only collect members that are within an order method.
+ OrderProperties.Add(node);
+ }
+
+ return base.VisitMember(node);
+ }
+
+ private static LambdaExpression StripQuotes(Expression expression)
+ {
+ while (expression.NodeType == ExpressionType.Quote)
+ {
+ expression = ((UnaryExpression)expression).Operand;
+ }
+
+ return (LambdaExpression)expression;
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExtractSelectExpressionVisitor.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExtractSelectExpressionVisitor.cs
new file mode 100644
index 00000000000..aa86962e1b0
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ExtractSelectExpressionVisitor.cs
@@ -0,0 +1,48 @@
+using System.Linq.Expressions;
+
+namespace GreenDonut.Data.Expressions;
+
+internal sealed class ExtractSelectExpressionVisitor : ExpressionVisitor
+{
+ private const string _selectMethod = "Select";
+
+ public LambdaExpression? Selector { get; private set; }
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ if (node.Method.Name == _selectMethod && node.Arguments.Count == 2)
+ {
+ var lambda = ConvertToLambda(node.Arguments[1]);
+ if (lambda.Type.IsGenericType
+ && lambda.Type.GetGenericTypeDefinition() == typeof(Func<,>))
+ {
+ // we make sure that the selector is of type Expression>
+ // otherwise we are not interested in it.
+ var genericArgs = lambda.Type.GetGenericArguments();
+ if (genericArgs[0] == genericArgs[1])
+ {
+ Selector = lambda;
+ }
+ }
+ }
+
+ return base.VisitMethodCall(node);
+ }
+
+ private static LambdaExpression ConvertToLambda(Expression e)
+ {
+ while (e.NodeType == ExpressionType.Quote)
+ {
+ e = ((UnaryExpression)e).Operand;
+ }
+
+ if (e.NodeType != ExpressionType.MemberAccess)
+ {
+ return (LambdaExpression)e;
+ }
+
+ // Convert the property expression into a lambda expression
+ var typeArguments = e.Type.GetGenericArguments()[0].GetGenericArguments();
+ return Expression.Lambda(e, Expression.Parameter(typeArguments[0]));
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/QueryHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/QueryHelpers.cs
new file mode 100644
index 00000000000..ae3d5deb760
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/QueryHelpers.cs
@@ -0,0 +1,129 @@
+using System.Linq.Expressions;
+
+namespace GreenDonut.Data.Expressions;
+
+internal static class QueryHelpers
+{
+ public static IQueryable EnsureOrderPropsAreSelected(
+ IQueryable query)
+ {
+ var selector = ExtractCurrentSelector(query);
+ if (selector is null)
+ {
+ return query;
+ }
+
+ var orderByProperties = ExtractOrderProperties(query);
+ if(orderByProperties.Count == 0)
+ {
+ return query;
+ }
+
+ var updatedSelector = AddPropertiesInSelector(selector, orderByProperties);
+ return ReplaceSelector(query, updatedSelector);
+ }
+
+ public static IQueryable EnsureGroupPropsAreSelected(
+ IQueryable query,
+ Expression> keySelector)
+ {
+ var selector = ExtractCurrentSelector(query);
+ if (selector is null)
+ {
+ return query;
+ }
+
+ var body = GetMemberExpression(keySelector);
+ if (body is null)
+ {
+ return query;
+ }
+
+ var updatedSelector = AddPropertiesInSelector(selector, [body]);
+ return ReplaceSelector(query, updatedSelector);
+
+ static MemberExpression? GetMemberExpression(Expression> keySelector)
+ {
+ var body = keySelector.Body;
+
+ if (body is UnaryExpression unaryExpr)
+ {
+ body = unaryExpr.Operand;
+ }
+
+ return body as MemberExpression;
+ }
+
+ }
+
+ private static Expression>? ExtractCurrentSelector(
+ IQueryable query)
+ {
+ var visitor = new ExtractSelectExpressionVisitor();
+ visitor.Visit(query.Expression);
+ return visitor.Selector as Expression>;
+ }
+
+ private static Expression> AddPropertiesInSelector(
+ Expression> selector,
+ List properties)
+ {
+ var parameter = selector.Parameters[0];
+ var visitor = new AddPropertiesVisitorRewriter(properties, parameter);
+ var updatedBody = visitor.Visit(selector.Body);
+ return Expression.Lambda>(updatedBody, parameter);
+ }
+
+ private static List ExtractOrderProperties(
+ IQueryable query)
+ {
+ var visitor = new ExtractOrderPropertiesVisitor();
+ visitor.Visit(query.Expression);
+ return visitor.OrderProperties;
+ }
+
+ private static IQueryable ReplaceSelector(
+ IQueryable query,
+ Expression> newSelector)
+ {
+ var visitor = new ReplaceSelectorVisitor(newSelector);
+ var newExpression = visitor.Visit(query.Expression);
+ return query.Provider.CreateQuery(newExpression);
+ }
+
+ public class AddPropertiesVisitorRewriter : ExpressionVisitor
+ {
+ private readonly List _propertiesToAdd;
+ private readonly ParameterExpression _parameter;
+
+ public AddPropertiesVisitorRewriter(
+ List propertiesToAdd,
+ ParameterExpression parameter)
+ {
+ _propertiesToAdd = propertiesToAdd;
+ _parameter = parameter;
+ }
+
+ protected override Expression VisitMemberInit(MemberInitExpression node)
+ {
+ // Get existing bindings (properties in the current selector)
+ var existingBindings = node.Bindings.Cast().ToList();
+
+ // Add the properties that are not already present in the bindings
+ foreach (var property in _propertiesToAdd)
+ {
+ var propertyName = property.Member.Name;
+ if (property.Expression is ParameterExpression parameterExpression
+ && existingBindings.All(b => b.Member.Name != propertyName))
+ {
+ var replacer = new ReplacerParameterVisitor(parameterExpression, _parameter);
+ var rewrittenProperty = (MemberExpression)replacer.Visit(property);
+ existingBindings.Add(Expression.Bind(rewrittenProperty.Member, rewrittenProperty));
+ }
+ }
+
+ // Create new MemberInitExpression with updated bindings
+ return Expression.MemberInit(node.NewExpression, existingBindings);
+ }
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReplaceSelectorVisitor.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReplaceSelectorVisitor.cs
new file mode 100644
index 00000000000..d8d692027b8
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReplaceSelectorVisitor.cs
@@ -0,0 +1,25 @@
+using System.Linq.Expressions;
+
+namespace GreenDonut.Data.Expressions;
+
+internal sealed class ReplaceSelectorVisitor(
+ Expression> newSelector)
+ : ExpressionVisitor
+{
+ private const string _selectMethod = "Select";
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ if (node.Method.Name == _selectMethod && node.Arguments.Count == 2)
+ {
+ return Expression.Call(
+ node.Method.DeclaringType!,
+ node.Method.Name,
+ [typeof(T), typeof(T)],
+ node.Arguments[0],
+ newSelector);
+ }
+
+ return base.VisitMethodCall(node);
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReplacerParameterVisitor.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReplacerParameterVisitor.cs
new file mode 100644
index 00000000000..c3dcda1fe3e
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReplacerParameterVisitor.cs
@@ -0,0 +1,14 @@
+using System.Linq.Expressions;
+
+namespace GreenDonut.Data.Expressions;
+
+internal sealed class ReplacerParameterVisitor(
+ ParameterExpression oldParameter,
+ ParameterExpression newParameter)
+ : ExpressionVisitor
+{
+ protected override Expression VisitParameter(ParameterExpression node)
+ => node == oldParameter
+ ? newParameter
+ : base.VisitParameter(node);
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReverseOrderExpressionRewriter.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReverseOrderExpressionRewriter.cs
new file mode 100644
index 00000000000..45b3eaafe1c
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Expressions/ReverseOrderExpressionRewriter.cs
@@ -0,0 +1,60 @@
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace GreenDonut.Data.Expressions;
+
+public class ReverseOrderExpressionRewriter : ExpressionVisitor
+{
+ private static readonly MethodInfo _orderByMethod = typeof(Queryable).GetMethods()
+ .First(m => m.Name == nameof(Queryable.OrderBy) && m.GetParameters().Length == 2);
+
+ private static readonly MethodInfo _orderByDescendingMethod = typeof(Queryable).GetMethods()
+ .First(m => m.Name == nameof(Queryable.OrderByDescending) && m.GetParameters().Length == 2);
+
+ private static readonly MethodInfo _thenByMethod = typeof(Queryable).GetMethods()
+ .First(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);
+
+ private static readonly MethodInfo _thenByDescendingMethod = typeof(Queryable).GetMethods()
+ .First(m => m.Name == nameof(Queryable.ThenByDescending) && m.GetParameters().Length == 2);
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ var visitedArguments = node.Arguments.Select(Visit).Cast().ToArray();
+
+ if (node.Method.Name == nameof(Queryable.OrderBy))
+ {
+ return Expression.Call(
+ _orderByDescendingMethod.MakeGenericMethod(node.Method.GetGenericArguments()),
+ visitedArguments);
+ }
+
+ if (node.Method.Name == nameof(Queryable.OrderByDescending))
+ {
+ return Expression.Call(
+ _orderByMethod.MakeGenericMethod(node.Method.GetGenericArguments()),
+ visitedArguments);
+ }
+
+ if (node.Method.Name == nameof(Queryable.ThenBy))
+ {
+ return Expression.Call(
+ _thenByDescendingMethod.MakeGenericMethod(node.Method.GetGenericArguments()),
+ visitedArguments);
+ }
+
+ if (node.Method.Name == nameof(Queryable.ThenByDescending))
+ {
+ return Expression.Call(
+ _thenByMethod.MakeGenericMethod(node.Method.GetGenericArguments()),
+ visitedArguments);
+ }
+
+ return base.VisitMethodCall(node);
+ }
+
+ public static IQueryable Rewrite(IQueryable query)
+ {
+ var reversedExpression = new ReverseOrderExpressionRewriter().Visit(query.Expression);
+ return query.Provider.CreateQuery(reversedExpression);
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/Extensions/PagingQueryableExtensions.cs
new file mode 100644
index 00000000000..ce43f15cee0
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/Extensions/PagingQueryableExtensions.cs
@@ -0,0 +1,383 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Linq.Expressions;
+using System.Reflection;
+using GreenDonut.Data.Cursors;
+using GreenDonut.Data.Expressions;
+using Marten;
+using static GreenDonut.Data.Expressions.ExpressionHelpers;
+
+
+// ReSharper disable once CheckNamespace
+namespace GreenDonut.Data;
+
+///
+/// Provides extension methods to page a queryable.
+///
+public static class PagingQueryableExtensions
+{
+ private static readonly AsyncLocal _interceptor = new();
+ private static readonly ConcurrentDictionary<(Type, Type), Expression> _countExpressionCache = new();
+
+ ///
+ /// Executes a query with paging and returns the selected page.
+ ///
+ ///
+ /// The queryable to be paged.
+ ///
+ ///
+ /// The paging arguments.
+ ///
+ ///
+ /// The cancellation token.
+ ///
+ ///
+ /// The type of the items in the queryable.
+ ///
+ ///
+ /// Returns a page of items.
+ ///
+ ///
+ /// If the queryable does not have any keys specified.
+ ///
+ public static async ValueTask> ToPageAsync(
+ this IQueryable source,
+ PagingArguments arguments,
+ CancellationToken cancellationToken = default)
+ => await source.ToPageAsync(arguments, includeTotalCount: arguments.IncludeTotalCount, cancellationToken);
+
+ ///
+ /// Executes a query with paging and returns the selected page.
+ ///
+ ///
+ /// The queryable to be paged.
+ ///
+ ///
+ /// The paging arguments.
+ ///
+ ///
+ /// If set to true the total count will be included in the result.
+ ///
+ ///
+ /// The cancellation token.
+ ///
+ ///
+ /// The type of the items in the queryable.
+ ///
+ ///
+ /// Returns a page of items.
+ ///
+ ///
+ /// If the queryable does not have any keys specified.
+ ///
+ public static async ValueTask> ToPageAsync(
+ this IQueryable source,
+ PagingArguments arguments,
+ bool includeTotalCount,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ source = QueryHelpers.EnsureOrderPropsAreSelected(source);
+
+ var keys = ParseDataSetKeys(source);
+
+ if (keys.Length == 0)
+ {
+ throw new ArgumentException(
+ "In order to use cursor pagination, you must specify at least one key using the `OrderBy` method.",
+ nameof(source));
+ }
+
+ if (arguments.Last is not null && arguments.First is not null)
+ {
+ throw new ArgumentException(
+ "You can specify either `first` or `last`, but not both as this can lead to unpredictable results.",
+ nameof(arguments));
+ }
+
+ if (arguments.First is null && arguments.Last is null)
+ {
+ arguments = arguments with { First = 10 };
+ }
+
+ // if relative cursors are enabled and no cursor is provided
+ // we must do an initial count of the dataset.
+ if (arguments.EnableRelativeCursors
+ && string.IsNullOrEmpty(arguments.After)
+ && string.IsNullOrEmpty(arguments.Before))
+ {
+ includeTotalCount = true;
+ }
+
+ var originalQuery = source;
+ var forward = arguments.Last is null;
+ var requestedCount = forward ? arguments.First!.Value : arguments.Last!.Value;
+ var offset = 0;
+ int? totalCount = null;
+ var usesRelativeCursors = false;
+ Cursor? cursor = null;
+
+ 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;
+
+ if (!includeTotalCount)
+ {
+ totalCount ??= cursor.TotalCount;
+ }
+
+ if (cursor.IsRelative)
+ {
+ usesRelativeCursors = true;
+ }
+ }
+
+ if (arguments.Before is not null)
+ {
+ if (usesRelativeCursors)
+ {
+ throw new ArgumentException(
+ "You cannot use `before` and `after` with relative cursors at the same time.",
+ nameof(arguments));
+ }
+
+ cursor = CursorParser.Parse(arguments.Before, keys);
+ var (whereExpr, cursorOffset) = BuildWhereExpression(keys, cursor, false);
+ source = source.Where(whereExpr);
+ offset = cursorOffset;
+
+ if (!includeTotalCount)
+ {
+ totalCount ??= cursor.TotalCount;
+ }
+ }
+
+ if (cursor?.IsRelative == true)
+ {
+ if ((arguments.Last is not null && cursor.Offset > 0)
+ || (arguments.First is not null && cursor.Offset < 0))
+ {
+ throw new ArgumentException(
+ "Positive offsets are not allowed with `last`, and negative offsets are not allowed with `first`.",
+ nameof(arguments));
+ }
+ }
+
+ var isBackward = arguments.Last is not null;
+
+ if (isBackward)
+ {
+ source = ReverseOrderExpressionRewriter.Rewrite(source);
+ }
+
+ var absOffset = Math.Abs(offset);
+
+ if (absOffset > 0)
+ {
+ source = source.Skip(absOffset * requestedCount);
+ }
+
+ source = source.Take(requestedCount + 1);
+
+ var builder = ImmutableArray.CreateBuilder();
+ var fetchCount = 0;
+
+ if (includeTotalCount)
+ {
+ totalCount ??= await originalQuery
+ .CountAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ TryGetQueryInterceptor()?.OnBeforeExecute(source);
+
+ await foreach (var item in source.ToAsyncEnumerable(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ fetchCount++;
+
+ builder.Add(item);
+
+ if (fetchCount > requestedCount)
+ {
+ break;
+ }
+ }
+ }
+ else
+ {
+ TryGetQueryInterceptor()?.OnBeforeExecute(source);
+
+ await foreach (var item in source.ToAsyncEnumerable(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ fetchCount++;
+
+ builder.Add(item);
+
+ if (fetchCount > requestedCount)
+ {
+ break;
+ }
+ }
+ }
+
+ if (builder.Count == 0)
+ {
+ return Page.Empty;
+ }
+
+ if (isBackward)
+ {
+ builder.Reverse();
+ }
+
+ if (builder.Count > requestedCount)
+ {
+ builder.RemoveAt(isBackward ? 0 : requestedCount);
+ }
+
+ var pageIndex = CreateIndex(arguments, cursor, totalCount);
+ return CreatePage(builder.ToImmutable(), arguments, keys, fetchCount, pageIndex, requestedCount, totalCount);
+ }
+
+ private static Page CreatePage(
+ ImmutableArray items,
+ PagingArguments arguments,
+ CursorKey[] keys,
+ int fetchCount,
+ int? index,
+ int? requestedPageSize,
+ int? totalCount)
+ {
+ var hasPrevious = false;
+ var hasNext = 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)
+ {
+ 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 && fetchCount > arguments.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 && fetchCount > arguments.First)
+ {
+ hasNext = true;
+ }
+
+ // if we fetched anything before an item we know that here is at least one more item.
+ if (arguments.Before is not null)
+ {
+ hasNext = true;
+ }
+
+ if (arguments.EnableRelativeCursors && totalCount is not null && requestedPageSize is not null)
+ {
+ return new Page(
+ items,
+ hasNext,
+ hasPrevious,
+ (item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(o, p, c)),
+ index ?? 1,
+ requestedPageSize.Value,
+ totalCount.Value);
+ }
+
+ return new Page(
+ items,
+ hasNext,
+ hasPrevious,
+ item => CursorFormatter.Format(item, keys),
+ totalCount);
+ }
+
+ private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount)
+ {
+ if (totalCount is not null
+ && arguments.Last is not null
+ && arguments.After is null
+ && arguments.Before is null)
+ {
+ return Math.Max(1, (int)Math.Ceiling(totalCount.Value / (double)arguments.Last.Value));
+ }
+
+ if (cursor?.IsRelative != true)
+ {
+ return null;
+ }
+
+ if (arguments.After is not null)
+ {
+ if (arguments.First is not null)
+ {
+ return (cursor.PageIndex ?? 1) + (cursor.Offset ?? 0) + 1;
+ }
+
+ if (arguments.Last is not null && totalCount is not null)
+ {
+ return Math.Max(1, (int)Math.Ceiling(totalCount.Value / (double)arguments.Last.Value));
+ }
+ }
+
+ if (arguments.Before is not null)
+ {
+ if (arguments.First is not null)
+ {
+ return 1;
+ }
+
+ if (arguments.Last is not null)
+ {
+ return (cursor.PageIndex ?? 1) - Math.Abs(cursor.Offset ?? 0) - 1;
+ }
+ }
+
+ return null;
+ }
+
+ private static CursorKey[] ParseDataSetKeys(IQueryable source)
+ {
+ var parser = new CursorKeyParser();
+ parser.Visit(source.Expression);
+ return [.. parser.Keys];
+ }
+
+ private sealed class InterceptorHolder
+ {
+ public PagingQueryInterceptor? Interceptor { get; set; }
+ }
+
+ internal static PagingQueryInterceptor? TryGetQueryInterceptor()
+ => _interceptor.Value?.Interceptor;
+
+ internal static void SetQueryInterceptor(PagingQueryInterceptor pagingQueryInterceptor)
+ {
+ if (_interceptor.Value is null)
+ {
+ _interceptor.Value = new InterceptorHolder();
+ }
+
+ _interceptor.Value.Interceptor = pagingQueryInterceptor;
+ }
+
+ internal static void ClearQueryInterceptor(PagingQueryInterceptor pagingQueryInterceptor)
+ {
+ if (_interceptor.Value is not null)
+ {
+ _interceptor.Value.Interceptor = null;
+ }
+ }
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/GreenDonut.Data.Marten.csproj b/src/GreenDonut/src/GreenDonut.Data.Marten/GreenDonut.Data.Marten.csproj
new file mode 100644
index 00000000000..8c3796af76b
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/GreenDonut.Data.Marten.csproj
@@ -0,0 +1,26 @@
+
+
+
+ GreenDonut.Data.Marten
+ GreenDonut.Data.Marten
+ GreenDonut.Data
+ This package provides data integrations like pagination, projections, filtering and sorting.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/GreenDonut/src/GreenDonut.Data.Marten/PagingQueryInterceptor.cs b/src/GreenDonut/src/GreenDonut.Data.Marten/PagingQueryInterceptor.cs
new file mode 100644
index 00000000000..a7729d658cb
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Data.Marten/PagingQueryInterceptor.cs
@@ -0,0 +1,52 @@
+namespace GreenDonut.Data;
+
+///
+/// This interceptor allows to capture paging queries for analysis.
+///
+public abstract class PagingQueryInterceptor : IDisposable
+{
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ protected PagingQueryInterceptor()
+ {
+ PagingQueryableExtensions.SetQueryInterceptor(this);
+ }
+
+ ///
+ /// This method is called before the query is executed and allows to intercept it.
+ ///
+ ///
+ /// The query that is about to be executed.
+ ///
+ ///
+ /// The type of the items in the query.
+ ///
+ public abstract void OnBeforeExecute(IQueryable query);
+
+ ///
+ /// The dispose call will remove the interceptor from the current scope.
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ PagingQueryableExtensions.ClearQueryInterceptor(this);
+ _disposed = true;
+ }
+ }
+
+ ///
+ /// This method allows to publish a query to the current interceptor.
+ ///
+ ///
+ /// The query that is about to be executed.
+ ///
+ ///
+ /// The type of the items in the query.
+ ///
+ public static void Publish(IQueryable query)
+ => PagingQueryableExtensions.TryGetQueryInterceptor()?.OnBeforeExecute(query);
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/GreenDonut.Data.Primitives.csproj b/src/GreenDonut/src/GreenDonut.Data.Primitives/GreenDonut.Data.Primitives.csproj
index ff39a60e3e3..a1418e3fe7d 100644
--- a/src/GreenDonut/src/GreenDonut.Data.Primitives/GreenDonut.Data.Primitives.csproj
+++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/GreenDonut.Data.Primitives.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/CapturePagingQueryInterceptor.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/CapturePagingQueryInterceptor.cs
new file mode 100644
index 00000000000..ecfe7b53f95
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/CapturePagingQueryInterceptor.cs
@@ -0,0 +1,18 @@
+using Marten;
+
+namespace GreenDonut.Data;
+
+public sealed class CapturePagingQueryInterceptor : PagingQueryInterceptor
+{
+ public List Queries { get; } = new();
+
+ public override void OnBeforeExecute(IQueryable query)
+ {
+ Queries.Add(
+ new QueryInfo
+ {
+ ExpressionText = query.Expression.ToString(),
+ QueryText = query.ToCommand().CommandText
+ });
+ }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/GreenDonut.Data.Marten.Tests.csproj b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/GreenDonut.Data.Marten.Tests.csproj
new file mode 100644
index 00000000000..76b4edbc000
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/GreenDonut.Data.Marten.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ GreenDonut.Data.Tests
+ GreenDonut.Data
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PagingHelperIntegrationTests.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PagingHelperIntegrationTests.cs
new file mode 100644
index 00000000000..679511b8867
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PagingHelperIntegrationTests.cs
@@ -0,0 +1,319 @@
+using System.Linq.Expressions;
+using GreenDonut.Data.TestContext;
+using Marten;
+using Squadron;
+using Weasel.Core;
+
+namespace GreenDonut.Data;
+
+[Collection(PostgresCacheCollectionFixture.DefinitionName)]
+public class IntegrationPagingHelperTests(PostgreSqlResource resource)
+{
+ public PostgreSqlResource Resource { get; } = resource;
+
+ private string CreateConnectionString()
+ {
+ var dbName = $"db_{Guid.NewGuid():N}";
+
+ Resource.CreateDatabaseAsync(dbName).GetAwaiter().GetResult();
+
+ return Resource.GetConnectionString(dbName);
+ }
+
+ private static DocumentStore GetStore(string connectionString)
+ {
+ var store = DocumentStore.For(options =>
+ {
+ options.Connection(connectionString);
+
+ options.UseSystemTextJsonForSerialization();
+
+ //options.AutoCreateSchemaObjects = AutoCreate.All;
+
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ });
+
+ return store;
+ }
+
+
+ [Fact]
+ public async Task Paging_First_5()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+ using var capture = new CapturePagingQueryInterceptor();
+
+ var store = GetStore(connectionString);
+
+ // Act
+ await using var session = store.LightweightSession();
+
+ var pagingArgs = new PagingArguments { First = 5 };
+ var result = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(pagingArgs);
+
+ // Assert
+ await CreateSnapshot()
+ .AddQueries(capture.Queries)
+ .Add(
+ new
+ {
+ result.HasNextPage,
+ result.HasPreviousPage,
+ First = result.First?.Id,
+ FirstCursor = result.First is not null ? result.CreateCursor(result.First) : null,
+ Last = result.Last?.Id,
+ LastCursor = result.Last is not null ? result.CreateCursor(result.Last) : null
+ })
+ .Add(result.Items)
+ .MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Paging_First_5_After_Id_13()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+ using var capture = new CapturePagingQueryInterceptor();
+
+ var store = GetStore(connectionString);
+
+ // Act
+ await using var session = store.LightweightSession();
+
+ var pagingArgs = new PagingArguments
+ {
+ First = 5,
+ After = "QnJhbmQxMjoxMw=="
+ };
+ var result = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(pagingArgs);
+
+ // Assert
+ await CreateSnapshot()
+ .AddQueries(capture.Queries)
+ .Add(
+ new
+ {
+ result.HasNextPage,
+ result.HasPreviousPage,
+ First = result.First?.Id,
+ FirstCursor = result.First is not null ? result.CreateCursor(result.First) : null,
+ Last = result.Last?.Id,
+ LastCursor = result.Last is not null ? result.CreateCursor(result.Last) : null
+ })
+ .Add(result.Items)
+ .MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Paging_Last_5()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+ using var capture = new CapturePagingQueryInterceptor();
+
+ var store = GetStore(connectionString);
+
+ // Act
+ await using var session = store.LightweightSession();
+
+ var pagingArgs = new PagingArguments { Last = 5 };
+ var result = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(pagingArgs);
+
+ // Assert
+ await CreateSnapshot()
+ .AddQueries(capture.Queries)
+ .Add(
+ new
+ {
+ result.HasNextPage,
+ result.HasPreviousPage,
+ First = result.First?.Id,
+ FirstName = result.First?.Name,
+ FirstCursor = result.First is not null ? result.CreateCursor(result.First) : null,
+ Last = result.Last?.Id,
+ LastName = result.Last?.Name,
+ LastCursor = result.Last is not null ? result.CreateCursor(result.Last) : null
+ })
+ .Add(result.Items)
+ .MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Paging_First_5_Before_Id_96()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+ using var capture = new CapturePagingQueryInterceptor();
+
+ var store = GetStore(connectionString);
+
+ // Act
+ await using var session = store.LightweightSession();
+
+ var pagingArgs = new PagingArguments
+ {
+ Last = 5,
+ Before = "QnJhbmQ5NTo5Ng=="
+ };
+ var result = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(pagingArgs);
+
+ // Assert
+ await CreateSnapshot()
+ .AddQueries(capture.Queries)
+ .Add(
+ new
+ {
+ result.HasNextPage,
+ result.HasPreviousPage,
+ First = result.First?.Id,
+ FirstCursor = result.First is not null ? result.CreateCursor(result.First) : null,
+ Last = result.Last?.Id,
+ LastCursor = result.Last is not null ? result.CreateCursor(result.Last) : null
+ })
+ .Add(result.Items)
+ .MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Paging_WithChildCollectionProjectionExpression_First_5()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+ using var capture = new CapturePagingQueryInterceptor();
+
+ var store = GetStore(connectionString);
+
+ // Act
+ await using var session = store.LightweightSession();
+
+ var pagingArgs = new PagingArguments
+ {
+ First = 5
+ };
+
+ var result = await session.Query()
+ .Select(BrandWithProductsDto.Projection)
+ .OrderBy(t => t.Name)
+ .ThenBy(t => t.Id)
+ .ToPageAsync(pagingArgs);
+
+ // Assert
+ await CreateSnapshot()
+ .AddQueries(capture.Queries)
+ .Add(
+ new
+ {
+ result.HasNextPage,
+ result.HasPreviousPage,
+ First = result.First?.Id,
+ FirstCursor = result.First is not null ? result.CreateCursor(result.First) : null,
+ Last = result.Last?.Id,
+ LastCursor = result.Last is not null ? result.CreateCursor(result.Last) : null
+ })
+ .Add(result.Items)
+ .MatchMarkdownAsync();
+ }
+
+ private static async Task SeedAsync(string connectionString)
+ {
+ var store = DocumentStore.For(options =>
+ {
+ options.Connection(connectionString);
+
+ options.UseSystemTextJsonForSerialization();
+ });
+
+ await using var session = store.LightweightSession();
+
+ var type = new ProductType
+ {
+ Name = "T-Shirt",
+ };
+ session.Store(type);
+
+ for (var i = 0; i < 100; i++)
+ {
+ var brand = new Brand
+ {
+ Name = "Brand:" + i,
+ DisplayName = i % 2 == 0 ? "BrandDisplay" + i : null,
+ BrandDetails = new() { Country = new() { Name = "Country" + i } }
+ };
+ session.Store(brand);
+
+ for (var j = 0; j < 100; j++)
+ {
+ var product = new Product
+ {
+ Name = $"Product {i}-{j}",
+ Type = type,
+ Brand = brand,
+ };
+ session.Store((product));
+ }
+ }
+
+ await session.SaveChangesAsync();
+ }
+
+ public class BrandDto
+ {
+ public BrandDto(int id, string name)
+ {
+ Id = id;
+ Name = name;
+ }
+
+ public int Id { get; }
+
+ public string Name { get; }
+ }
+
+ public class BrandWithProductsDto
+ {
+ public required int Id { get; init; }
+
+ public required string Name { get; init; }
+
+ public required IReadOnlyCollection Products { get; init; }
+
+ public static Expression> Projection
+ => brand => new BrandWithProductsDto
+ {
+ Id = brand.Id,
+ Name = brand.Name,
+ Products = brand.Products.AsQueryable().Select(ProductDto.Projection).ToList()
+ };
+ }
+
+ public class ProductDto
+ {
+ public required int Id { get; init; }
+
+ public required string Name { get; init; }
+
+ public static Expression> Projection
+ => product => new ProductDto
+ {
+ Id = product.Id,
+ Name = product.Name
+ };
+ }
+
+ private static Snapshot CreateSnapshot()
+ {
+#if NET9_0_OR_GREATER
+ return Snapshot.Create();
+#else
+ return Snapshot.Create("NET8_0");
+#endif
+ }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PagingHelperTests.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PagingHelperTests.cs
new file mode 100644
index 00000000000..1651d14260b
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PagingHelperTests.cs
@@ -0,0 +1,527 @@
+using GreenDonut.Data.TestContext;
+using Marten;
+using Squadron;
+
+namespace GreenDonut.Data;
+
+[Collection(PostgresCacheCollectionFixture.DefinitionName)]
+public class PagingHelperTests(PostgreSqlResource resource)
+{
+ public PostgreSqlResource Resource { get; } = resource;
+
+ private string CreateConnectionString()
+ {
+ var dbName = $"db_{Guid.NewGuid():N}" ;
+
+ Resource.CreateDatabaseAsync(dbName).GetAwaiter().GetResult();
+
+ return Resource.GetConnectionString(dbName);
+ }
+
+ private static DocumentStore GetStore(string connectionString)
+ {
+ var store = DocumentStore.For(options =>
+ {
+ options.Connection(connectionString);
+
+ options.UseSystemTextJsonForSerialization();
+
+ //options.AutoCreateSchemaObjects = AutoCreate.All;
+
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ });
+
+ return store;
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // Act
+ var arguments = new PagingArguments(2);
+ await using var session = store.LightweightSession();
+ var page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id)
+ .ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items_Second_Page()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // -> get first page
+ var arguments = new PagingArguments(2);
+ await using var session = store.LightweightSession();
+ var page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+ arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!));
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items_Second_Page_With_Offset_2()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // -> get first page
+ var arguments = new PagingArguments(2) { EnableRelativeCursors = true };
+ await using var session = store.LightweightSession();
+ var page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+ var cursor = page.CreateCursor(page.Last!, 2);
+ arguments = new PagingArguments(2, after: cursor);
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items_Second_Page_With_Offset_Negative_2()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+
+ // -> get first page
+ var arguments = new PagingArguments(2) { EnableRelativeCursors = true };
+ var first = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // -> get second page
+ var cursor = first.CreateCursor(first.Last!, 0);
+ arguments = new PagingArguments(2, after: cursor) { EnableRelativeCursors = true };
+ var page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // -> get third page
+ cursor = page.CreateCursor(page.Last!, 0);
+ arguments = new PagingArguments(2, after: cursor) { EnableRelativeCursors = true };
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+ /*
+ 1 Product 0-0
+ 2 Product 0-1
+ 11 Product 0-10
+ 12 Product 0-11
+ 13 Product 0-12 <- Cursor is set here - 1
+ 14 Product 0-13
+ 15 Product 0-14
+ 16 Product 0-15
+ 17 Product 0-16
+ 18 Product 0-17
+ */
+ cursor = page.CreateCursor(page.Last!, -1);
+ arguments = new PagingArguments(last: 2, before: cursor);
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ /*
+ 1 Product 0-0 <- first
+ 2 Product 0-1 <- last
+ 11 Product 0-10
+ 12 Product 0-11
+ 13 Product 0-12 <- Cursor is set here - 1
+ 14 Product 0-13
+ 15 Product 0-14
+ 16 Product 0-15
+ 17 Product 0-16
+ 18 Product 0-17
+ */
+ new {
+ First = page.First!.Name,
+ Last = page.Last!.Name,
+ ItemsCount = page.Items.Length
+ }.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items_Third_Page()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // -> get first page
+ var arguments = new PagingArguments(2);
+ await using var session = store.LightweightSession();
+ var page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id)
+ .ToPageAsync(arguments);
+
+ arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!));
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+ arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!));
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items_Between()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // -> get first page
+ var arguments = new PagingArguments(4);
+ await using var session = store.LightweightSession();
+ var page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id)
+ .ToPageAsync(arguments);
+
+ // Act
+ arguments = new PagingArguments(2, after: page.CreateCursor(page.First!), before: page.CreateCursor(page.Last!));
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_Last_2_Items()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // Act
+ var arguments = new PagingArguments(last: 2);
+ await using var session = store.LightweightSession();
+ var page = await session.Query()
+ .OrderBy(t => t.Name)
+ .ThenBy(t => t.Id)
+ .ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task QueryContext_Simple_Selector()
+ {
+ // Arrange
+ using var interceptor = new CapturePagingQueryInterceptor();
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // Act
+ var query = new QueryContext(
+ Selector: t => new Product { Id = t.Id, Name = t.Name },
+ Sorting: new SortDefinition().AddDescending(t => t.Id));
+
+ var arguments = new PagingArguments(last: 2);
+
+ await using var session = store.LightweightSession();
+
+ await session.Query()
+ .With(query)
+ .ToPageAsync(arguments);
+
+ // Assert
+ CreateSnapshot()
+ .AddQueries(interceptor.Queries)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task QueryContext_Simple_Selector_Include_Brand()
+ {
+ // Arrange
+ using var interceptor = new CapturePagingQueryInterceptor();
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // Act
+ var query = new QueryContext(
+ Selector: t => new Product { Id = t.Id, Name = t.Name },
+ Sorting: new SortDefinition().AddDescending(t => t.Id));
+
+ query = query.Include(t => t.Brand);
+
+ var arguments = new PagingArguments(last: 2);
+
+ await using var session = store.LightweightSession();
+
+ var page = await session.Query()
+ .With(query)
+ .ToPageAsync(arguments);
+
+ // Assert
+ CreateSnapshot()
+ .AddQueries(interceptor.Queries)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task QueryContext_Simple_Selector_Include_Brand_Name()
+ {
+ // Arrange
+ using var interceptor = new CapturePagingQueryInterceptor();
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // Act
+ var query = new QueryContext(
+ Selector: t => new Product { Id = t.Id, Name = t.Name },
+ Sorting: new SortDefinition().AddDescending(t => t.Id));
+
+ query = query.Select(t => new Product { Brand = new Brand { Name = t.Brand!.Name } });
+
+ var arguments = new PagingArguments(last: 2);
+
+ await using var session = store.LightweightSession();
+
+ var page = await session.Query()
+ .With(query)
+ .ToPageAsync(arguments);
+
+ // Assert
+ CreateSnapshot()
+ .AddQueries(interceptor.Queries)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task QueryContext_Simple_Selector_Include_Product_List()
+ {
+ // Arrange
+ using var interceptor = new CapturePagingQueryInterceptor();
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // Act
+ var query = new QueryContext(
+ Selector: t => new Brand { Id = t.Id, Name = t.Name, Products = t.Products },
+ Sorting: new SortDefinition().AddDescending(t => t.Id));
+
+ var arguments = new PagingArguments(last: 2);
+
+ await using var session = store.LightweightSession();
+
+ var page = await session.Query()
+ .With(query)
+ .ToPageAsync(arguments);
+
+ // Assert
+ CreateSnapshot()
+ .AddQueries(interceptor.Queries)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Last_2_Items_Before_Last_Page()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // -> get last page
+ var arguments = new PagingArguments(last: 2);
+ await using var session = store.LightweightSession();
+ var page = await session.Query()
+ .OrderBy(t => t.Name)
+ .ThenBy(t => t.Id)
+ .ToPageAsync(arguments);
+
+ // Act
+ arguments = arguments with { Before = page.CreateCursor(page.First!), };
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_Last_2_Items_Between()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ // -> get last page
+ var arguments = new PagingArguments(last: 4);
+ await using var session = store.LightweightSession();
+ var page = await session.Query()
+ .OrderBy(t => t.Name)
+ .ThenBy(t => t.Id)
+ .ToPageAsync(arguments);
+
+ // Act
+ arguments = new PagingArguments(after: page.CreateCursor(page.First!), last: 2, before: page.CreateCursor(page.Last!));
+ page = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+ page.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public async Task Fetch_First_2_Items_Second_Page_Descending_AllTypes()
+ {
+ // Arrange
+ var connectionString = CreateConnectionString();
+ await SeedTestAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+
+ Dictionary> queries = new()
+ {
+ { "Bool", session.Query().OrderByDescending(t => t.Bool) },
+ { "DateOnly", session.Query().OrderByDescending(t => t.DateOnly) },
+ { "DateTimeOffset", session.Query().OrderByDescending(t => t.DateTimeOffset) },
+ { "Decimal", session.Query().OrderByDescending(t => t.Decimal) },
+ { "Double", session.Query().OrderByDescending(t => t.Double) },
+ { "Float", session.Query().OrderByDescending(t => t.Float) },
+ { "Guid", session.Query().OrderByDescending(t => t.Guid) },
+ { "Int", session.Query().OrderByDescending(t => t.Int) },
+ { "Long", session.Query().OrderByDescending(t => t.Long) },
+ { "Short", session.Query().OrderByDescending(t => t.Short) },
+ { "String", session.Query().OrderByDescending(t => t.String) },
+ { "TimeOnly", session.Query().OrderByDescending(t => t.TimeOnly) },
+ { "UInt", session.Query().OrderByDescending(t => t.UInt) },
+ };
+
+ // Act
+ Dictionary> pages = [];
+
+ foreach (var (label, query) in queries)
+ {
+ // Get 1st page.
+ var arguments = new PagingArguments(2);
+ var page = await query.ThenByDescending(t => t.Id).ToPageAsync(arguments);
+
+ // Get 2nd page.
+ arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!));
+ pages.Add(label, await query.ThenByDescending(t => t.Id).ToPageAsync(arguments));
+ }
+
+ // Assert
+ pages.MatchMarkdownSnapshot();
+ }
+
+ private static async Task SeedAsync(string connectionString)
+ {
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+
+ var type = new ProductType { Name = "T-Shirt", };
+ session.Store(type);
+
+ for (var i = 0; i < 100; i++)
+ {
+ var brand = new Brand
+ {
+ Name = "Brand" + i,
+ DisplayName = i % 2 == 0 ? "BrandDisplay" + i : null,
+ BrandDetails = new() { Country = new() { Name = "Country" + i } }
+ };
+ session.Store(brand);
+
+ for (var j = 0; j < 100; j++)
+ {
+ var product = new Product
+ {
+ Name = $"Product {i}-{j}",
+ Type = type,
+ Brand = brand,
+ };
+ session.Store(product);
+ }
+ }
+
+ await session.SaveChangesAsync();
+ }
+
+ private static async Task SeedTestAsync(string connectionString)
+ {
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+
+ for (var i = 1; i <= 10; i++)
+ {
+ var test = new Test
+ {
+ Id = i,
+ Bool = i % 2 == 0,
+ DateOnly = DateOnly.FromDateTime(DateTime.UnixEpoch.AddDays(i - 1)),
+ DateTimeOffset = DateTimeOffset.UnixEpoch.AddDays(i - 1),
+ Decimal = i,
+ Double = i,
+ Float = i,
+ Guid = Guid.ParseExact($"0000000000000000000000000000000{i - 1}", "N"),
+ Int = i,
+ Long = i,
+ Short = (short)i,
+ String = i.ToString(),
+ TimeOnly = TimeOnly.MinValue.AddHours(i),
+ TimeSpan = TimeSpan.FromHours(i),
+ UInt = (uint)i
+ };
+
+ session.Store(test);
+ }
+
+ await session.SaveChangesAsync();
+ }
+
+ private static Snapshot CreateSnapshot()
+ {
+#if NET9_0_OR_GREATER
+ return Snapshot.Create();
+#else
+ return Snapshot.Create("NET8_0");
+#endif
+ }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PostgresCacheCollectionFixture.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PostgresCacheCollectionFixture.cs
new file mode 100644
index 00000000000..7a8fbe4f637
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/PostgresCacheCollectionFixture.cs
@@ -0,0 +1,9 @@
+using Squadron;
+
+namespace GreenDonut.Data;
+
+[CollectionDefinition(DefinitionName)]
+public class PostgresCacheCollectionFixture : ICollectionFixture
+{
+ internal const string DefinitionName = "PostgresSqlResource";
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/RelativeCursorTests.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/RelativeCursorTests.cs
new file mode 100644
index 00000000000..52d7c55a234
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/RelativeCursorTests.cs
@@ -0,0 +1,472 @@
+#if NET9_0_OR_GREATER
+using System.ComponentModel.DataAnnotations;
+using GreenDonut.Data.TestContext;
+using Marten;
+using Squadron;
+
+namespace GreenDonut.Data;
+
+[Collection(PostgresCacheCollectionFixture.DefinitionName)]
+public class RelativeCursorTests(PostgreSqlResource resource)
+{
+ public PostgreSqlResource Resource { get; } = resource;
+
+ private string CreateConnectionString()
+ {
+ var dbName = $"db_{Guid.NewGuid():N}" ;
+
+ Resource.CreateDatabaseAsync(dbName).GetAwaiter().GetResult();
+
+ return Resource.GetConnectionString(dbName);
+ }
+
+ private static DocumentStore GetStore(string connectionString)
+ {
+ var store = DocumentStore.For(options =>
+ {
+ options.Connection(connectionString);
+
+ options.UseSystemTextJsonForSerialization();
+
+ //options.AutoCreateSchemaObjects = AutoCreate.All;
+
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ options.Schema.For().Identity(x => x.Id);
+ });
+
+ return store;
+ }
+
+ [Fact]
+ public async Task Fetch_Second_Page()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(2) { EnableRelativeCursors = true };
+ var first = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+
+ using var capture = new CapturePagingQueryInterceptor();
+ arguments = arguments with { After = first.CreateCursor(first.Last!, 0) };
+ var second = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+
+ /*
+ 1. Aetherix
+ 2. Brightex <- Cursor
+ 3. Celestara <- Page 2 - Item 1
+ 4. Dynamova <- Page 2 - Item 2
+ 5. Evolvance
+ 6. Futurova
+ */
+
+ Snapshot.Create()
+ .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() })
+ .AddSql(capture)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Third_Page_With_Offset_1()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(2) { EnableRelativeCursors = true };
+ var first = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+
+ using var capture = new CapturePagingQueryInterceptor();
+ arguments = arguments with { After = first.CreateCursor(first.Last!, 1) };
+ var second = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+
+ /*
+ 1. Aetherix
+ 2. Brightex <- Cursor
+ 3. Celestara
+ 4. Dynamova
+ 5. Evolvance <- Page 3 - Item 1
+ 6. Futurova <- Page 3 - Item 2
+ */
+
+ Snapshot.Create()
+ .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() })
+ .AddSql(capture)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Fourth_Page_With_Offset_1()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(2) { EnableRelativeCursors = true };
+ var first = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+ arguments = arguments with { After = first.CreateCursor(first.Last!, 0) };
+ var second = await session.Query().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 session.Query().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)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Fourth_Page_With_Offset_2()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(2) { EnableRelativeCursors = true };
+ var first = await session.Query().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 session.Query().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)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Second_To_Last_Page_Offset_0()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true };
+ var last = await session.Query().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 session.Query().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)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Third_To_Last_Page_Offset_Negative_1()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true };
+ var last = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+
+ using var capture = new CapturePagingQueryInterceptor();
+ arguments = arguments with { Before = last.CreateCursor(last.First!, -1) };
+ var thirdToLast = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+
+ /*
+ 14. Nebularis
+ 15. Omniflex <- Selected - Item 1
+ 16. Pulsarix <- Selected - Item 2
+ 17. Quantumis
+ 18. Radiantum
+ 19. Synerflux <- Cursor
+ 20. Vertexis
+ */
+
+ Snapshot.Create()
+ .Add(new
+ {
+ Page = thirdToLast.Index,
+ thirdToLast.TotalCount,
+ Items = thirdToLast.Items.Select(t => t.Name).ToArray()
+ })
+ .AddSql(capture)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Fourth_To_Last_Page_Offset_Negative_2()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true };
+ var last = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+
+ using var capture = new CapturePagingQueryInterceptor();
+ arguments = arguments with { Before = last.CreateCursor(last.First!, -2) };
+ var thirdToLast = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+
+ /*
+ 11. Kinetiq
+ 12. Luminara
+ 13. Momentumix <- Selected - Item 1
+ 14. Nebularis <- Selected - Item 2
+ 15. Omniflex
+ 16. Pulsarix
+ 17. Quantumis
+ 18. Radiantum
+ 19. Synerflux <- Cursor
+ 20. Vertexis
+ */
+
+ Snapshot.Create()
+ .Add(new
+ {
+ Page = thirdToLast.Index,
+ thirdToLast.TotalCount,
+ Items = thirdToLast.Items.Select(t => t.Name).ToArray()
+ })
+ .AddSql(capture)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset_Negative_1()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true };
+ var last = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+ arguments = arguments with { Before = last.CreateCursor(last.First!, 0) };
+ var secondToLast = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+
+ using var capture = new CapturePagingQueryInterceptor();
+ arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!, -1) };
+ var fourthToLast = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Assert
+
+ /*
+ 11. Kinetiq
+ 12. Luminara
+ 13. Momentumix <- Selected - Item 1
+ 14. Nebularis <- Selected - Item 2
+ 15. Omniflex
+ 16. Pulsarix
+ 17. Quantumis <- Cursor
+ 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)
+ .MatchMarkdown();
+ }
+
+ [Fact]
+ public async Task Fetch_Backward_With_Positive_Offset()
+ {
+ // Arrange
+
+ var connectionString = CreateConnectionString();
+ await SeedAsync(connectionString);
+
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+ var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true };
+ var last = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+ arguments = arguments with { Before = last.CreateCursor(last.First!, 0) };
+ var secondToLast = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+ arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!, 0) };
+ var thirdToLast = await session.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+
+ // Act
+
+ arguments = arguments with { Before = thirdToLast.CreateCursor(thirdToLast.First!, 1) };
+
+ async Task Error()
+ {
+ await using var ctx = store.LightweightSession();
+ await ctx.Query().OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments);
+ }
+
+ // Assert
+
+ await Assert.ThrowsAsync(Error);
+ }
+
+ private static async Task SeedAsync(string connectionString)
+ {
+ var store = GetStore(connectionString);
+
+ await using var session = store.LightweightSession();
+
+ /*
+ 1. Aetherix
+ 2. Brightex
+ 3. Celestara
+ 4. Dynamova
+ 5. Evolvance
+ 6. Futurova
+ 7. Glacient
+ 8. Hyperionix
+ 9. Innovexa
+ 10. Joventra
+ 11. Kinetiq
+ 12. Luminara
+ 13. Momentumix
+ 14. Nebularis
+ 15. Omniflex
+ 16. Pulsarix
+ 17. Quantumis
+ 18. Radiantum
+ 19. Synerflux
+ 20. Vertexis
+ */
+
+ session.Store(new Brand { Name = "Aetherix", GroupId = 1 });
+ session.Store(new Brand { Name = "Brightex", GroupId = 1 });
+ session.Store(new Brand { Name = "Celestara", GroupId = 1 });
+ session.Store(new Brand { Name = "Dynamova", GroupId = 1 });
+ session.Store(new Brand { Name = "Evolvance", GroupId = 1 });
+ session.Store(new Brand { Name = "Futurova", GroupId = 1 });
+ session.Store(new Brand { Name = "Glacient", GroupId = 1 });
+ session.Store(new Brand { Name = "Hyperionix", GroupId = 1 });
+ session.Store(new Brand { Name = "Innovexa", GroupId = 1 });
+ session.Store(new Brand { Name = "Joventra", GroupId = 1 });
+
+ session.Store(new Brand { Name = "Kinetiq", GroupId = 2 });
+ session.Store(new Brand { Name = "Luminara", GroupId = 2 });
+ session.Store(new Brand { Name = "Momentumix", GroupId = 2 });
+ session.Store(new Brand { Name = "Nebularis", GroupId = 2 });
+ session.Store(new Brand { Name = "Omniflex", GroupId = 2 });
+ session.Store(new Brand { Name = "Pulsarix", GroupId = 2 });
+ session.Store(new Brand { Name = "Quantumis", GroupId = 2 });
+ session.Store(new Brand { Name = "Radiantum", GroupId = 2 });
+ session.Store(new Brand { Name = "Synerflux", GroupId = 2 });
+ session.Store(new Brand { Name = "Vertexis", GroupId = 2 });
+
+ await session.SaveChangesAsync();
+ }
+
+ public class Brand
+ {
+ public int GroupId { get; set; }
+
+ public int Id { get; set; }
+
+ [MaxLength(100)] public required string Name { get; set; }
+ }
+}
+#endif
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/SnapshotExtensions.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/SnapshotExtensions.cs
new file mode 100644
index 00000000000..ec2cc397d43
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/SnapshotExtensions.cs
@@ -0,0 +1,30 @@
+namespace GreenDonut.Data;
+
+public static class SnapshotExtensions
+{
+ public static Snapshot AddQueries(
+ this Snapshot snapshot,
+ List queries)
+ {
+ for (var i = 0; i < queries.Count; i++)
+ {
+ snapshot
+ .Add(queries[i].QueryText, $"SQL {i}", "sql")
+ .Add(queries[i].ExpressionText, $"Expression {i}");
+ }
+
+ return snapshot;
+ }
+
+ public static Snapshot AddSql(
+ this Snapshot snapshot,
+ CapturePagingQueryInterceptor interceptor)
+ {
+ for (var i = 0; i < interceptor.Queries.Count; i++)
+ {
+ snapshot.Add(interceptor.Queries[i].QueryText, $"SQL {i}", "sql");
+ }
+
+ return snapshot;
+ }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Animal.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Animal.cs
new file mode 100644
index 00000000000..5cf24c662c5
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Animal.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public abstract class Animal
+{
+ public int Id { get; set; }
+
+ [MaxLength(100)]
+ public required string Name { get; set; }
+
+ public int OwnerId { get; set; }
+
+ public Owner? Owner { get; set; }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Bar.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Bar.cs
new file mode 100644
index 00000000000..764b36fc059
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Bar.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public class Bar
+{
+ public int Id { get; set; }
+
+ [MaxLength(100)]
+ public string? Description { get; set; }
+
+ [MaxLength(100)]
+ public string SomeField1 { get; set; } = default!;
+
+ [MaxLength(100)]
+ public string? SomeField2 { get; set; }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Brand.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Brand.cs
new file mode 100644
index 00000000000..75789d5a700
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Brand.cs
@@ -0,0 +1,31 @@
+// ReSharper disable CollectionNeverUpdated.Global
+
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public class Brand
+{
+ public int Id { get; set; }
+
+ [Required]
+ public string Name { get; set; } = default!;
+
+ public string? DisplayName { get; set; } = default!;
+
+ public string? AlwaysNull { get; set; }
+
+ public ICollection Products { get; set; } = new List();
+
+ public BrandDetails BrandDetails { get; set; } = default!;
+}
+
+public class BrandDetails
+{
+ public Country Country { get; set; } = default!;
+}
+
+public class Country
+{
+ public string Name { get; set; } = default!;
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Cat.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Cat.cs
new file mode 100644
index 00000000000..9df06b2bbca
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Cat.cs
@@ -0,0 +1,6 @@
+namespace GreenDonut.Data.TestContext;
+
+public class Cat : Animal
+{
+ public bool IsPurring { get; set; }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Dog.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Dog.cs
new file mode 100644
index 00000000000..4fd7a2fdcf5
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Dog.cs
@@ -0,0 +1,6 @@
+namespace GreenDonut.Data.TestContext;
+
+public class Dog : Animal
+{
+ public bool IsBarking { get; set; }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Foo.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Foo.cs
new file mode 100644
index 00000000000..88ba56995dc
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Foo.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public class Foo
+{
+ public int Id { get; set; }
+
+ [MaxLength(100)]
+ public string Name { get; set; } = default!;
+
+ public int? BarId { get; set; }
+
+ public Bar? Bar { get; set; }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Owner.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Owner.cs
new file mode 100644
index 00000000000..dc47d8a4185
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Owner.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public class Owner
+{
+ public int Id { get; set; }
+
+ [MaxLength(100)]
+ public required string Name { get; set; }
+
+ public List Pets { get; set; } = new();
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Product.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Product.cs
new file mode 100644
index 00000000000..92efeb42924
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Product.cs
@@ -0,0 +1,102 @@
+// ReSharper disable CollectionNeverUpdated.Global
+
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public class Product
+{
+ public int Id { get; set; }
+
+ [Required]
+ public string Name { get; set; } = default!;
+
+ public string? Description { get; set; }
+
+ public decimal Price { get; set; }
+
+ public string? ImageFileName { get; set; }
+
+ public int TypeId { get; set; }
+
+ public ProductType? Type { get; set; }
+
+ public int BrandId { get; set; }
+
+ public Brand? Brand { get; set; }
+
+ // Quantity in stock
+ public int AvailableStock { get; set; }
+
+ // Available stock at which we should reorder
+ public int RestockThreshold { get; set; }
+
+ // Maximum number of units that can be in-stock at any time (due to physicial/logistical constraints in warehouses)
+ public int MaxStockThreshold { get; set; }
+
+ /// Optional embedding for the catalog item's description.
+ // [JsonIgnore]
+ // public Vector Embedding { get; set; }
+
+ ///
+ /// True if item is on reorder
+ ///
+ public bool OnReorder { get; set; }
+
+ ///
+ /// Decrements the quantity of a particular item in inventory and ensures the restockThreshold hasn't
+ /// been breached. If so, a RestockRequest is generated in CheckThreshold.
+ ///
+ /// If there is sufficient stock of an item, then the integer returned at the end of this call should be the same as quantityDesired.
+ /// In the event that there is not sufficient stock available, the method will remove whatever stock is available and return that quantity to the client.
+ /// In this case, it is the responsibility of the client to determine if the amount that is returned is the same as quantityDesired.
+ /// It is invalid to pass in a negative number.
+ ///
+ ///
+ /// int: Returns the number actually removed from stock.
+ ///
+ public int RemoveStock(int quantityDesired)
+ {
+ if (AvailableStock == 0)
+ {
+ // throw new CatalogDomainException($"Empty stock, product item {Name} is sold out");
+ }
+
+ if (quantityDesired <= 0)
+ {
+ // throw new CatalogDomainException($"Item units desired should be greater than zero");
+ }
+
+ var removed = Math.Min(quantityDesired, AvailableStock);
+
+ AvailableStock -= removed;
+
+ return removed;
+ }
+
+ ///
+ /// Increments the quantity of a particular item in inventory.
+ ///
+ /// int: Returns the quantity that has been added to stock
+ ///
+ public int AddStock(int quantity)
+ {
+ var original = AvailableStock;
+
+ // The quantity that the client is trying to add to stock is greater than what can be physically accommodated in the Warehouse
+ if ((AvailableStock + quantity) > MaxStockThreshold)
+ {
+ // For now, this method only adds new units up maximum stock threshold. In an expanded version of this application, we
+ //could include tracking for the remaining units and store information about overstock elsewhere.
+ AvailableStock += (MaxStockThreshold - AvailableStock);
+ }
+ else
+ {
+ AvailableStock += quantity;
+ }
+
+ OnReorder = false;
+
+ return AvailableStock - original;
+ }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/ProductImage.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/ProductImage.cs
new file mode 100644
index 00000000000..fe2f64e6591
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/ProductImage.cs
@@ -0,0 +1,3 @@
+namespace GreenDonut.Data.TestContext;
+
+public sealed record ProductImage(string Name, Func OpenStream);
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/ProductType.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/ProductType.cs
new file mode 100644
index 00000000000..48ac5196239
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/ProductType.cs
@@ -0,0 +1,14 @@
+// ReSharper disable CollectionNeverUpdated.Global
+
+using System.ComponentModel.DataAnnotations;
+
+namespace GreenDonut.Data.TestContext;
+
+public class ProductType
+{
+ public int Id { get; set; }
+
+ [Required] public string Name { get; set; } = default!;
+
+ public ICollection Products { get; } = new List();
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Test.cs b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Test.cs
new file mode 100644
index 00000000000..b73727140d3
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/TestContext/Test.cs
@@ -0,0 +1,34 @@
+namespace GreenDonut.Data.TestContext;
+
+public class Test
+{
+ public int Id { get; set; }
+
+ public bool Bool { get; set; }
+
+ public DateOnly DateOnly { get; set; }
+
+ public DateTimeOffset DateTimeOffset { get; set; }
+
+ public decimal Decimal { get; set; }
+
+ public double Double { get; set; }
+
+ public float Float { get; set; }
+
+ public Guid Guid { get; set; }
+
+ public int Int { get; set; }
+
+ public long Long { get; set; }
+
+ public short Short { get; set; }
+
+ public string String { get; set; } = "";
+
+ public TimeOnly TimeOnly { get; set; }
+
+ public TimeSpan TimeSpan { get; set; }
+
+ public uint UInt { get; set; }
+}
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5.md
new file mode 100644
index 00000000000..473bd2ee201
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5.md
@@ -0,0 +1,94 @@
+# Paging_First_5
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d order by d.data ->> 'Name', d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderBy(t => t.Name).ThenBy(t => t.Id).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": false,
+ "First": 1,
+ "FirstCursor": "e31CcmFuZFw6MDox",
+ "Last": 13,
+ "LastCursor": "e31CcmFuZFw6MTI6MTM="
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 1,
+ "Name": "Brand:0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ {
+ "Id": 2,
+ "Name": "Brand:1",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country1"
+ }
+ }
+ },
+ {
+ "Id": 11,
+ "Name": "Brand:10",
+ "DisplayName": "BrandDisplay10",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country10"
+ }
+ }
+ },
+ {
+ "Id": 12,
+ "Name": "Brand:11",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country11"
+ }
+ }
+ },
+ {
+ "Id": 13,
+ "Name": "Brand:12",
+ "DisplayName": "BrandDisplay12",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country12"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.md
new file mode 100644
index 00000000000..293415669f2
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13.md
@@ -0,0 +1,94 @@
+# Paging_First_5_After_Id_13
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d where (d.data ->> 'Name' > :p0 or (d.data ->> 'Name' = :p1 and d.id > :p2)) order by d.data ->> 'Name', d.id LIMIT :p3;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderBy(t => t.Name).ThenBy(t => t.Id).Where(t => ((Compare(t.Name, value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) > 0) OrElse ((t.Name == value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) AndAlso (t.Id > value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value)))).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": true,
+ "First": 14,
+ "FirstCursor": "e31CcmFuZFw6MTM6MTQ=",
+ "Last": 18,
+ "LastCursor": "e31CcmFuZFw6MTc6MTg="
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 14,
+ "Name": "Brand:13",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country13"
+ }
+ }
+ },
+ {
+ "Id": 15,
+ "Name": "Brand:14",
+ "DisplayName": "BrandDisplay14",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country14"
+ }
+ }
+ },
+ {
+ "Id": 16,
+ "Name": "Brand:15",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country15"
+ }
+ }
+ },
+ {
+ "Id": 17,
+ "Name": "Brand:16",
+ "DisplayName": "BrandDisplay16",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country16"
+ }
+ }
+ },
+ {
+ "Id": 18,
+ "Name": "Brand:17",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country17"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13_NET8_0.md
new file mode 100644
index 00000000000..293415669f2
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_After_Id_13_NET8_0.md
@@ -0,0 +1,94 @@
+# Paging_First_5_After_Id_13
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d where (d.data ->> 'Name' > :p0 or (d.data ->> 'Name' = :p1 and d.id > :p2)) order by d.data ->> 'Name', d.id LIMIT :p3;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderBy(t => t.Name).ThenBy(t => t.Id).Where(t => ((Compare(t.Name, value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) > 0) OrElse ((t.Name == value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) AndAlso (t.Id > value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value)))).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": true,
+ "First": 14,
+ "FirstCursor": "e31CcmFuZFw6MTM6MTQ=",
+ "Last": 18,
+ "LastCursor": "e31CcmFuZFw6MTc6MTg="
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 14,
+ "Name": "Brand:13",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country13"
+ }
+ }
+ },
+ {
+ "Id": 15,
+ "Name": "Brand:14",
+ "DisplayName": "BrandDisplay14",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country14"
+ }
+ }
+ },
+ {
+ "Id": 16,
+ "Name": "Brand:15",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country15"
+ }
+ }
+ },
+ {
+ "Id": 17,
+ "Name": "Brand:16",
+ "DisplayName": "BrandDisplay16",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country16"
+ }
+ }
+ },
+ {
+ "Id": 18,
+ "Name": "Brand:17",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country17"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.md
new file mode 100644
index 00000000000..56880fb94f7
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96.md
@@ -0,0 +1,94 @@
+# Paging_First_5_Before_Id_96
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d where (d.data ->> 'Name' < :p0 or (d.data ->> 'Name' = :p1 and d.id < :p2)) order by d.data ->> 'Name' desc, d.id desc LIMIT :p3;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Where(t => ((Compare(t.Name, value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) < 0) OrElse ((t.Name == value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) AndAlso (t.Id < value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value)))).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": true,
+ "First": 92,
+ "FirstCursor": "e31CcmFuZFw6OTE6OTI=",
+ "Last": 96,
+ "LastCursor": "e31CcmFuZFw6OTU6OTY="
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 92,
+ "Name": "Brand:91",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country91"
+ }
+ }
+ },
+ {
+ "Id": 93,
+ "Name": "Brand:92",
+ "DisplayName": "BrandDisplay92",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country92"
+ }
+ }
+ },
+ {
+ "Id": 94,
+ "Name": "Brand:93",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country93"
+ }
+ }
+ },
+ {
+ "Id": 95,
+ "Name": "Brand:94",
+ "DisplayName": "BrandDisplay94",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country94"
+ }
+ }
+ },
+ {
+ "Id": 96,
+ "Name": "Brand:95",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country95"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96_NET8_0.md
new file mode 100644
index 00000000000..56880fb94f7
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_Before_Id_96_NET8_0.md
@@ -0,0 +1,94 @@
+# Paging_First_5_Before_Id_96
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d where (d.data ->> 'Name' < :p0 or (d.data ->> 'Name' = :p1 and d.id < :p2)) order by d.data ->> 'Name' desc, d.id desc LIMIT :p3;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Where(t => ((Compare(t.Name, value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) < 0) OrElse ((t.Name == value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.String]).value) AndAlso (t.Id < value(GreenDonut.Data.Expressions.ExpressionHelpers+<>c__DisplayClass6_0`1[System.Int32]).value)))).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": true,
+ "First": 92,
+ "FirstCursor": "e31CcmFuZFw6OTE6OTI=",
+ "Last": 96,
+ "LastCursor": "e31CcmFuZFw6OTU6OTY="
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 92,
+ "Name": "Brand:91",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country91"
+ }
+ }
+ },
+ {
+ "Id": 93,
+ "Name": "Brand:92",
+ "DisplayName": "BrandDisplay92",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country92"
+ }
+ }
+ },
+ {
+ "Id": 94,
+ "Name": "Brand:93",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country93"
+ }
+ }
+ },
+ {
+ "Id": 95,
+ "Name": "Brand:94",
+ "DisplayName": "BrandDisplay94",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country94"
+ }
+ }
+ },
+ {
+ "Id": 96,
+ "Name": "Brand:95",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country95"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_NET8_0.md
new file mode 100644
index 00000000000..473bd2ee201
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_First_5_NET8_0.md
@@ -0,0 +1,94 @@
+# Paging_First_5
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d order by d.data ->> 'Name', d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderBy(t => t.Name).ThenBy(t => t.Id).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": false,
+ "First": 1,
+ "FirstCursor": "e31CcmFuZFw6MDox",
+ "Last": 13,
+ "LastCursor": "e31CcmFuZFw6MTI6MTM="
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 1,
+ "Name": "Brand:0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ {
+ "Id": 2,
+ "Name": "Brand:1",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country1"
+ }
+ }
+ },
+ {
+ "Id": 11,
+ "Name": "Brand:10",
+ "DisplayName": "BrandDisplay10",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country10"
+ }
+ }
+ },
+ {
+ "Id": 12,
+ "Name": "Brand:11",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country11"
+ }
+ }
+ },
+ {
+ "Id": 13,
+ "Name": "Brand:12",
+ "DisplayName": "BrandDisplay12",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country12"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_Last_5.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_Last_5.md
new file mode 100644
index 00000000000..2ba92462dff
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_Last_5.md
@@ -0,0 +1,96 @@
+# Paging_Last_5
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d order by d.data ->> 'Name' desc, d.id desc LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": false,
+ "HasPreviousPage": true,
+ "First": 96,
+ "FirstName": "Brand:95",
+ "FirstCursor": "e31CcmFuZFw6OTU6OTY=",
+ "Last": 100,
+ "LastName": "Brand:99",
+ "LastCursor": "e31CcmFuZFw6OTk6MTAw"
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 96,
+ "Name": "Brand:95",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country95"
+ }
+ }
+ },
+ {
+ "Id": 97,
+ "Name": "Brand:96",
+ "DisplayName": "BrandDisplay96",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country96"
+ }
+ }
+ },
+ {
+ "Id": 98,
+ "Name": "Brand:97",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country97"
+ }
+ }
+ },
+ {
+ "Id": 99,
+ "Name": "Brand:98",
+ "DisplayName": "BrandDisplay98",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country98"
+ }
+ }
+ },
+ {
+ "Id": 100,
+ "Name": "Brand:99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_Last_5_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_Last_5_NET8_0.md
new file mode 100644
index 00000000000..2ba92462dff
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_Last_5_NET8_0.md
@@ -0,0 +1,96 @@
+# Paging_Last_5
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_brand as d order by d.data ->> 'Name' desc, d.id desc LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderByDescending(t => t.Name).ThenByDescending(t => t.Id).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": false,
+ "HasPreviousPage": true,
+ "First": 96,
+ "FirstName": "Brand:95",
+ "FirstCursor": "e31CcmFuZFw6OTU6OTY=",
+ "Last": 100,
+ "LastName": "Brand:99",
+ "LastCursor": "e31CcmFuZFw6OTk6MTAw"
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 96,
+ "Name": "Brand:95",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country95"
+ }
+ }
+ },
+ {
+ "Id": 97,
+ "Name": "Brand:96",
+ "DisplayName": "BrandDisplay96",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country96"
+ }
+ }
+ },
+ {
+ "Id": 98,
+ "Name": "Brand:97",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country97"
+ }
+ }
+ },
+ {
+ "Id": 99,
+ "Name": "Brand:98",
+ "DisplayName": "BrandDisplay98",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country98"
+ }
+ }
+ },
+ {
+ "Id": 100,
+ "Name": "Brand:99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_WithChildCollectionProjectionExpression_First_5.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_WithChildCollectionProjectionExpression_First_5.md
new file mode 100644
index 00000000000..1ff8dc29cd3
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_WithChildCollectionProjectionExpression_First_5.md
@@ -0,0 +1,59 @@
+# Paging_WithChildCollectionProjectionExpression_First_5
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Products', d.data -> 'Products') as data from public.mt_doc_brand as d LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).Select(brand => new BrandWithProductsDto() {Id = brand.Id, Name = brand.Name, Products = brand.Products.AsQueryable().Select(ProductDto.Projection).ToList()}).OrderBy(t => t.Name).ThenBy(t => t.Id).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": false,
+ "First": 1,
+ "FirstCursor": "e31CcmFuZFw6MDox",
+ "Last": 5,
+ "LastCursor": "e31CcmFuZFw6NDo1"
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 1,
+ "Name": "Brand:0",
+ "Products": []
+ },
+ {
+ "Id": 2,
+ "Name": "Brand:1",
+ "Products": []
+ },
+ {
+ "Id": 3,
+ "Name": "Brand:2",
+ "Products": []
+ },
+ {
+ "Id": 4,
+ "Name": "Brand:3",
+ "Products": []
+ },
+ {
+ "Id": 5,
+ "Name": "Brand:4",
+ "Products": []
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_WithChildCollectionProjectionExpression_First_5_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_WithChildCollectionProjectionExpression_First_5_NET8_0.md
new file mode 100644
index 00000000000..1ff8dc29cd3
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/IntegrationPagingHelperTests.Paging_WithChildCollectionProjectionExpression_First_5_NET8_0.md
@@ -0,0 +1,59 @@
+# Paging_WithChildCollectionProjectionExpression_First_5
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Products', d.data -> 'Products') as data from public.mt_doc_brand as d LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).Select(brand => new BrandWithProductsDto() {Id = brand.Id, Name = brand.Name, Products = brand.Products.AsQueryable().Select(ProductDto.Projection).ToList()}).OrderBy(t => t.Name).ThenBy(t => t.Id).Take(6)
+```
+
+## Result 3
+
+```json
+{
+ "HasNextPage": true,
+ "HasPreviousPage": false,
+ "First": 1,
+ "FirstCursor": "e31CcmFuZFw6MDox",
+ "Last": 5,
+ "LastCursor": "e31CcmFuZFw6NDo1"
+}
+```
+
+## Result 4
+
+```json
+[
+ {
+ "Id": 1,
+ "Name": "Brand:0",
+ "Products": []
+ },
+ {
+ "Id": 2,
+ "Name": "Brand:1",
+ "Products": []
+ },
+ {
+ "Id": 3,
+ "Name": "Brand:2",
+ "Products": []
+ },
+ {
+ "Id": 4,
+ "Name": "Brand:3",
+ "Products": []
+ },
+ {
+ "Id": 5,
+ "Name": "Brand:4",
+ "Products": []
+ }
+]
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items.md
new file mode 100644
index 00000000000..8f627b88901
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items.md
@@ -0,0 +1,66 @@
+# Fetch_First_2_Items
+
+```json
+[
+ {
+ "Id": 1,
+ "Name": "Product 0-0",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 2,
+ "Name": "Product 0-1",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Between.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Between.md
new file mode 100644
index 00000000000..4a39af5e1a8
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Between.md
@@ -0,0 +1,66 @@
+# Fetch_First_2_Items_Between
+
+```json
+[
+ {
+ "Id": 2,
+ "Name": "Product 0-1",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 11,
+ "Name": "Product 0-10",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page.md
new file mode 100644
index 00000000000..1d7b065fbfa
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page.md
@@ -0,0 +1,66 @@
+# Fetch_First_2_Items_Second_Page
+
+```json
+[
+ {
+ "Id": 11,
+ "Name": "Product 0-10",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 12,
+ "Name": "Product 0-11",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_Descending_AllTypes.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_Descending_AllTypes.md
new file mode 100644
index 00000000000..dd51d646826
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_Descending_AllTypes.md
@@ -0,0 +1,474 @@
+# Fetch_First_2_Items_Second_Page_Descending_AllTypes
+
+```json
+{
+ "Bool": [
+ {
+ "Id": 10,
+ "Bool": true,
+ "DateOnly": "1970-01-10",
+ "DateTimeOffset": "1970-01-10T00:00:00+00:00",
+ "Decimal": 10.0,
+ "Double": 10.0,
+ "Float": 10.0,
+ "Guid": "00000000-0000-0000-0000-000000000009",
+ "Int": 10,
+ "Long": 10,
+ "Short": 10,
+ "String": "10",
+ "TimeOnly": "10:00:00",
+ "TimeSpan": "10:00:00",
+ "UInt": 10
+ },
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ }
+ ],
+ "DateOnly": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "DateTimeOffset": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Decimal": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Double": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Float": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Guid": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Int": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Long": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "Short": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "String": [
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ },
+ {
+ "Id": 6,
+ "Bool": true,
+ "DateOnly": "1970-01-06",
+ "DateTimeOffset": "1970-01-06T00:00:00+00:00",
+ "Decimal": 6.0,
+ "Double": 6.0,
+ "Float": 6.0,
+ "Guid": "00000000-0000-0000-0000-000000000005",
+ "Int": 6,
+ "Long": 6,
+ "Short": 6,
+ "String": "6",
+ "TimeOnly": "06:00:00",
+ "TimeSpan": "06:00:00",
+ "UInt": 6
+ }
+ ],
+ "TimeOnly": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ],
+ "UInt": [
+ {
+ "Id": 8,
+ "Bool": true,
+ "DateOnly": "1970-01-08",
+ "DateTimeOffset": "1970-01-08T00:00:00+00:00",
+ "Decimal": 8.0,
+ "Double": 8.0,
+ "Float": 8.0,
+ "Guid": "00000000-0000-0000-0000-000000000007",
+ "Int": 8,
+ "Long": 8,
+ "Short": 8,
+ "String": "8",
+ "TimeOnly": "08:00:00",
+ "TimeSpan": "08:00:00",
+ "UInt": 8
+ },
+ {
+ "Id": 7,
+ "Bool": false,
+ "DateOnly": "1970-01-07",
+ "DateTimeOffset": "1970-01-07T00:00:00+00:00",
+ "Decimal": 7.0,
+ "Double": 7.0,
+ "Float": 7.0,
+ "Guid": "00000000-0000-0000-0000-000000000006",
+ "Int": 7,
+ "Long": 7,
+ "Short": 7,
+ "String": "7",
+ "TimeOnly": "07:00:00",
+ "TimeSpan": "07:00:00",
+ "UInt": 7
+ }
+ ]
+}
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_With_Offset_2.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_With_Offset_2.md
new file mode 100644
index 00000000000..5e9be182dd0
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_With_Offset_2.md
@@ -0,0 +1,66 @@
+# Fetch_First_2_Items_Second_Page_With_Offset_2
+
+```json
+[
+ {
+ "Id": 15,
+ "Name": "Product 0-14",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 16,
+ "Name": "Product 0-15",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_With_Offset_Negative_2.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_With_Offset_Negative_2.md
new file mode 100644
index 00000000000..cb5ebf3d1be
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Second_Page_With_Offset_Negative_2.md
@@ -0,0 +1,9 @@
+# Fetch_First_2_Items_Second_Page_With_Offset_Negative_2
+
+```json
+{
+ "First": "Product 0-1",
+ "Last": "Product 0-10",
+ "ItemsCount": 2
+}
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Third_Page.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Third_Page.md
new file mode 100644
index 00000000000..e29b0f888f5
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_First_2_Items_Third_Page.md
@@ -0,0 +1,66 @@
+# Fetch_First_2_Items_Third_Page
+
+```json
+[
+ {
+ "Id": 13,
+ "Name": "Product 0-12",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 14,
+ "Name": "Product 0-13",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 1,
+ "Name": "Brand0",
+ "DisplayName": "BrandDisplay0",
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country0"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items.md
new file mode 100644
index 00000000000..21b02e0234d
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items.md
@@ -0,0 +1,66 @@
+# Fetch_Last_2_Items
+
+```json
+[
+ {
+ "Id": 9999,
+ "Name": "Product 99-98",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 100,
+ "Name": "Brand99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 10000,
+ "Name": "Product 99-99",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 100,
+ "Name": "Brand99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items_Before_Last_Page.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items_Before_Last_Page.md
new file mode 100644
index 00000000000..02929be0539
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items_Before_Last_Page.md
@@ -0,0 +1,66 @@
+# Fetch_Last_2_Items_Before_Last_Page
+
+```json
+[
+ {
+ "Id": 9997,
+ "Name": "Product 99-96",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 100,
+ "Name": "Brand99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 9998,
+ "Name": "Product 99-97",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 100,
+ "Name": "Brand99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items_Between.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items_Between.md
new file mode 100644
index 00000000000..3830165014b
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.Fetch_Last_2_Items_Between.md
@@ -0,0 +1,66 @@
+# Fetch_Last_2_Items_Between
+
+```json
+[
+ {
+ "Id": 9998,
+ "Name": "Product 99-97",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 100,
+ "Name": "Brand99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ },
+ {
+ "Id": 9999,
+ "Name": "Product 99-98",
+ "Description": null,
+ "Price": 0.0,
+ "ImageFileName": null,
+ "TypeId": 0,
+ "Type": {
+ "Id": 1,
+ "Name": "T-Shirt",
+ "Products": []
+ },
+ "BrandId": 0,
+ "Brand": {
+ "Id": 100,
+ "Name": "Brand99",
+ "DisplayName": null,
+ "AlwaysNull": null,
+ "Products": [],
+ "BrandDetails": {
+ "Country": {
+ "Name": "Country99"
+ }
+ }
+ },
+ "AvailableStock": 0,
+ "RestockThreshold": 0,
+ "MaxStockThreshold": 0,
+ "OnReorder": false
+ }
+]
+```
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector.md
new file mode 100644
index 00000000000..fa913442393
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name') as data from public.mt_doc_product as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Product]).OrderBy(t => t.Id).Select(t => new Product() {Id = t.Id, Name = t.Name}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand.md
new file mode 100644
index 00000000000..574d28beac4
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector_Include_Brand
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Brand', d.data -> 'Brand') as data from public.mt_doc_product as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Product]).OrderBy(t => t.Id).Select(root => new Product() {Id = root.Id, Name = root.Name, Brand = root.Brand}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_NET8_0.md
new file mode 100644
index 00000000000..574d28beac4
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_NET8_0.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector_Include_Brand
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Brand', d.data -> 'Brand') as data from public.mt_doc_product as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Product]).OrderBy(t => t.Id).Select(root => new Product() {Id = root.Id, Name = root.Name, Brand = root.Brand}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_Name.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_Name.md
new file mode 100644
index 00000000000..e4592c0a101
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_Name.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector_Include_Brand_Name
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Brand', jsonb_build_object('Name', d.data -> 'Brand' ->> 'Name') ) as data from public.mt_doc_product as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Product]).OrderBy(t => t.Id).Select(root => new Product() {Id = root.Id, Name = root.Name, Brand = new Brand() {Name = root.Brand.Name}}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_Name_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_Name_NET8_0.md
new file mode 100644
index 00000000000..e4592c0a101
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Brand_Name_NET8_0.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector_Include_Brand_Name
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Brand', jsonb_build_object('Name', d.data -> 'Brand' ->> 'Name') ) as data from public.mt_doc_product as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Product]).OrderBy(t => t.Id).Select(root => new Product() {Id = root.Id, Name = root.Name, Brand = new Brand() {Name = root.Brand.Name}}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Product_List.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Product_List.md
new file mode 100644
index 00000000000..1436cc5c03e
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Product_List.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector_Include_Product_List
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Products', d.data -> 'Products') as data from public.mt_doc_brand as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderBy(t => t.Id).Select(t => new Brand() {Id = t.Id, Name = t.Name, Products = t.Products}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Product_List_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Product_List_NET8_0.md
new file mode 100644
index 00000000000..1436cc5c03e
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_Include_Product_List_NET8_0.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector_Include_Product_List
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name', 'Products', d.data -> 'Products') as data from public.mt_doc_brand as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Brand]).OrderBy(t => t.Id).Select(t => new Brand() {Id = t.Id, Name = t.Name, Products = t.Products}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_NET8_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_NET8_0.md
new file mode 100644
index 00000000000..fa913442393
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/PagingHelperTests.QueryContext_Simple_Selector_NET8_0.md
@@ -0,0 +1,14 @@
+# QueryContext_Simple_Selector
+
+## SQL 0
+
+```sql
+select jsonb_build_object('Id', d.id, 'Name', d.data ->> 'Name') as data from public.mt_doc_product as d order by d.id LIMIT :p0;
+```
+
+## Expression 0
+
+```text
+value(Marten.Linq.MartenLinqQueryable`1[GreenDonut.Data.TestContext.Product]).OrderBy(t => t.Id).Select(t => new Product() {Id = t.Id, Name = t.Name}).Take(3)
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_Page_With_Offset_1.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_Page_With_Offset_1.md
new file mode 100644
index 00000000000..82579b2081f
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_Page_With_Offset_1.md
@@ -0,0 +1,21 @@
+# Fetch_Fourth_Page_With_Offset_1
+
+## Result 1
+
+```json
+{
+ "Page": 4,
+ "TotalCount": 20,
+ "Items": [
+ "Glacient",
+ "Hyperionix"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' > :p0 or (d.data ->> 'Name' = :p1 and d.id > :p2)) order by d.data ->> 'Name', d.id OFFSET :p3 LIMIT :p4;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_Page_With_Offset_2.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_Page_With_Offset_2.md
new file mode 100644
index 00000000000..4405b2bd0a6
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_Page_With_Offset_2.md
@@ -0,0 +1,21 @@
+# Fetch_Fourth_Page_With_Offset_2
+
+## Result 1
+
+```json
+{
+ "Page": 4,
+ "TotalCount": 20,
+ "Items": [
+ "Glacient",
+ "Hyperionix"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' > :p0 or (d.data ->> 'Name' = :p1 and d.id > :p2)) order by d.data ->> 'Name', d.id OFFSET :p3 LIMIT :p4;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset_Negative_1.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset_Negative_1.md
new file mode 100644
index 00000000000..19845db4e09
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset_Negative_1.md
@@ -0,0 +1,21 @@
+# Fetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset_Negative_1
+
+## Result 1
+
+```json
+{
+ "Page": 7,
+ "TotalCount": 20,
+ "Items": [
+ "Momentumix",
+ "Nebularis"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' < :p0 or (d.data ->> 'Name' = :p1 and d.id < :p2)) order by d.data ->> 'Name' desc, d.id desc OFFSET :p3 LIMIT :p4;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_To_Last_Page_Offset_Negative_2.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_To_Last_Page_Offset_Negative_2.md
new file mode 100644
index 00000000000..fc4b289de9f
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Fourth_To_Last_Page_Offset_Negative_2.md
@@ -0,0 +1,21 @@
+# Fetch_Fourth_To_Last_Page_Offset_Negative_2
+
+## Result 1
+
+```json
+{
+ "Page": 7,
+ "TotalCount": 20,
+ "Items": [
+ "Momentumix",
+ "Nebularis"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' < :p0 or (d.data ->> 'Name' = :p1 and d.id < :p2)) order by d.data ->> 'Name' desc, d.id desc OFFSET :p3 LIMIT :p4;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Second_Page.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Second_Page.md
new file mode 100644
index 00000000000..64b350b63db
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Second_Page.md
@@ -0,0 +1,21 @@
+# Fetch_Second_Page
+
+## Result 1
+
+```json
+{
+ "Page": 2,
+ "TotalCount": 20,
+ "Items": [
+ "Celestara",
+ "Dynamova"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' > :p0 or (d.data ->> 'Name' = :p1 and d.id > :p2)) order by d.data ->> 'Name', d.id LIMIT :p3;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Second_To_Last_Page_Offset_0.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Second_To_Last_Page_Offset_0.md
new file mode 100644
index 00000000000..9190845d674
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Second_To_Last_Page_Offset_0.md
@@ -0,0 +1,21 @@
+# Fetch_Second_To_Last_Page_Offset_0
+
+## Result 1
+
+```json
+{
+ "Page": 9,
+ "TotalCount": 20,
+ "Items": [
+ "Quantumis",
+ "Radiantum"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' < :p0 or (d.data ->> 'Name' = :p1 and d.id < :p2)) order by d.data ->> 'Name' desc, d.id desc LIMIT :p3;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Third_Page_With_Offset_1.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Third_Page_With_Offset_1.md
new file mode 100644
index 00000000000..bb57a898e2d
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Third_Page_With_Offset_1.md
@@ -0,0 +1,21 @@
+# Fetch_Third_Page_With_Offset_1
+
+## Result 1
+
+```json
+{
+ "Page": 3,
+ "TotalCount": 20,
+ "Items": [
+ "Evolvance",
+ "Futurova"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' > :p0 or (d.data ->> 'Name' = :p1 and d.id > :p2)) order by d.data ->> 'Name', d.id OFFSET :p3 LIMIT :p4;
+```
+
diff --git a/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Third_To_Last_Page_Offset_Negative_1.md b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Third_To_Last_Page_Offset_Negative_1.md
new file mode 100644
index 00000000000..8ca6e5ee557
--- /dev/null
+++ b/src/GreenDonut/test/GreenDonut.Data.Marten.Tests/__snapshots__/RelativeCursorTests.Fetch_Third_To_Last_Page_Offset_Negative_1.md
@@ -0,0 +1,21 @@
+# Fetch_Third_To_Last_Page_Offset_Negative_1
+
+## Result 1
+
+```json
+{
+ "Page": 8,
+ "TotalCount": 20,
+ "Items": [
+ "Omniflex",
+ "Pulsarix"
+ ]
+}
+```
+
+## SQL 0
+
+```sql
+select d.id, d.data from public.mt_doc_relativecursortests_brand as d where (d.data ->> 'Name' < :p0 or (d.data ->> 'Name' = :p1 and d.id < :p2)) order by d.data ->> 'Name' desc, d.id desc OFFSET :p3 LIMIT :p4;
+```
+
diff --git a/src/HotChocolate/Marten/src/Data/HotChocolate.Data.Marten.csproj b/src/HotChocolate/Marten/src/Data/HotChocolate.Data.Marten.csproj
index 3512ab9f054..45bf4013887 100644
--- a/src/HotChocolate/Marten/src/Data/HotChocolate.Data.Marten.csproj
+++ b/src/HotChocolate/Marten/src/Data/HotChocolate.Data.Marten.csproj
@@ -32,6 +32,7 @@
+