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 @@ +