diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index ad08cc0f1a0..a447e7e7149 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -262,7 +262,14 @@ public static async ValueTask> ToPageAsync( } var pageIndex = CreateIndex(arguments, cursor, totalCount); - return CreatePage(builder.ToImmutable(), arguments, keys, fetchCount, pageIndex, requestedCount, totalCount); + return CreateValueCursorPage( + Page.ToEntries(builder.ToImmutable()), + arguments, + keys, + fetchCount, + pageIndex, + requestedCount, + totalCount); } /// @@ -296,11 +303,12 @@ public static ValueTask>> ToBatchPageAsync ToBatchPageAsync( + => ToBatchPageAsync( source, keySelector, - t => t, + null, arguments, + includeTotalCount: arguments.IncludeTotalCount, cancellationToken); /// @@ -338,10 +346,10 @@ public static ValueTask>> ToBatchPageAsync ToBatchPageAsync( + => ToBatchPageAsync( source, keySelector, - t => t, + null, arguments, includeTotalCount: includeTotalCount, cancellationToken); @@ -368,10 +376,10 @@ public static ValueTask>> ToBatchPageAsync /// - /// The type of the items in the queryable. + /// The type of the value selected from the items in the queryable. /// /// - /// The type of the items in the queryable. + /// The type of the source elements from which keys and values are projected. /// /// /// @@ -420,7 +428,7 @@ public static ValueTask>> ToBatchPageAsync /// - /// The type of the items in the queryable. + /// The type of the source elements from which keys and values are projected. /// /// /// @@ -429,7 +437,7 @@ public static ValueTask>> ToBatchPageAsync>> ToBatchPageAsync( this IQueryable source, Expression> keySelector, - Func valueSelector, + Func? valueSelector, PagingArguments arguments, bool includeTotalCount, CancellationToken cancellationToken = default) @@ -464,6 +472,13 @@ public static async ValueTask>> ToBatchPageAsync>> ToBatchPageAsync item.Items.Count ? item.Items.Count : requestedCount; - var builder = ImmutableArray.CreateBuilder(itemCount); + var totalCount = counts?.GetValueOrDefault(item.Key) ?? batchExpression.Cursor?.TotalCount; + var pageIndex = CreateIndex(arguments, batchExpression.Cursor, totalCount); - if (batchExpression.IsBackward) + if (valueSelector is not null) { - for (var i = itemCount - 1; i >= 0; i--) + var entryBuilder = ImmutableArray.CreateBuilder>(itemCount); + var elementBuilder = ImmutableArray.CreateBuilder(itemCount); + + if (batchExpression.IsBackward) { - builder.Add(valueSelector(item.Items[i])); + for (var i = itemCount - 1; i >= 0; i--) + { + var element = item.Items[i]; + entryBuilder.Add(new PageEntry(valueSelector(element), entryBuilder.Count)); + elementBuilder.Add(element); + } } + else + { + for (var i = 0; i < itemCount; i++) + { + var element = item.Items[i]; + entryBuilder.Add(new PageEntry(valueSelector(element), entryBuilder.Count)); + elementBuilder.Add(element); + } + } + + var page = CreateElementCursorPage( + entryBuilder.ToImmutable(), + elementBuilder.ToImmutable(), + arguments, + keys, + item.Items.Count, + pageIndex, + requestedCount, + totalCount); + map.Add(item.Key, page); } else { - for (var i = 0; i < itemCount; i++) + var entryBuilder = ImmutableArray.CreateBuilder>(itemCount); + + if (batchExpression.IsBackward) { - builder.Add(valueSelector(item.Items[i])); + for (var i = itemCount - 1; i >= 0; i--) + { + entryBuilder.Add(new PageEntry((TValue)(object)item.Items[i]!, entryBuilder.Count)); + } + } + else + { + for (var i = 0; i < itemCount; i++) + { + entryBuilder.Add(new PageEntry((TValue)(object)item.Items[i]!, entryBuilder.Count)); + } } - } - var totalCount = counts?.GetValueOrDefault(item.Key) ?? batchExpression.Cursor?.TotalCount; - var pageIndex = CreateIndex(arguments, batchExpression.Cursor, totalCount); - var page = CreatePage( - builder.ToImmutable(), - arguments, - keys, - item.Items.Count, - pageIndex, - requestedCount, - totalCount); - map.Add(item.Key, page); + var page = CreateValueCursorPage( + entryBuilder.ToImmutable(), + arguments, + keys, + item.Items.Count, + pageIndex, + requestedCount, + totalCount); + map.Add(item.Key, page); + } } return map; @@ -604,14 +658,74 @@ private class CountResult public int Count { get; set; } } - private static Page CreatePage( - ImmutableArray items, + private static Page CreateValueCursorPage( + ImmutableArray> entries, + PagingArguments arguments, + CursorKey[] keys, + int fetchCount, + int? index, + int? requestedPageSize, + int? totalCount) + { + var (hasNext, hasPrevious) = CreatePageFlags(arguments, fetchCount); + + if (arguments.EnableRelativeCursors && totalCount is not null && requestedPageSize is not null) + { + return new ValueCursorPage( + entries, + hasNext, + hasPrevious, + entry => CursorFormatter.Format(entry.Node, keys, new CursorPageInfo(entry.Offset, entry.PageIndex, entry.TotalCount)), + index ?? 1, + requestedPageSize.Value, + totalCount.Value); + } + + return new ValueCursorPage( + entries, + hasNext, + hasPrevious, + item => CursorFormatter.Format(item, keys), + totalCount); + } + + private static Page CreateElementCursorPage( + ImmutableArray> entries, + ImmutableArray elements, PagingArguments arguments, CursorKey[] keys, int fetchCount, int? index, int? requestedPageSize, int? totalCount) + { + var (hasNext, hasPrevious) = CreatePageFlags(arguments, fetchCount); + + if (arguments.EnableRelativeCursors && totalCount is not null && requestedPageSize is not null) + { + return new ElementCursorPage( + entries, + elements, + hasNext, + hasPrevious, + entry => CursorFormatter.Format(entry.Node, keys, new CursorPageInfo(entry.Offset, entry.PageIndex, entry.TotalCount)), + index ?? 1, + requestedPageSize.Value, + totalCount.Value); + } + + return new ElementCursorPage( + entries, + elements, + hasNext, + hasPrevious, + item => CursorFormatter.Format(item, keys), + totalCount); + } + + private static (bool HasNext, bool HasPrevious) CreatePageFlags( + PagingArguments arguments, + int fetchCount) { var hasPrevious = false; var hasNext = false; @@ -644,24 +758,7 @@ private static Page CreatePage( 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); + return (hasNext, hasPrevious); } private static int? CreateIndex(PagingArguments arguments, Cursor? cursor, int? totalCount) diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/EdgeEntry.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/EdgeEntry.cs new file mode 100644 index 00000000000..0e015e2356f --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/EdgeEntry.cs @@ -0,0 +1,53 @@ +namespace GreenDonut.Data; + +/// +/// Bundles a node with the positional metadata needed for cursor generation. +/// +/// +/// The type of the node. +/// +public readonly struct EdgeEntry +{ + /// + /// Initializes a new instance of the struct. + /// + /// + /// The node (item from the page). + /// + /// + /// The offset relative to the current cursor position. + /// + /// + /// The page index. + /// + /// + /// The total count of items in the dataset. + /// + public EdgeEntry(T node, int offset, int pageIndex, int totalCount) + { + Node = node; + Offset = offset; + PageIndex = pageIndex; + TotalCount = totalCount; + } + + /// + /// Gets the node (the item from the page). + /// + public T Node { get; } + + /// + /// Gets the offset relative to the current cursor position. + /// + public int Offset { get; } + + /// + /// Gets the page index. + /// + public int PageIndex { get; } + + /// + /// Gets the total count of items in the dataset. + /// + public int TotalCount { get; } +} diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/ElementCursorPage.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/ElementCursorPage.cs new file mode 100644 index 00000000000..4d8a89be60f --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/ElementCursorPage.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; + +namespace GreenDonut.Data; + +/// +/// Represents a page whose cursor must be created from a different source element than the page item. +/// +/// +/// The type used to create the cursor. +/// +/// +/// The type of the page items. +/// +internal sealed class ElementCursorPage : Page +{ + private readonly ImmutableArray _elements; + private readonly Func, string> _createCursor; + + /// + /// Initializes a new instance of the class. + /// + public ElementCursorPage( + ImmutableArray> entries, + ImmutableArray elements, + bool hasNextPage, + bool hasPreviousPage, + Func createCursor, + int? totalCount = null) + : base(entries, hasNextPage, hasPreviousPage, totalCount) + { + EnsureEqualLength(entries, elements); + _elements = elements; + _createCursor = entry => createCursor(entry.Node); + } + + /// + /// Initializes a new instance of the class. + /// + public ElementCursorPage( + ImmutableArray> entries, + ImmutableArray elements, + bool hasNextPage, + bool hasPreviousPage, + Func, string> createCursor, + int index, + int requestedPageSize, + int totalCount) + : base(entries, hasNextPage, hasPreviousPage, index, requestedPageSize, totalCount) + { + EnsureEqualLength(entries, elements); + _elements = elements; + _createCursor = createCursor; + } + + protected override string CreateCursor(int index, int offset, int pageIndex, int totalCount) + => _createCursor(new EdgeEntry(_elements[index], offset, pageIndex, totalCount)); + + private static void EnsureEqualLength( + ImmutableArray> entries, + ImmutableArray elements) + { + if (entries.Length != elements.Length) + { + throw new ArgumentException( + "The items and elements collections must have the same length."); + } + } +} diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs index b8d79d9a91a..4addf220986 100644 --- a/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs +++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/Page.cs @@ -9,21 +9,13 @@ namespace GreenDonut.Data; /// /// The type of the items. /// -public sealed class Page : IEnumerable +public abstract class Page : IReadOnlyList { - private readonly ImmutableArray _items; - private readonly bool _hasNextPage; - private readonly bool _hasPreviousPage; - private readonly Func _createCursor; - private readonly int? _requestedPageSize; - private readonly int? _index; - private readonly int? _totalCount; - /// /// Initializes a new instance of the class. /// - /// - /// The items of the page. + /// + /// The entries of the page. /// /// /// Defines if there is a next page. @@ -31,31 +23,26 @@ public sealed class Page : IEnumerable /// /// Defines if there is a previous page. /// - /// - /// A delegate to create a cursor for an item. - /// /// /// The total count of items in the dataset. /// - public Page( - ImmutableArray items, + protected Page( + ImmutableArray> entries, bool hasNextPage, bool hasPreviousPage, - Func createCursor, int? totalCount = null) { - _items = items; - _hasNextPage = hasNextPage; - _hasPreviousPage = hasPreviousPage; - _createCursor = (item, _, _, _) => createCursor(item); - _totalCount = totalCount; + Entries = entries; + HasNextPage = hasNextPage; + HasPreviousPage = hasPreviousPage; + TotalCount = totalCount; } /// /// Initializes a new instance of the class. /// - /// - /// The items of the page. + /// + /// The entries of the page. /// /// /// Defines if there is a next page. @@ -63,111 +50,266 @@ public Page( /// /// Defines if there is a previous page. /// - /// - /// A delegate to create a cursor for an item. - /// - /// - /// The total count of items in the dataset. - /// /// /// The index number of this page. /// /// /// The requested page size. /// - internal Page( - ImmutableArray items, + /// + /// The total count of items in the dataset. + /// + protected Page( + ImmutableArray> entries, bool hasNextPage, bool hasPreviousPage, - Func createCursor, int index, int requestedPageSize, int totalCount) { - _items = items; - _hasNextPage = hasNextPage; - _hasPreviousPage = hasPreviousPage; - _createCursor = createCursor; - _index = index; - _requestedPageSize = requestedPageSize; - _totalCount = totalCount; + Entries = entries; + HasNextPage = hasNextPage; + HasPreviousPage = hasPreviousPage; + Index = index; + RequestedSize = requestedPageSize; + TotalCount = totalCount; } /// - /// Gets the items of this page. + /// Gets the entries of this page. /// - public ImmutableArray Items => _items; + public ImmutableArray> Entries { get; } /// - /// Gets the first item of this page. + /// Gets the number of items in this page. /// - public T? First => _items.Length > 0 ? _items[0] : default; + public int Count => Entries.Length; /// - /// Gets the last item of this page. + /// Gets the item at the specified index. /// - public T? Last => _items.Length > 0 ? _items[^1] : default; + /// + /// The zero-based index of the item. + /// + public T this[int index] => Entries[index].Item; + + /// + /// Gets the first entry of this page. + /// + public PageEntry? First => Entries.Length > 0 ? Entries[0] : null; + + /// + /// Gets the last entry of this page. + /// + public PageEntry? Last => Entries.Length > 0 ? Entries[^1] : null; /// /// Defines if there is a next page. /// - public bool HasNextPage => _hasNextPage; + public bool HasNextPage { get; } /// /// Defines if there is a previous page. /// - public bool HasPreviousPage => _hasPreviousPage; + public bool HasPreviousPage { get; } /// /// Gets the index number of this page. /// - public int? Index => _index; + public int? Index { get; } /// /// Gets the requested page size. /// This value can be null if the page size is unknown. /// - internal int? RequestedSize => _requestedPageSize; + internal int? RequestedSize { get; } /// /// Gets the total count of items in the dataset. /// This value can be null if the total count is unknown. /// - public int? TotalCount => _totalCount; + public int? TotalCount { get; } /// - /// Creates a cursor for an item of this page. + /// Creates a cursor for an entry of this page. /// - /// - /// The item for which a cursor shall be created. + /// + /// The entry for which a cursor shall be created. /// /// - /// Returns a cursor for the item. + /// Returns a cursor for the entry. /// - public string CreateCursor(T item) => _createCursor(item, 0, 0, 0); + public string CreateCursor(PageEntry entry) + { + EnsureIndex(entry.Index); + return CreateCursor(entry.Index, 0, 0, 0); + } - public string CreateCursor(T item, int offset) + /// + /// Creates a relative cursor for an entry of this page. + /// + /// + /// The entry for which a cursor shall be created. + /// + /// + /// The offset relative to the current cursor position. + /// + /// + /// Returns a cursor for the entry. + /// + public string CreateCursor(PageEntry entry, int offset) { - if (_index is null || _totalCount is null) + EnsureIndex(entry.Index); + + if (Index is null || TotalCount is null) { throw new InvalidOperationException("This page does not allow relative cursors."); } - return _createCursor(item, offset, _index ?? 1, _totalCount ?? 0); + return CreateCursor(entry.Index, offset, Index.Value, TotalCount.Value); } /// /// An empty page. /// - public static Page Empty => new([], false, false, _ => string.Empty); + public static Page Empty => ValueCursorPage.Empty; + + protected abstract string CreateCursor(int index, int offset, int pageIndex, int totalCount); + + private void EnsureIndex(int index) + { + if ((uint)index >= (uint)Entries.Length) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + } /// /// Gets the enumerator for the items of this page. /// - /// - public IEnumerator GetEnumerator() - => ((IEnumerable)_items).GetEnumerator(); + public Enumerator GetEnumerator() => new(Entries); + + IEnumerator IEnumerable.GetEnumerator() + { + foreach (var entry in Entries) + { + yield return entry.Item; + } + } IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); + => ((IEnumerable)this).GetEnumerator(); + + /// + /// Creates a page whose cursor can be created directly from the page item. + /// + public static Page Create( + ImmutableArray items, + bool hasNextPage, + bool hasPreviousPage, + Func createCursor, + int? totalCount = null) + => new ValueCursorPage(ToEntries(items), hasNextPage, hasPreviousPage, createCursor, totalCount); + + /// + /// Creates a page whose cursor can be created directly from the page item. + /// + public static Page Create( + ImmutableArray items, + bool hasNextPage, + bool hasPreviousPage, + Func, string> createCursor, + int index, + int requestedPageSize, + int totalCount) + => new ValueCursorPage( + ToEntries(items), + hasNextPage, + hasPreviousPage, + createCursor, + index, + requestedPageSize, + totalCount); + + /// + /// Creates a page whose cursor must be created from a different source element than the page item. + /// + public static Page Create( + ImmutableArray items, + ImmutableArray elements, + bool hasNextPage, + bool hasPreviousPage, + Func createCursor, + int? totalCount = null) + => new ElementCursorPage( + ToEntries(items), + elements, + hasNextPage, + hasPreviousPage, + createCursor, + totalCount); + + /// + /// Creates a page whose cursor must be created from a different source element than the page item. + /// + public static Page Create( + ImmutableArray items, + ImmutableArray elements, + bool hasNextPage, + bool hasPreviousPage, + Func, string> createCursor, + int index, + int requestedPageSize, + int totalCount) + => new ElementCursorPage( + ToEntries(items), + elements, + hasNextPage, + hasPreviousPage, + createCursor, + index, + requestedPageSize, + totalCount); + + internal static ImmutableArray> ToEntries(ImmutableArray items) + { + if (items.IsEmpty) + { + return []; + } + + var builder = ImmutableArray.CreateBuilder>(items.Length); + + for (var i = 0; i < items.Length; i++) + { + builder.Add(new PageEntry(items[i], i)); + } + + return builder.MoveToImmutable(); + } + + /// + /// A struct-based enumerator that yields the nodes of the page entries. + /// + public struct Enumerator + { + private readonly ImmutableArray> _entries; + private int _index; + + internal Enumerator(ImmutableArray> entries) + { + _entries = entries; + _index = -1; + } + + /// + /// Gets the current item. + /// + public T Current => _entries[_index].Item; + + /// + /// Advances the enumerator to the next item. + /// + public bool MoveNext() => ++_index < _entries.Length; + } } diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/PageEntry.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/PageEntry.cs new file mode 100644 index 00000000000..fad8f31f670 --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/PageEntry.cs @@ -0,0 +1,38 @@ +namespace GreenDonut.Data; + +/// +/// Represents an entry in a page, bundling the item with its position. +/// +/// +/// The type of the item. +/// +public readonly struct PageEntry +{ + /// + /// Initializes a new instance of the struct. + /// + /// + /// The item from the page. + /// + /// + /// The zero-based index of the item within the page. + /// + public PageEntry(T item, int index) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentOutOfRangeException.ThrowIfLessThan(index, 0); + + Item = item; + Index = index; + } + + /// + /// Gets the item from the page. + /// + public T Item { get; } + + /// + /// Gets the zero-based index of the item within the page. + /// + public int Index { get; } +} diff --git a/src/GreenDonut/src/GreenDonut.Data.Primitives/ValueCursorPage.cs b/src/GreenDonut/src/GreenDonut.Data.Primitives/ValueCursorPage.cs new file mode 100644 index 00000000000..33e4454f6a8 --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data.Primitives/ValueCursorPage.cs @@ -0,0 +1,52 @@ +using System.Collections.Immutable; + +namespace GreenDonut.Data; + +/// +/// Represents a page whose cursor can be created directly from the page item. +/// +/// +/// The type of the page items. +/// +internal sealed class ValueCursorPage : Page +{ + private readonly Func, string> _createCursor; + + /// + /// Initializes a new instance of the class. + /// + public ValueCursorPage( + ImmutableArray> entries, + bool hasNextPage, + bool hasPreviousPage, + Func createCursor, + int? totalCount = null) + : base(entries, hasNextPage, hasPreviousPage, totalCount) + { + _createCursor = entry => createCursor(entry.Node); + } + + /// + /// Initializes a new instance of the class. + /// + public ValueCursorPage( + ImmutableArray> entries, + bool hasNextPage, + bool hasPreviousPage, + Func, string> createCursor, + int index, + int requestedPageSize, + int totalCount) + : base(entries, hasNextPage, hasPreviousPage, index, requestedPageSize, totalCount) + { + _createCursor = createCursor; + } + + /// + /// An empty page. + /// + public static new ValueCursorPage Empty { get; } = new([], false, false, _ => string.Empty); + + protected override string CreateCursor(int index, int offset, int pageIndex, int totalCount) + => _createCursor(new EdgeEntry(Entries[index].Item, offset, pageIndex, totalCount)); +} diff --git a/src/GreenDonut/src/GreenDonut.Data/Extensions/GreenDonutPageExtensions.cs b/src/GreenDonut/src/GreenDonut.Data/Extensions/GreenDonutPageExtensions.cs index 1aeec3ba4c6..51db5732c0b 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Extensions/GreenDonutPageExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Extensions/GreenDonutPageExtensions.cs @@ -30,7 +30,7 @@ public static class GreenDonutPageExtensions /// /// /// This method creates cursors for the previous pages based on the current page. - /// The cursors are created using the method. + /// The cursors are created using the method. /// public static ImmutableArray CreateRelativeBackwardCursors(this Page page, int maxCursors = 5) { @@ -48,6 +48,7 @@ public static ImmutableArray CreateRelativeBackwardCursors(this P return []; } + var firstEntry = page.First.Value; var previousPages = page.Index.Value - 1; var cursors = ImmutableArray.CreateBuilder(); @@ -58,7 +59,7 @@ public static ImmutableArray CreateRelativeBackwardCursors(this P cursors.Insert( 0, new PageCursor( - page.CreateCursor(page.First, i), + page.CreateCursor(firstEntry, i), previousPages + i)); } @@ -88,7 +89,7 @@ public static ImmutableArray CreateRelativeBackwardCursors(this P /// /// /// This method creates cursors for the next pages based on the current page. - /// The cursors are created using the method. + /// The cursors are created using the method. /// public static ImmutableArray CreateRelativeForwardCursors(this Page page, int maxCursors = 5) { @@ -113,14 +114,15 @@ public static ImmutableArray CreateRelativeForwardCursors(this Pa return []; } + var lastEntry = page.Last.Value; var cursors = ImmutableArray.CreateBuilder(); - cursors.Add(new PageCursor(page.CreateCursor(page.Last, 0), page.Index.Value + 1)); + cursors.Add(new PageCursor(page.CreateCursor(lastEntry, 0), page.Index.Value + 1)); for (var i = 1; i < maxCursors && page.Index + i < totalPages; i++) { cursors.Add( new PageCursor( - page.CreateCursor(page.Last, i), + page.CreateCursor(lastEntry, i), page.Index.Value + i + 1)); } diff --git a/src/GreenDonut/src/GreenDonut.Data/Extensions/PageCursorExtensions.cs b/src/GreenDonut/src/GreenDonut.Data/Extensions/PageCursorExtensions.cs new file mode 100644 index 00000000000..faaa0c1a491 --- /dev/null +++ b/src/GreenDonut/src/GreenDonut.Data/Extensions/PageCursorExtensions.cs @@ -0,0 +1,25 @@ +namespace GreenDonut.Data; + +/// +/// Extensions for creating cursors from the boundaries of a page. +/// +public static class PageCursorExtensions +{ + /// + /// Creates a cursor for the first item of the page. + /// + public static string? CreateStartCursor(this Page page) + { + ArgumentNullException.ThrowIfNull(page); + return page.First is not null ? page.CreateCursor(page.First.Value) : null; + } + + /// + /// Creates a cursor for the last item of the page. + /// + public static string? CreateEndCursor(this Page page) + { + ArgumentNullException.ThrowIfNull(page); + return page.Last is not null ? page.CreateCursor(page.Last.Value) : null; + } +} diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperIntegrationTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperIntegrationTests.cs index 4032d3144ba..b2e16934016 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperIntegrationTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperIntegrationTests.cs @@ -36,12 +36,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -68,12 +68,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -104,12 +104,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -136,14 +136,14 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstName = result.First?.Item.Name, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastName = result.Last?.Item.Name, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -174,12 +174,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -214,12 +214,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -255,9 +255,9 @@ public async Task BatchPaging_First_5() snapshot.Add( new { - First = page.Value.CreateCursor(page.Value.First!), - Last = page.Value.CreateCursor(page.Value.Last!), - page.Value.Items + First = page.Value.CreateStartCursor(), + Last = page.Value.CreateEndCursor(), + Items = page.Value.ToArray() }, name: page.Key.ToString()); } @@ -297,9 +297,9 @@ public async Task BatchPaging_Last_5() snapshot.Add( new { - First = page.Value.CreateCursor(page.Value.First!), - Last = page.Value.CreateCursor(page.Value.Last!), - page.Value.Items + First = page.Value.CreateStartCursor(), + Last = page.Value.CreateEndCursor(), + Items = page.Value.ToArray() }, name: page.Key.ToString()); } @@ -339,9 +339,9 @@ public async Task BatchPaging_With_Relative_Cursor() snapshot.Add( new { - First = page.Value.CreateCursor(page.Value.First!, 0), - Last = page.Value.CreateCursor(page.Value.Last!, 0), - page.Value.Items + First = page.Value.CreateCursor(page.Value.Entries[0], 0), + Last = page.Value.CreateCursor(page.Value.Entries[^1], 0), + Items = page.Value.ToArray() }, name: page.Key.ToString()); } diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperPostgreSqlNullableTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperPostgreSqlNullableTests.cs index 42894406b4b..2a053bb6bc2 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperPostgreSqlNullableTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperPostgreSqlNullableTests.cs @@ -27,7 +27,7 @@ public async Task Paging_Nullable_Ascending_Cursor_Value_NonNull() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.Time) @@ -59,7 +59,7 @@ public async Task Paging_Nullable_Descending_Cursor_Value_NonNull() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Time) @@ -94,7 +94,7 @@ public async Task Paging_Nullable_Ascending_Cursor_Value_Null() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.Time) @@ -126,7 +126,7 @@ public async Task Paging_Nullable_Descending_Cursor_Value_Null() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Time) @@ -161,7 +161,7 @@ public async Task Paging_NullableReference_Ascending_Cursor_Value_NonNull() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.String) @@ -193,7 +193,7 @@ public async Task Paging_NullableReference_Descending_Cursor_Value_NonNull() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.String) @@ -228,7 +228,7 @@ public async Task Paging_NullableReference_Ascending_Cursor_Value_Null() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.String) @@ -260,7 +260,7 @@ public async Task Paging_NullableReference_Descending_Cursor_Value_Null() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.String) @@ -298,7 +298,7 @@ async Task Act() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.Time) diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperSqlServerNullableTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperSqlServerNullableTests.cs index 84eaf510b00..0f70bfc0dac 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperSqlServerNullableTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperSqlServerNullableTests.cs @@ -27,7 +27,7 @@ public async Task Paging_Nullable_Ascending_Cursor_Value_NonNull() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.Time) @@ -59,7 +59,7 @@ public async Task Paging_Nullable_Descending_Cursor_Value_NonNull() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Time) @@ -91,7 +91,7 @@ public async Task Paging_Nullable_Ascending_Cursor_Value_Null() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.Time) @@ -123,7 +123,7 @@ public async Task Paging_Nullable_Descending_Cursor_Value_Null() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Time) @@ -155,7 +155,7 @@ public async Task Paging_NullableReference_Ascending_Cursor_Value_NonNull() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.String) @@ -187,7 +187,7 @@ public async Task Paging_NullableReference_Descending_Cursor_Value_NonNull() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.String) @@ -219,7 +219,7 @@ public async Task Paging_NullableReference_Ascending_Cursor_Value_Null() .ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderBy(t => t.Date) .ThenBy(t => t.String) @@ -251,7 +251,7 @@ public async Task Paging_NullableReference_Descending_Cursor_Value_Null() .ThenByDescending(t => t.Id) .ToPageAsync(arguments); - arguments = arguments with { After = page1.CreateCursor(page1.Last!) }; + arguments = arguments with { After = page1.CreateEndCursor() }; var page2 = await context.Records .OrderByDescending(t => t.Date) .ThenByDescending(t => t.String) diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperTests.cs index ed9f416e537..00ebccd4a97 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperTests.cs @@ -41,7 +41,7 @@ public async Task Fetch_First_2_Items_Second_Page() var page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act - arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!)); + arguments = new PagingArguments(2, after: page.CreateEndCursor()); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -61,7 +61,7 @@ public async Task Fetch_First_2_Items_Second_Page_With_Offset_2() var page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act - var cursor = page.CreateCursor(page.Last!, 2); + var cursor = page.CreateCursor(page.Last!.Value, 2); arguments = new PagingArguments(2, after: cursor); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); @@ -83,12 +83,12 @@ public async Task Fetch_First_2_Items_Second_Page_With_Offset_Negative_2() var first = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // -> get second page - var cursor = first.CreateCursor(first.Last!, 0); + var cursor = first.CreateCursor(first.Last!.Value, 0); arguments = new PagingArguments(2, after: cursor) { EnableRelativeCursors = true }; var page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // -> get third page - cursor = page.CreateCursor(page.Last!, 0); + cursor = page.CreateCursor(page.Last!.Value, 0); arguments = new PagingArguments(2, after: cursor) { EnableRelativeCursors = true }; page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); @@ -105,7 +105,7 @@ 16 Product 0-15 17 Product 0-16 18 Product 0-17 */ - cursor = page.CreateCursor(page.Last!, -1); + cursor = page.CreateCursor(page.Last!.Value, -1); arguments = new PagingArguments(last: 2, before: cursor); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); @@ -124,9 +124,9 @@ 18 Product 0-17 */ new { - First = page.First!.Name, - Last = page.Last!.Name, - ItemsCount = page.Items.Length + First = page.First!.Value.Item.Name, + Last = page.Last!.Value.Item.Name, + ItemsCount = page.Count }.MatchMarkdownSnapshot(); } @@ -143,11 +143,11 @@ public async Task Fetch_First_2_Items_Third_Page() var page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id) .ToPageAsync(arguments); - arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!)); + arguments = new PagingArguments(2, after: page.CreateEndCursor()); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act - arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!)); + arguments = new PagingArguments(2, after: page.CreateEndCursor()); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -168,7 +168,7 @@ public async Task Fetch_First_2_Items_Between() .ToPageAsync(arguments); // Act - arguments = new PagingArguments(2, after: page.CreateCursor(page.First!), before: page.CreateCursor(page.Last!)); + arguments = new PagingArguments(2, after: page.CreateStartCursor(), before: page.CreateEndCursor()); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -328,7 +328,7 @@ public async Task Fetch_Last_2_Items_Before_Last_Page() .ToPageAsync(arguments); // Act - arguments = arguments with { Before = page.CreateCursor(page.First!) }; + arguments = arguments with { Before = page.CreateStartCursor() }; page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -351,7 +351,7 @@ public async Task Fetch_Last_2_Items_Between() .ToPageAsync(arguments); // Act - arguments = new PagingArguments(after: page.CreateCursor(page.First!), last: 2, before: page.CreateCursor(page.Last!)); + arguments = new PagingArguments(after: page.CreateStartCursor(), last: 2, before: page.CreateEndCursor()); page = await context.Products.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -382,9 +382,9 @@ public async Task Batch_Fetch_First_2_Items() snapshot.Add( new { - First = page.Value.CreateCursor(page.Value.First!), - Last = page.Value.CreateCursor(page.Value.Last!), - page.Value.Items + First = page.Value.CreateStartCursor(), + Last = page.Value.CreateEndCursor(), + Items = page.Value.ToArray() }, name: page.Key.ToString()); } @@ -438,7 +438,7 @@ public async Task Fetch_First_2_Items_Second_Page_Descending_AllTypes() var page = await query.ThenByDescending(t => t.Id).ToPageAsync(arguments); // Get 2nd page. - arguments = new PagingArguments(2, after: page.CreateCursor(page.Last!)); + arguments = new PagingArguments(2, after: page.CreateEndCursor()); pages.Add(label, await query.ThenByDescending(t => t.Id).ToPageAsync(arguments)); } diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs index 19d5baba324..248b306bebc 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs @@ -98,11 +98,11 @@ e is Dog var secondPage = await context.Pets .With(query, sort => sort.AddDescending(e => e.Name)) - .ToPageAsync(arguments with { After = firstPage.CreateCursor(firstPage.Last!) }); + .ToPageAsync(arguments with { After = firstPage.CreateEndCursor() }); // assert Assert.NotNull(secondPage); - Assert.Equal(2, secondPage.Items.Length); + Assert.Equal(2, secondPage.Count); } [Fact] @@ -135,13 +135,13 @@ e is Dog .With(query, sort => sort.AddDescending(e => e.Name)) .ToBatchPageAsync( e => e.OwnerId, - arguments with { After = firstPage.CreateCursor(firstPage.Last!) }); + arguments with { After = firstPage.CreateEndCursor() }); var secondPage = Assert.Single(secondMap).Value; // assert Assert.NotNull(secondPage); - Assert.Equal(2, secondPage.Items.Length); + Assert.Equal(2, secondPage.Count); } private static async Task SeedFileSystemAsync(string connectionString) diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs index 4a15e6ba395..c0dd398ece4 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/RelativeCursorTests.cs @@ -28,7 +28,7 @@ public async Task Fetch_Second_Page() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 0) }; var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -43,7 +43,7 @@ 6. Futurova */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = second.Index, second.TotalCount, Items = second.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -63,7 +63,7 @@ public async Task BatchFetch_Second_Page() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 0) }; var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var second = map[1]; @@ -80,7 +80,7 @@ 6. Futurova */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = second.Index, second.TotalCount, Items = second.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -100,7 +100,7 @@ public async Task Fetch_Third_Page_With_Offset_1() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 1) }; var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -115,7 +115,7 @@ 6. Futurova <- Page 3 - Item 2 */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = second.Index, second.TotalCount, Items = second.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -135,7 +135,7 @@ public async Task BatchFetch_Third_Page_With_Offset_1() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 1) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 1) }; var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var second = map[1]; @@ -152,7 +152,7 @@ 6. Futurova <- Page 3 - Item 2 */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = second.Index, second.TotalCount, Items = second.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = second.Index, second.TotalCount, Items = second.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -168,13 +168,13 @@ public async Task Fetch_Fourth_Page_With_Offset_1() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; var first = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 0) }; var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = second.CreateCursor(second.Last!, 1) }; + arguments = arguments with { After = second.CreateCursor(second.Last!.Value, 1) }; var fourth = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -191,7 +191,7 @@ 8. Hyperionix <- Page 4 - Item 2 */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -207,13 +207,13 @@ public async Task BatchFetch_Fourth_Page_With_Offset_1() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(2) { EnableRelativeCursors = true }; var first = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { After = first.CreateCursor(first.Last!, 0) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 0) }; var second = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = second.CreateCursor(second.Last!, 1) }; + arguments = arguments with { After = second.CreateCursor(second.Last!.Value, 1) }; var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var fourth = map[1]; @@ -232,7 +232,7 @@ 8. Hyperionix <- Page 4 - Item 2 */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -252,7 +252,7 @@ public async Task Fetch_Fourth_Page_With_Offset_2() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 2) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 2) }; var fourth = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -269,7 +269,7 @@ 8. Hyperionix <- Page 4 - Item 2 */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -289,7 +289,7 @@ public async Task BatchFetch_Fourth_Page_With_Offset_2() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { After = first.CreateCursor(first.Last!, 2) }; + arguments = arguments with { After = first.CreateCursor(first.Last!.Value, 2) }; var map = await context.Brands.Where(t => t.GroupId == 1).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var fourth = map[1]; @@ -308,7 +308,7 @@ 8. Hyperionix <- Page 4 - Item 2 */ Snapshot.Create(postFix: TestEnvironment.TargetFramework) - .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Items.Select(t => t.Name).ToArray() }) + .Add(new { Page = fourth.Index, fourth.TotalCount, Items = fourth.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); } @@ -328,7 +328,7 @@ public async Task Fetch_Second_To_Last_Page_Offset_0() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, 0) }; var secondToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -348,7 +348,7 @@ 20. Vertexis { Page = secondToLast.Index, secondToLast.TotalCount, - Items = secondToLast.Items.Select(t => t.Name).ToArray() + Items = secondToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -369,7 +369,7 @@ public async Task BatchFetch_Second_To_Last_Page_Offset_0() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, 0) }; var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var secondToLast = map[2]; @@ -391,7 +391,7 @@ 20. Vertexis { Page = secondToLast.Index, secondToLast.TotalCount, - Items = secondToLast.Items.Select(t => t.Name).ToArray() + Items = secondToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -412,7 +412,7 @@ public async Task Fetch_Third_To_Last_Page_Offset_Negative_1() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { Before = last.CreateCursor(last.First!, -1) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, -1) }; var thirdToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -432,7 +432,7 @@ 20. Vertexis { Page = thirdToLast.Index, thirdToLast.TotalCount, - Items = thirdToLast.Items.Select(t => t.Name).ToArray() + Items = thirdToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -453,7 +453,7 @@ public async Task BatchFetch_Third_To_Last_Page_Offset_Negative_1() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { Before = last.CreateCursor(last.First!, -1) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, -1) }; var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var thirdToLast = map[2]; @@ -475,7 +475,7 @@ 20. Vertexis { Page = thirdToLast.Index, thirdToLast.TotalCount, - Items = thirdToLast.Items.Select(t => t.Name).ToArray() + Items = thirdToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -496,7 +496,7 @@ public async Task Fetch_Fourth_To_Last_Page_Offset_Negative_2() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { Before = last.CreateCursor(last.First!, -2) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, -2) }; var thirdToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -519,7 +519,7 @@ 20. Vertexis { Page = thirdToLast.Index, thirdToLast.TotalCount, - Items = thirdToLast.Items.Select(t => t.Name).ToArray() + Items = thirdToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -540,7 +540,7 @@ public async Task BatchFetch_Fourth_To_Last_Page_Offset_Negative_2() // Act using var capture = new CapturePagingQueryInterceptor(); - arguments = arguments with { Before = last.CreateCursor(last.First!, -2) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, -2) }; var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var thirdToLast = map[2]; @@ -565,7 +565,7 @@ 20. Vertexis { Page = thirdToLast.Index, thirdToLast.TotalCount, - Items = thirdToLast.Items.Select(t => t.Name).ToArray() + Items = thirdToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -582,13 +582,13 @@ public async Task Fetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset_Nega await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, 0) }; var secondToLast = await context.Brands.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) }; + arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!.Value, -1) }; var fourthToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Assert @@ -611,7 +611,7 @@ 20. Vertexis { Page = fourthToLast.Index, fourthToLast.TotalCount, - Items = fourthToLast.Items.Select(t => t.Name).ToArray() + Items = fourthToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -628,13 +628,13 @@ public async Task BatchFetch_Fourth_To_Last_Page_From_Second_To_Last_Page_Offset await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, 0) }; var secondToLast = await context.Brands.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) }; + arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!.Value, -1) }; var map = await context.Brands.Where(t => t.GroupId == 2).OrderBy(t => t.Name).ThenBy(t => t.Id) .ToBatchPageAsync(t => t.GroupId, arguments); var fourthToLast = map[2]; @@ -659,7 +659,7 @@ 20. Vertexis { Page = fourthToLast.Index, fourthToLast.TotalCount, - Items = fourthToLast.Items.Select(t => t.Name).ToArray() + Items = fourthToLast.Select(t => t.Name).ToArray() }) .AddSql(capture) .MatchSnapshot(); @@ -676,14 +676,14 @@ public async Task Fetch_Backward_With_Positive_Offset() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, 0) }; var secondToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!, 0) }; + arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!.Value, 0) }; var thirdToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act - arguments = arguments with { Before = thirdToLast.CreateCursor(thirdToLast.First!, 1) }; + arguments = arguments with { Before = thirdToLast.CreateCursor(thirdToLast.First!.Value, 1) }; async Task Error() { @@ -707,14 +707,14 @@ public async Task BatchFetch_Backward_With_Positive_Offset() await using var context = new TestContext(connectionString); var arguments = new PagingArguments(last: 2) { EnableRelativeCursors = true }; var last = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { Before = last.CreateCursor(last.First!, 0) }; + arguments = arguments with { Before = last.CreateCursor(last.First!.Value, 0) }; var secondToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); - arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!, 0) }; + arguments = arguments with { Before = secondToLast.CreateCursor(secondToLast.First!.Value, 0) }; var thirdToLast = await context.Brands.OrderBy(t => t.Name).ThenBy(t => t.Id).ToPageAsync(arguments); // Act - arguments = arguments with { Before = thirdToLast.CreateCursor(thirdToLast.First!, 1) }; + arguments = arguments with { Before = thirdToLast.CreateCursor(thirdToLast.First!.Value, 1) }; async Task Error() { diff --git a/src/GreenDonut/test/GreenDonut.Data.Tests/PageTests.cs b/src/GreenDonut/test/GreenDonut.Data.Tests/PageTests.cs new file mode 100644 index 00000000000..0693792464e --- /dev/null +++ b/src/GreenDonut/test/GreenDonut.Data.Tests/PageTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; + +namespace GreenDonut.Data; + +public class PageTests +{ + [Fact] + public void CreateCursor_UsesMatchingElementIndex_WhenValuesRepeat() + { + // arrange + var page = Page.Create( + items: ["duplicate", "duplicate"], + elements: ImmutableArray.Create(1, 2), + hasNextPage: false, + hasPreviousPage: false, + createCursor: element => element.ToString()); + + // act + var first = page.CreateStartCursor(); + var second = page.CreateEndCursor(); + + // assert + Assert.Equal(0, page.First!.Value.Index); + Assert.Equal(1, page.Last!.Value.Index); + Assert.Equal("1", first); + Assert.Equal("2", second); + } + + [Fact] + public void CreateCursor_Throws_WhenIndexIsNegative() + { + // arrange + var page = Page.Create( + items: ["a"], + hasNextPage: false, + hasPreviousPage: false, + createCursor: static value => value); + + // act + void Action() => page.CreateCursor(new PageEntry("a", -1)); + + // assert + Assert.Throws(Action); + } + + [Fact] + public void CreateCursor_Throws_WhenIndexIsOutsidePage() + { + // arrange + var page = Page.Create( + items: ["a"], + hasNextPage: false, + hasPreviousPage: false, + createCursor: static value => value); + + // act + void Action() => page.CreateCursor(new PageEntry("a", 1)); + + // assert + Assert.Throws(Action); + } + + [Fact] + public void FirstAndLast_AreNull_OnEmptyPage() + { + // arrange + var page = Page.Empty; + + // assert + Assert.Null(page.First); + Assert.Null(page.Last); + Assert.Null(page.CreateStartCursor()); + Assert.Null(page.CreateEndCursor()); + } + + [Fact] + public void CreateRelativeBackwardCursors_UsesFirstPageIndex() + { + // arrange + var page = Page.Create( + items: ["duplicate", "duplicate"], + elements: ImmutableArray.Create(1, 2), + hasNextPage: true, + hasPreviousPage: true, + createCursor: static entry => $"{entry.Node}:{entry.Offset}:{entry.PageIndex}:{entry.TotalCount}", + index: 3, + requestedPageSize: 2, + totalCount: 10); + + // act + var cursors = page.CreateRelativeBackwardCursors(2); + + // assert + Assert.Collection( + cursors, + cursor => Assert.Equal(new PageCursor("1:-1:3:10", 1), cursor), + cursor => Assert.Equal(new PageCursor("1:0:3:10", 2), cursor)); + } + + [Fact] + public void CreateRelativeForwardCursors_UsesLastPageIndex() + { + // arrange + var page = Page.Create( + items: ["duplicate", "duplicate"], + elements: ImmutableArray.Create(1, 2), + hasNextPage: true, + hasPreviousPage: true, + createCursor: static entry => $"{entry.Node}:{entry.Offset}:{entry.PageIndex}:{entry.TotalCount}", + index: 1, + requestedPageSize: 2, + totalCount: 10); + + // act + var cursors = page.CreateRelativeForwardCursors(2); + + // assert + Assert.Collection( + cursors, + cursor => Assert.Equal(new PageCursor("2:0:1:10", 2), cursor), + cursor => Assert.Equal(new PageCursor("2:1:1:10", 3), cursor)); + } +} diff --git a/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageConnection.cs b/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageConnection.cs index d1dd52f0826..17b074eb0d2 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageConnection.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageConnection.cs @@ -45,12 +45,12 @@ public override IReadOnlyList>? Edges { if (_edges is null) { - var items = _page.Items; - var edges = new PageEdge[items.Length]; + var entries = _page.Entries; + var edges = new PageEdge[entries.Length]; - for (var i = 0; i < items.Length; i++) + for (var i = 0; i < entries.Length; i++) { - edges[i] = new PageEdge(_page, items[i]); + edges[i] = new PageEdge(_page, entries[i]); } _edges = edges; @@ -64,7 +64,7 @@ public override IReadOnlyList>? Edges /// A flattened list of the nodes. /// [GraphQLDescription("A flattened list of the nodes")] - public virtual IReadOnlyList? Nodes => _page.Items; + public virtual IReadOnlyList? Nodes => _page; /// /// Information to aid in pagination. diff --git a/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageEdge.cs b/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageEdge.cs index e8eca765fd5..ccb06335acf 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageEdge.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageEdge.cs @@ -8,26 +8,26 @@ namespace HotChocolate.Types.Pagination; /// /// The page that contains the node. /// -/// -/// The node that is part of the edge. +/// +/// The entry within the page. /// /// /// The type of the node. /// [GraphQLName("{0}Edge")] -public class PageEdge(Page page, TNode node) : IEdge +public class PageEdge(Page page, PageEntry entry) : IEdge { /// /// The item at the end of the edge. /// [GraphQLDescription("The item at the end of the edge.")] - public TNode Node => node; + public TNode Node => entry.Item; /// /// A cursor for use in pagination. /// [GraphQLDescription("A cursor for use in pagination.")] - public string Cursor => page.CreateCursor(node); + public string Cursor => page.CreateCursor(entry); object? IEdge.Node => Node; } diff --git a/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageInfo.cs b/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageInfo.cs index de43c761787..c37be0a3b04 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageInfo.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination.Extensions/PageInfo.cs @@ -80,16 +80,10 @@ public class PageInfo(Page page, int maxRelativeCursorCount = 5) : public override bool HasPreviousPage => page.HasPreviousPage; /// - public override string? StartCursor - => page.First is not null - ? page.CreateCursor(page.First) - : null; + public override string? StartCursor => page.CreateStartCursor(); /// - public override string? EndCursor - => page.Last is not null - ? page.CreateCursor(page.Last) - : null; + public override string? EndCursor => page.CreateEndCursor(); /// public override IReadOnlyList ForwardCursors diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/Edge.cs b/src/HotChocolate/Core/src/Types.CursorPagination/Edge.cs index ae475112a50..7287f6ada57 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/Edge.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination/Edge.cs @@ -10,6 +10,7 @@ namespace HotChocolate.Types.Pagination; public class Edge : IEdge { private readonly Func, string>? _resolveCursor; + private readonly int _index; private string? _cursor; /// @@ -52,6 +53,30 @@ public Edge(T node, Func resolveCursor) _resolveCursor = edge => resolveCursor(edge.Node); } + /// + /// Initializes a new instance of . + /// + /// + /// The node that the edge will wrap. + /// + /// + /// The zero-based index of the in the current page. + /// + /// + /// A delegate that resolves the cursor for the specified . + /// + /// + /// is . + /// + public Edge(T node, int index, Func resolveCursor) + { + ArgumentNullException.ThrowIfNull(resolveCursor); + + Node = node; + _index = index; + _resolveCursor = _ => resolveCursor(_index); + } + /// /// Initializes a new instance of . /// diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/PagingTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/PagingTests.cs index 027efd11c6a..679cdb3a83e 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Tests/PagingTests.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/PagingTests.cs @@ -626,7 +626,9 @@ public class AuthorConnection : ConnectionBase 0; } - public class AuthorEdge(GreenDonut.Data.Page page, Author author) : PageEdge(page, author) + public class AuthorEdge( + GreenDonut.Data.Page page, + GreenDonut.Data.PageEntry entry) : PageEdge(page, entry) { public Author Author => Node; } @@ -682,7 +684,9 @@ public class AuthorConnection : ConnectionBase 0; } - public class AuthorEdge(GreenDonut.Data.Page page, Author author) : PageEdge(page, author) + public class AuthorEdge( + GreenDonut.Data.Page page, + GreenDonut.Data.PageEntry entry) : PageEdge(page, entry) { public Author Author => Node; } diff --git a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/EdgeTests.cs b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/EdgeTests.cs index 717eb0e2799..dc73e6d8a5c 100644 --- a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/EdgeTests.cs +++ b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/EdgeTests.cs @@ -43,6 +43,17 @@ public void CreateEdge_CursorIsNull_ArgumentNullException_2() Assert.Throws(Action); } + [Fact] + public void CreateEdge_ResolveCursorByIndex_ArgumentNullException() + { + // arrange + // act + void Action() => new Edge("abc", 1, default!); + + // assert + Assert.Throws(Action); + } + [Fact] public void CreateEdge_CursorIsEmpty_ArgumentException() { @@ -54,6 +65,18 @@ public void CreateEdge_CursorIsEmpty_ArgumentException() Assert.Throws(Action); } + [Fact] + public void CreateEdge_ResolveCursorByIndex_UsesIndex() + { + // arrange + // act + var edge = new Edge("abc", 2, index => $"cursor:{index}"); + + // assert + Assert.Equal("abc", edge.Node); + Assert.Equal("cursor:2", edge.Cursor); + } + [Fact] public async Task Extend_Edge_Type_And_Inject_Edge_Value_Schema() { diff --git a/src/HotChocolate/Data/src/Data/Extensions/HotChocolatePaginationResultExtensions.cs b/src/HotChocolate/Data/src/Data/Extensions/HotChocolatePaginationResultExtensions.cs index 7541983f27e..0ab1adffdc8 100644 --- a/src/HotChocolate/Data/src/Data/Extensions/HotChocolatePaginationResultExtensions.cs +++ b/src/HotChocolate/Data/src/Data/Extensions/HotChocolatePaginationResultExtensions.cs @@ -43,42 +43,14 @@ public static async Task> ToConnectionAsync( /// The page result. /// /// - /// A factory to create an edge from a source entity. + /// A factory that receives the source item and its cursor, and returns the edge. /// /// /// Returns a relay connection. /// public static async Task> ToConnectionAsync( this Task> resultPromise, - Func> createEdge) - where TTarget : class - where TSource : class - { - var result = await resultPromise; - return CreateConnection(result, createEdge); - } - - /// - /// Converts a to a . - /// - /// - /// The source entity type. - /// - /// - /// The target entity type. - /// - /// - /// The page result. - /// - /// - /// A factory to create an edge from a source entity. - /// - /// - /// Returns a relay connection. - /// - public static async Task> ToConnectionAsync( - this Task> resultPromise, - Func, Edge> createEdge) + Func, PageEntry, IEdge> createEdge) where TTarget : class where TSource : class { @@ -119,42 +91,14 @@ public static async ValueTask> ToConnectionAsync( /// The page result. /// /// - /// A factory to create an edge from a source entity. - /// - /// - /// Returns a relay connection. - /// - public static async ValueTask> ToConnectionAsync( - this ValueTask> resultPromise, - Func> createEdge) - where TTarget : class - where TSource : class - { - var result = await resultPromise; - return CreateConnection(result, createEdge); - } - - /// - /// Converts a to a . - /// - /// - /// The source entity type. - /// - /// - /// The target entity type. - /// - /// - /// The page result. - /// - /// - /// A factory to create an edge from a source entity. + /// A factory that receives the source item and its cursor, and returns the edge. /// /// /// Returns a relay connection. /// public static async ValueTask> ToConnectionAsync( this ValueTask> resultPromise, - Func, Edge> createEdge) + Func, PageEntry, IEdge> createEdge) where TTarget : class where TSource : class { @@ -183,53 +127,48 @@ public static Connection ToConnection( private static Connection CreateConnection(Page? page) where T : class { page ??= Page.Empty; + var entries = page.Entries; + var edges = new PageEdge[entries.Length]; + + for (var i = 0; i < entries.Length; i++) + { + edges[i] = new PageEdge(page, entries[i]); + } return new Connection( - page.Items.Select(t => new Edge(t, page.CreateCursor)).ToArray(), + edges, new ConnectionPageInfo( page.HasNextPage, page.HasPreviousPage, - CreateCursor(page.First, page.CreateCursor), - CreateCursor(page.Last, page.CreateCursor)), + page.CreateStartCursor(), + page.CreateEndCursor()), page.TotalCount ?? 0); } private static Connection CreateConnection( Page? page, - Func> createEdge) + Func, PageEntry, IEdge> createEdge) where TTarget : class - where TSource : class { page ??= Page.Empty; + var entries = page.Entries; + var edges = entries.IsEmpty ? [] : new IEdge[entries.Length]; - return new Connection( - page.Items.Select(t => createEdge(t, page.CreateCursor(t))).ToArray(), - new ConnectionPageInfo( - page.HasNextPage, - page.HasPreviousPage, - CreateCursor(page.First, page.CreateCursor), - CreateCursor(page.Last, page.CreateCursor)), - page.TotalCount ?? 0); - } - - private static Connection CreateConnection( - Page? page, - Func, Edge> createEdge) - where TTarget : class - where TSource : class - { - page ??= Page.Empty; + if (!entries.IsEmpty) + { + for (var i = 0; i < entries.Length; i++) + { + edges[i] = createEdge(page, entries[i]); + } + } return new Connection( - page.Items.Select(t => createEdge(t, page)).ToArray(), + edges, new ConnectionPageInfo( page.HasNextPage, page.HasPreviousPage, - CreateCursor(page.First, page.CreateCursor), - CreateCursor(page.Last, page.CreateCursor)), + page.CreateStartCursor(), + page.CreateEndCursor()), page.TotalCount ?? 0); } - - private static string? CreateCursor(T? item, Func createCursor) where T : class - => item is null ? null : createCursor(item); } diff --git a/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj b/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj index a8f0d569b50..ccc16908a61 100644 --- a/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj +++ b/src/HotChocolate/Data/src/Data/HotChocolate.Data.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/DataLoaderTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/DataLoaderTests.cs index 544390af6aa..310e3a6cf75 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/DataLoaderTests.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/DataLoaderTests.cs @@ -88,7 +88,7 @@ public async Task Include_On_Page_Results() .LoadRequiredAsync(1, cts.Token); // assert - Assert.Equal(5, products.Items.Length); + Assert.Equal(5, products.Count); interceptor.MatchSnapshot(); } diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogConnection.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogConnection.cs index 4e2753b0b04..ff0c015c1aa 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogConnection.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogConnection.cs @@ -27,12 +27,12 @@ public override IReadOnlyList> Edges { if (_edges is null) { - var items = _page.Items; - var edges = new CatalogEdge[items.Length]; + var entries = _page.Entries; + var edges = new CatalogEdge[entries.Length]; - for (var i = 0; i < items.Length; i++) + for (var i = 0; i < entries.Length; i++) { - edges[i] = new CatalogEdge(_page, items[i]); + edges[i] = new CatalogEdge(_page, entries[i]); } _edges = edges; @@ -45,7 +45,7 @@ public override IReadOnlyList> Edges /// /// A flattened list of the nodes. /// - public IReadOnlyList Nodes => _page.Items; + public IReadOnlyList Nodes => _page; /// /// Information to aid in pagination. @@ -56,18 +56,8 @@ public override ConnectionPageInfo PageInfo { if (_pageInfo is null) { - string? startCursor = null; - string? endCursor = null; - - if (_page.First is not null) - { - startCursor = _page.CreateCursor(_page.First); - } - - if (_page.Last is not null) - { - endCursor = _page.CreateCursor(_page.Last); - } + var startCursor = _page.CreateStartCursor(); + var endCursor = _page.CreateEndCursor(); _pageInfo = new ConnectionPageInfo(_page.HasNextPage, _page.HasPreviousPage, startCursor, endCursor); } diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogEdge.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogEdge.cs index 8effeb11f4c..f87f06f69ad 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogEdge.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/CatalogEdge.cs @@ -7,17 +7,17 @@ namespace HotChocolate.Data.Types.Brands; /// An edge in a connection. /// [GraphQLName("{0}Edge")] -public class CatalogEdge(Page page, TEntity node) : IEdge +public class CatalogEdge(Page page, PageEntry entry) : IEdge { /// /// The item at the end of the edge. /// - public TEntity Node { get; } = node; + public TEntity Node => entry.Item; object? IEdge.Node => Node; /// /// A cursor for use in pagination. /// - public string Cursor => page.CreateCursor(Node); + public string Cursor => page.CreateCursor(entry); } diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductConnection.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductConnection.cs index b6686824806..ce42d52d4e2 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductConnection.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductConnection.cs @@ -28,12 +28,12 @@ public override IReadOnlyList? Edges { if (_edges is null) { - var items = _page.Items; - var edges = new ProductsEdge[items.Length]; + var entries = _page.Entries; + var edges = new ProductsEdge[entries.Length]; - for (var i = 0; i < items.Length; i++) + for (var i = 0; i < entries.Length; i++) { - edges[i] = new ProductsEdge(_page, items[i]); + edges[i] = new ProductsEdge(_page, entries[i]); } _edges = edges; @@ -46,7 +46,7 @@ public override IReadOnlyList? Edges /// /// A flattened list of the nodes. /// - public IReadOnlyList? Nodes => _page.Items; + public IReadOnlyList? Nodes => _page; /// /// Information to aid in pagination. @@ -57,18 +57,8 @@ public override ConnectionPageInfo PageInfo { if (_pageInfo is null) { - string? startCursor = null; - string? endCursor = null; - - if (_page.First is not null) - { - startCursor = _page.CreateCursor(_page.First); - } - - if (_page.Last is not null) - { - endCursor = _page.CreateCursor(_page.Last); - } + var startCursor = _page.CreateStartCursor(); + var endCursor = _page.CreateEndCursor(); _pageInfo = new ConnectionPageInfo(_page.HasNextPage, _page.HasPreviousPage, startCursor, endCursor); } @@ -85,14 +75,16 @@ public override ConnectionPageInfo PageInfo [GraphQLType>>>] public IEnumerable GetEndCursors(int count) { - if (_page.Last is null) + if (_page.Count == 0) { yield break; } + var lastEntry = _page.Entries[^1]; + for (var i = 0; i < count; i++) { - yield return _page.CreateCursor(_page.Last, i); + yield return _page.CreateCursor(lastEntry, i); } } } diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductsEdge.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductsEdge.cs index 0adaab8c556..4db645637fa 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductsEdge.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Products/ProductsEdge.cs @@ -7,17 +7,17 @@ namespace HotChocolate.Data.Types.Products; /// /// An edge in a connection. /// -public class ProductsEdge(Page page, Product node) : IEdge +public class ProductsEdge(Page page, PageEntry entry) : IEdge { /// /// The item at the end of the edge. /// - public Product Node { get; } = node; + public Product Node => entry.Item; object? IEdge.Node => Node; /// /// A cursor for use in pagination. /// - public string Cursor => page.CreateCursor(Node); + public string Cursor => page.CreateCursor(entry); } diff --git a/src/HotChocolate/Data/test/Data.Tests/PagingHelperIntegrationTests.cs b/src/HotChocolate/Data/test/Data.Tests/PagingHelperIntegrationTests.cs index 1bc5ad329e7..c5d9b01f0ee 100644 --- a/src/HotChocolate/Data/test/Data.Tests/PagingHelperIntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/PagingHelperIntegrationTests.cs @@ -33,7 +33,7 @@ public async Task GetDefaultPage() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -80,7 +80,7 @@ public async Task GetDefaultPage2() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -127,7 +127,7 @@ public async Task GetSecondPage_With_2_Items() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -174,7 +174,7 @@ public async Task GetDefaultPage_With_Nullable() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -230,7 +230,7 @@ public async Task GetDefaultPage_With_Nullable_SecondPage() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ModifyPagingOptions(o => o.NullOrdering = NullOrdering.NativeNullsLast) @@ -287,7 +287,7 @@ public async Task GetDefaultPage_With_Nullable_Fallback() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -343,7 +343,7 @@ public async Task GetDefaultPage_With_Nullable_Fallback_SecondPage() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ModifyPagingOptions(o => o.NullOrdering = NullOrdering.NativeNullsLast) @@ -400,7 +400,7 @@ public async Task GetDefaultPage_With_Deep() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -456,7 +456,7 @@ public async Task GetDefaultPage_With_Deep_SecondPage() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ExecuteRequestAsync( @@ -512,7 +512,7 @@ public async Task Nested_Paging_First_2() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddTypeExtension(typeof(BrandExtensions)) .AddPagingArguments() @@ -568,7 +568,7 @@ public async Task Nested_Paging_First_2_With_Projections() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddTypeExtension(typeof(BrandExtensionsWithSelect)) .AddPagingArguments() @@ -637,12 +637,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -673,12 +673,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -713,12 +713,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -749,12 +749,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -789,12 +789,12 @@ await Snapshot { 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 + First = result.First?.Item.Id, + FirstCursor = result.CreateStartCursor(), + Last = result.Last?.Item.Id, + LastCursor = result.CreateEndCursor() }) - .Add(result.Items) + .Add(result.ToArray()) .MatchMarkdownAsync(); } @@ -830,9 +830,9 @@ public async Task BatchPaging_First_5() snapshot.Add( new { - First = page.Value.CreateCursor(page.Value.First!), - Last = page.Value.CreateCursor(page.Value.Last!), - page.Value.Items + First = page.Value.CreateStartCursor(), + Last = page.Value.CreateEndCursor(), + Items = page.Value.ToArray() }, name: page.Key.ToString()); } @@ -872,9 +872,9 @@ public async Task BatchPaging_Last_5() snapshot.Add( new { - First = page.Value.CreateCursor(page.Value.First!), - Last = page.Value.CreateCursor(page.Value.Last!), - page.Value.Items + First = page.Value.CreateStartCursor(), + Last = page.Value.CreateEndCursor(), + Items = page.Value.ToArray() }, name: page.Key.ToString()); } @@ -883,6 +883,70 @@ public async Task BatchPaging_Last_5() snapshot.MatchMarkdownSnapshot(); } + [Fact] + public async Task BatchPaging_With_ValueSelector_ToConnectionAsync() + { + // Arrange + var connectionString = CreateConnectionString(); + var brandId = await SeedMinimalAsync(connectionString); + + // Act + await using var context = new CatalogContext(connectionString); + + var pagingArgs = new PagingArguments { First = 2 }; + + var results = await context.Products + .Where(t => t.BrandId == brandId) + .Select(t => new { t.BrandId, Product = t }) + .OrderBy(t => t.Product.Name) + .ThenBy(t => t.Product.Id) + .ToBatchPageAsync( + keySelector: t => t.BrandId, + valueSelector: t => t.Product, + pagingArgs); + + // Assert + Assert.True(results.TryGetValue(brandId, out var page)); + + var connection = await new ValueTask>(page!).ToConnectionAsync(); + Assert.Equal(2, connection.Edges.Count); + Assert.All(connection.Edges, edge => Assert.False(string.IsNullOrEmpty(edge.Cursor))); + } + + [Fact] + public async Task BatchPaging_Backward_With_ValueSelector_ToConnectionAsync() + { + // Arrange + var connectionString = CreateConnectionString(); + var brandId = await SeedMinimalAsync(connectionString); + + // Act + await using var context = new CatalogContext(connectionString); + + var pagingArgs = new PagingArguments { Last = 2 }; + + var results = await context.Products + .Where(t => t.BrandId == brandId) + .Select(t => new { t.BrandId, Product = t }) + .OrderBy(t => t.Product.Name) + .ThenBy(t => t.Product.Id) + .ToBatchPageAsync( + keySelector: t => t.BrandId, + valueSelector: t => t.Product, + pagingArgs); + + // Assert + Assert.True(results.TryGetValue(brandId, out var page)); + + var connection = await new ValueTask>(page!).ToConnectionAsync(); + Assert.Equal(2, connection.Edges.Count); + Assert.All(connection.Edges, edge => Assert.False(string.IsNullOrEmpty(edge.Cursor))); + Assert.True(page!.HasPreviousPage); + Assert.False(page.HasNextPage); + Assert.Equal("Product 0-2", connection.Edges[0].Node.Name); + Assert.Equal("Product 0-3", connection.Edges[1].Node.Name); + } + [Fact] public async Task Map_Page_To_Connection_With_Dto() { @@ -895,7 +959,7 @@ public async Task Map_Page_To_Connection_With_Dto() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddTypeExtension(typeof(BrandConnectionEdgeExtensions)) .AddPagingArguments() @@ -944,7 +1008,7 @@ public async Task Map_Page_To_Connection_With_Dto_2() // Act var result = await new ServiceCollection() .AddScoped(_ => new CatalogContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddTypeExtension(typeof(BrandConnectionEdgeExtensions2)) .AddPagingArguments() @@ -993,7 +1057,7 @@ public async Task Ensure_Nullable_Connections_Dont_Throw() // Act var result = await new ServiceCollection() .AddScoped(_ => new FooBarContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) @@ -1047,7 +1111,7 @@ public async Task Ensure_Nullable_Connections_Dont_Throw_2() // Act var result = await new ServiceCollection() .AddScoped(_ => new FooBarContext(connectionString)) - .AddGraphQL() + .AddGraphQLServer() .AddQueryType() .AddPagingArguments() .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) @@ -1127,6 +1191,30 @@ private static async Task SeedAsync(string connectionString) await context.SaveChangesAsync(); } + private static async Task SeedMinimalAsync(string connectionString) + { + await using var context = new CatalogContext(connectionString); + await context.Database.EnsureCreatedAsync(); + + var type = new ProductType { Name = "T-Shirt" }; + var brand = new Brand + { + Name = "Brand:0", + BrandDetails = new() { Country = new() { Name = "Country0" } } + }; + + context.ProductTypes.Add(type); + context.Brands.Add(brand); + context.Products.AddRange( + new Product { Name = "Product 0-0", Type = type, Brand = brand }, + new Product { Name = "Product 0-1", Type = type, Brand = brand }, + new Product { Name = "Product 0-2", Type = type, Brand = brand }, + new Product { Name = "Product 0-3", Type = type, Brand = brand }); + + await context.SaveChangesAsync(); + return brand.Id; + } + private static async Task SeedFooAsync(string connectionString) { await using var context = new FooBarContext(connectionString); @@ -1251,7 +1339,7 @@ public async Task> GetBrandsAsync( .OrderBy(t => t.Name) .ThenBy(t => t.Id) .ToPageAsync(arguments, cancellationToken: ct) - .ToConnectionAsync((brand, page) => new BrandEdge(brand, edge => page.CreateCursor(edge.Brand))); + .ToConnectionAsync((page, entry) => new BrandEdge(page, entry)); } } @@ -1267,7 +1355,7 @@ public async Task> GetBrandsAsync( .OrderBy(t => t.Name) .ThenBy(t => t.Id) .ToPageAsync(arguments, cancellationToken: ct) - .ToConnectionAsync((brand, cursor) => new BrandEdge2(brand, cursor)); + .ToConnectionAsync((page, entry) => new BrandEdge2(page, entry)); } } @@ -1306,10 +1394,10 @@ public class BrandConnectionEdgeExtensions2 public class BrandEdge : Edge { - public BrandEdge(Brand brand, Func cursor) - : base(new BrandDto(brand.Id, brand.Name), edge => cursor((BrandEdge)edge)) + public BrandEdge(Page page, PageEntry entry) + : base(new BrandDto(entry.Item.Id, entry.Item.Name), page.CreateCursor(entry)) { - Brand = brand; + Brand = entry.Item; } public Brand Brand { get; } @@ -1317,10 +1405,10 @@ public BrandEdge(Brand brand, Func cursor) public class BrandEdge2 : Edge { - public BrandEdge2(Brand brand, string cursor) - : base(new BrandDto(brand.Id, brand.Name), cursor) + public BrandEdge2(Page page, PageEntry entry) + : base(new BrandDto(entry.Item.Id, entry.Item.Name), page.CreateCursor(entry)) { - Brand = brand; + Brand = entry.Item; } public Brand Brand { get; } diff --git a/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs b/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs index f25c94d9704..13b56d1532b 100644 --- a/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs @@ -267,7 +267,7 @@ protected override Task>> Lo var pageItems = allItems.Take(take).ToImmutableArray(); var hasNext = allItems.Length > take; - map[key] = new Page( + map[key] = Page.Create( pageItems, hasNextPage: hasNext, hasPreviousPage: false, diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index cb87d271264..ce7ca6137ca 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -251,6 +251,76 @@ Most of the properties you'd want to modify are now immutable data structures th `OperationResultBuilder.CreateError(error)` can be simply replaced with `new OperationResult([error])`. +## Page and cursor API changes + +### `Page` is now abstract + +`Page` can no longer be instantiated directly. Use the static factory methods instead: + +- Use `Page.Empty` when you just need to return an empty page. +- Use `Page.Create(...)` when you need to construct a page yourself. + +```diff +-return new Page( +- items, +- hasNextPage: hasNext, +- hasPreviousPage: false, +- createCursor: product => CreateCursor(product), +- totalCount: totalCount); ++return Page.Create( ++ items, ++ hasNextPage: hasNext, ++ hasPreviousPage: false, ++ createCursor: product => CreateCursor(product), ++ totalCount: totalCount); +``` + +### `CreateCursor` now takes an index instead of an item + +`Page.CreateCursor` previously accepted a `T` item. It now accepts a zero-based `int` index into the page's `Items` array. This enables cursor generation from the underlying source element when a `valueSelector` projection is used. + +```diff +-string cursor = page.CreateCursor(page.First); ++string cursor = page.CreateCursor(page.FirstIndex!.Value); +``` + +Use the new convenience extension methods `CreateStartCursor()` and `CreateEndCursor()` when you only need boundary cursors: + +```diff +-var startCursor = page.First is not null ? page.CreateCursor(page.First) : null; +-var endCursor = page.Last is not null ? page.CreateCursor(page.Last) : null; ++var startCursor = page.CreateStartCursor(); ++var endCursor = page.CreateEndCursor(); +``` + +Two new properties, `FirstIndex` and `LastIndex`, return the zero-based indices of the first and last items (or `null` for an empty page). + +### `Edge` constructor changes + +A new constructor overload accepts the item, its zero-based index, and a `Func` cursor resolver: + +```diff +-new Edge(item, cursor: page.CreateCursor) ++new Edge(item, index, cursor: page.CreateCursor) +``` + +The existing `Edge(T node, Func resolveCursor)` constructor is still available for cases where the cursor is resolved from the item itself. + +### `ToConnectionAsync` with custom edge factory + +The `ToConnectionAsync` overloads that accept a custom edge factory now pass the zero-based item index instead of the item's cursor: + +```diff +-.ToConnectionAsync((source, page) => +- new MyEdge(source, edge => page.CreateCursor(edge.Node))); ++.ToConnectionAsync((source, page, index) => ++ new MyEdge(source, page.CreateCursor(index))); +``` + +### `ToBatchPageAsync` with `valueSelector` + +`ToBatchPageAsync` now supports a `valueSelector` parameter (`Func`) that projects each source element before the page is returned, while preserving the original elements for correct cursor generation. Passing `null` for `valueSelector` is only valid when `TElement` and `TValue` are the same type; a mismatched combination throws `ArgumentNullException` at runtime. + ## OperationResult changes We've removed the `IOperationResult` abstraction. If you've previously pattern-matched on this, you can simply replace it with `OperationResult`. To assert that an `IExecutionResult` is an `OperationResult` in tests, use `result.ExpectOperationResult();`.