< Summary

Information
Class: ArturRios.Output.PaginatedOutputExtensions
Assembly: ArturRios.Output
File(s): /home/runner/work/dotnet-output/dotnet-output/src/PaginatedOutputExtensions.cs
Line coverage
96%
Covered lines: 177
Uncovered lines: 7
Coverable lines: 184
Total lines: 628
Line coverage: 96.1%
Branch coverage
90%
Covered branches: 81
Total branches: 90
Branch coverage: 90%
Method coverage
100%
Covered methods: 28
Fully covered methods: 26
Total methods: 28
Method coverage: 100%
Full method coverage: 92.8%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
PaginateAsync()100%1414100%
Paginate(...)100%1010100%
PaginateByToken(...)100%22100%
PaginateByToken(...)100%22100%
PaginateByTokenAsync()100%22100%
PaginateByTokenAsync()50%22100%
.ctor(...)100%11100%
get_CountQuery()100%11100%
get_PageQuery()100%11100%
get_PageNumber()100%11100%
get_PageSize()100%11100%
get_IssueNextToken()100%11100%
TryPlanKeysetPage(...)88.88%181890%
IssueNextToken()83.33%66100%
ReadKeysetPage(...)100%22100%
ReadKeysetPageAsync()100%66100%
CompleteKeysetPage(...)100%22100%
TryReadValue(...)50%2255.55%
After(...)100%22100%
Beyond(...)83.33%66100%
HasComparisonOperator(...)100%11100%
.cctor()100%11100%
Captured(...)50%22100%
.ctor(...)100%11100%
.ctor(...)100%11100%
VisitParameter(...)50%22100%
TryGetOffset(...)100%22100%
OrderByExpression(...)87.5%88100%

File(s)

/home/runner/work/dotnet-output/dotnet-output/src/PaginatedOutputExtensions.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics.CodeAnalysis;
 3using System.Linq.Expressions;
 4using System.Reflection;
 5using System.Text.Json;
 6using Microsoft.EntityFrameworkCore;
 7using Microsoft.EntityFrameworkCore.Query;
 8
 9namespace ArturRios.Output;
 10
 11/// <summary>
 12/// Provides helpers to paginate an <see cref="IQueryable{T}"/> either
 13/// synchronously or asynchronously. These extensions return a <see cref="PaginatedOutput{T}"/>.
 14/// </summary>
 15public static class PaginatedOutputExtensions
 16{
 17    /// <summary>
 18    /// Asynchronously paginates the query and returns a <see cref="PaginatedOutput{T}"/>.
 19    /// </summary>
 20    /// <typeparam name="T">Element type of the queryable.</typeparam>
 21    /// <param name="query">The source query.</param>
 22    /// <param name="pageNumber">1-based page number. Values below <c>1</c> are clamped to <c>1</c>.</param>
 23    /// <param name="pageSize">Number of items per page. Values below <c>1</c> are clamped to <c>1</c>.</param>
 24    /// <param name="orderBy">Optional ordering expression.</param>
 25    /// <param name="totalCount">
 26    /// Optional total count of items in the query. When supplied the count query is skipped and this value is
 27    /// reported as <see cref="PaginatedOutput{T}.TotalItems"/>. Negative values are clamped to <c>0</c>.
 28    /// </param>
 29    /// <param name="cancellationToken">Cancellation token.</param>
 30    /// <returns>A task that resolves to a populated <see cref="PaginatedOutput{T}"/>.</returns>
 31    /// <exception cref="ArgumentNullException"><paramref name="query"/> is <c>null</c>.</exception>
 32    public static async Task<PaginatedOutput<T>> PaginateAsync<T>(
 33        this IQueryable<T> query,
 34        int pageNumber,
 35        int pageSize,
 36        Expression<Func<T, object?>>? orderBy = null,
 37        int? totalCount = null,
 38        CancellationToken cancellationToken = default)
 39    {
 2240        ArgumentNullException.ThrowIfNull(query);
 41
 2142        pageNumber = Math.Max(1, pageNumber);
 2143        pageSize = Math.Max(1, pageSize);
 44
 2145        if (orderBy is not null)
 46        {
 1247            query = OrderByExpression(query, orderBy);
 48        }
 49
 2150        var countWasSupplied = totalCount.HasValue;
 51
 2152        totalCount = countWasSupplied
 2153            ? Math.Max(0, totalCount!.Value)
 2154            : query.Provider is IAsyncQueryProvider
 2155                ? await query.CountAsync(cancellationToken).ConfigureAwait(false)
 2156                : query.Count();
 57
 58        List<T> items;
 59
 2060        if ((!countWasSupplied && totalCount.Value == 0) || !TryGetOffset(pageNumber, pageSize, out var offset))
 61        {
 562            items = [];
 63        }
 64        else
 65        {
 1566            var pageQuery = query.Skip(offset).Take(pageSize);
 67
 1568            items = pageQuery.Provider is IAsyncQueryProvider
 1569                ? await pageQuery.ToListAsync(cancellationToken).ConfigureAwait(false)
 1570                : pageQuery.ToList();
 71        }
 72
 2073        return PaginatedOutput<T>.New
 2074            .WithData(items)
 2075            .WithPagination(pageNumber, pageSize, totalCount.Value);
 2076    }
 77
 78    /// <summary>
 79    /// Synchronously paginates the query and returns a <see cref="PaginatedOutput{T}"/>.
 80    /// </summary>
 81    /// <typeparam name="T">Element type of the queryable.</typeparam>
 82    /// <param name="query">The source query.</param>
 83    /// <param name="pageNumber">1-based page number. Values below <c>1</c> are clamped to <c>1</c>.</param>
 84    /// <param name="pageSize">Number of items per page. Values below <c>1</c> are clamped to <c>1</c>.</param>
 85    /// <param name="orderBy">Optional ordering expression.</param>
 86    /// <param name="totalCount">
 87    /// Optional total count of items in the query. When supplied the count query is skipped and this value is
 88    /// reported as <see cref="PaginatedOutput{T}.TotalItems"/>. Negative values are clamped to <c>0</c>.
 89    /// </param>
 90    /// <returns>A populated <see cref="PaginatedOutput{T}"/>.</returns>
 91    /// <exception cref="ArgumentNullException"><paramref name="query"/> is <c>null</c>.</exception>
 92    public static PaginatedOutput<T> Paginate<T>(
 93        this IQueryable<T> query,
 94        int pageNumber,
 95        int pageSize,
 96        Expression<Func<T, object?>>? orderBy = null,
 97        int? totalCount = null)
 98    {
 1999        ArgumentNullException.ThrowIfNull(query);
 100
 18101        pageNumber = Math.Max(1, pageNumber);
 18102        pageSize = Math.Max(1, pageSize);
 103
 18104        if (orderBy is not null)
 105        {
 7106            query = OrderByExpression(query, orderBy);
 107        }
 108
 18109        var countWasSupplied = totalCount.HasValue;
 110
 18111        totalCount = countWasSupplied ? Math.Max(0, totalCount!.Value) : query.Count();
 112
 18113        var items = (!countWasSupplied && totalCount.Value == 0) || !TryGetOffset(pageNumber, pageSize, out var offset)
 18114            ? []
 18115            : query
 18116                .Skip(offset)
 18117                .Take(pageSize)
 18118                .ToList();
 119
 18120        return PaginatedOutput<T>.New
 18121            .WithData(items)
 18122            .WithPagination(pageNumber, pageSize, totalCount.Value);
 123    }
 124
 125    /// <summary>
 126    /// Returns the page after the one that issued <paramref name="pageToken"/>, or the first page when no token is
 127    /// given, walking the query in <paramref name="keySelector"/> order with a keyset filter instead of an offset.
 128    /// </summary>
 129    /// <remarks>
 130    /// The key must be unique and non-null; for a key that repeats (a timestamp, a name) use the overload that takes
 131    /// a tiebreaker. Any ordering already on <paramref name="query"/> is replaced. Tokens are signed, so a caller can
 132    /// only reach a page with the token issued by the page before it, but not encrypted: the key values in a token
 133    /// are readable.
 134    /// </remarks>
 135    /// <typeparam name="T">Element type of the queryable.</typeparam>
 136    /// <typeparam name="TKey">Type of the key the listing is ordered and resumed by.</typeparam>
 137    /// <param name="query">The source query.</param>
 138    /// <param name="pageToken">
 139    /// <see cref="PaginatedOutput{T}.NextPageToken"/> from the previous page, or <c>null</c> (or blank) for the first
 140    /// page.
 141    /// </param>
 142    /// <param name="pageSize">Number of items per page. Values below <c>1</c> are clamped to <c>1</c>.</param>
 143    /// <param name="keySelector">The unique, non-null key the listing is ordered and resumed by.</param>
 144    /// <param name="options">Signing key, lifetime and scope of the tokens.</param>
 145    /// <param name="direction">Whether the listing walks the key ascending or descending.</param>
 146    /// <param name="includeTotalCount">
 147    /// When <c>true</c>, the whole query is counted and reported as <see cref="PaginatedOutput{T}.TotalItems"/>;
 148    /// otherwise the count query is skipped and <c>TotalItems</c> is <c>0</c>.
 149    /// </param>
 150    /// <returns>
 151    /// The page with its <see cref="PaginatedOutput{T}.NextPageToken"/>, or — when the token is invalid, expired or
 152    /// issued for another listing — an empty output carrying one of the <see cref="PageTokenErrors"/>.
 153    /// </returns>
 154    /// <exception cref="ArgumentNullException">
 155    /// <paramref name="query"/>, <paramref name="keySelector"/> or <paramref name="options"/> is <c>null</c>.
 156    /// </exception>
 157    /// <exception cref="ArgumentException"><see cref="PageTokenOptions.SigningKey"/> is too short.</exception>
 158    /// <exception cref="InvalidOperationException">The last item on a page that has a next page has a <c>null</c> key.<
 159    public static PaginatedOutput<T> PaginateByToken<T, TKey>(
 160        this IQueryable<T> query,
 161        string? pageToken,
 162        int pageSize,
 163        Expression<Func<T, TKey>> keySelector,
 164        PageTokenOptions options,
 165        SortDirection direction = SortDirection.Ascending,
 166        bool includeTotalCount = false) =>
 52167        TryPlanKeysetPage<T, TKey, object?>(query, pageToken, pageSize, keySelector, null, options, direction,
 52168            out var plan, out var rejection)
 52169            ? ReadKeysetPage(plan, includeTotalCount)
 52170            : rejection;
 171
 172    /// <summary>
 173    /// Returns the page after the one that issued <paramref name="pageToken"/>, or the first page when no token is
 174    /// given, walking the query in (<paramref name="keySelector"/>, <paramref name="tieBreakerSelector"/>) order with a
 175    /// keyset filter instead of an offset.
 176    /// </summary>
 177    /// <remarks>
 178    /// Use this overload when the key can repeat: the tiebreaker — typically the primary key — must make the pair
 179    /// unique, and neither may be <c>null</c>. Everything else is as in
 180    /// <see cref="PaginateByToken{T, TKey}(IQueryable{T}, string?, int, Expression{Func{T, TKey}}, PageTokenOptions, So
 181    /// </remarks>
 182    /// <typeparam name="T">Element type of the queryable.</typeparam>
 183    /// <typeparam name="TKey">Type of the key the listing is ordered and resumed by.</typeparam>
 184    /// <typeparam name="TTieBreaker">Type of the tiebreaker that orders items with equal keys.</typeparam>
 185    /// <param name="query">The source query.</param>
 186    /// <param name="pageToken">
 187    /// <see cref="PaginatedOutput{T}.NextPageToken"/> from the previous page, or <c>null</c> (or blank) for the first
 188    /// page.
 189    /// </param>
 190    /// <param name="pageSize">Number of items per page. Values below <c>1</c> are clamped to <c>1</c>.</param>
 191    /// <param name="keySelector">The non-null key the listing is ordered and resumed by.</param>
 192    /// <param name="tieBreakerSelector">The non-null value that orders items with equal keys.</param>
 193    /// <param name="options">Signing key, lifetime and scope of the tokens.</param>
 194    /// <param name="direction">Whether the listing walks the key and tiebreaker ascending or descending.</param>
 195    /// <param name="includeTotalCount">
 196    /// When <c>true</c>, the whole query is counted and reported as <see cref="PaginatedOutput{T}.TotalItems"/>;
 197    /// otherwise the count query is skipped and <c>TotalItems</c> is <c>0</c>.
 198    /// </param>
 199    /// <returns>
 200    /// The page with its <see cref="PaginatedOutput{T}.NextPageToken"/>, or — when the token is invalid, expired or
 201    /// issued for another listing — an empty output carrying one of the <see cref="PageTokenErrors"/>.
 202    /// </returns>
 203    /// <exception cref="ArgumentNullException">
 204    /// <paramref name="query"/>, <paramref name="keySelector"/>, <paramref name="tieBreakerSelector"/> or
 205    /// <paramref name="options"/> is <c>null</c>.
 206    /// </exception>
 207    /// <exception cref="ArgumentException"><see cref="PageTokenOptions.SigningKey"/> is too short.</exception>
 208    /// <exception cref="InvalidOperationException">
 209    /// The last item on a page that has a next page has a <c>null</c> key or tiebreaker.
 210    /// </exception>
 211    public static PaginatedOutput<T> PaginateByToken<T, TKey, TTieBreaker>(
 212        this IQueryable<T> query,
 213        string? pageToken,
 214        int pageSize,
 215        Expression<Func<T, TKey>> keySelector,
 216        Expression<Func<T, TTieBreaker>> tieBreakerSelector,
 217        PageTokenOptions options,
 218        SortDirection direction = SortDirection.Ascending,
 219        bool includeTotalCount = false)
 220    {
 20221        ArgumentNullException.ThrowIfNull(tieBreakerSelector);
 222
 19223        return TryPlanKeysetPage(query, pageToken, pageSize, keySelector, tieBreakerSelector, options, direction,
 19224            out var plan, out var rejection)
 19225            ? ReadKeysetPage(plan, includeTotalCount)
 19226            : rejection;
 227    }
 228
 229    /// <summary>
 230    /// Asynchronously returns the page after the one that issued <paramref name="pageToken"/>, or the first page when
 231    /// no token is given. See
 232    /// <see cref="PaginateByToken{T, TKey}(IQueryable{T}, string?, int, Expression{Func{T, TKey}}, PageTokenOptions, So
 233    /// </summary>
 234    /// <typeparam name="T">Element type of the queryable.</typeparam>
 235    /// <typeparam name="TKey">Type of the key the listing is ordered and resumed by.</typeparam>
 236    /// <param name="query">The source query.</param>
 237    /// <param name="pageToken">
 238    /// <see cref="PaginatedOutput{T}.NextPageToken"/> from the previous page, or <c>null</c> (or blank) for the first
 239    /// page.
 240    /// </param>
 241    /// <param name="pageSize">Number of items per page. Values below <c>1</c> are clamped to <c>1</c>.</param>
 242    /// <param name="keySelector">The unique, non-null key the listing is ordered and resumed by.</param>
 243    /// <param name="options">Signing key, lifetime and scope of the tokens.</param>
 244    /// <param name="direction">Whether the listing walks the key ascending or descending.</param>
 245    /// <param name="includeTotalCount">
 246    /// When <c>true</c>, the whole query is counted and reported as <see cref="PaginatedOutput{T}.TotalItems"/>.
 247    /// </param>
 248    /// <param name="cancellationToken">Cancellation token.</param>
 249    /// <returns>A task that resolves to the page, or to an empty output carrying one of the <see cref="PageTokenErrors"
 250    /// <exception cref="ArgumentNullException">
 251    /// <paramref name="query"/>, <paramref name="keySelector"/> or <paramref name="options"/> is <c>null</c>.
 252    /// </exception>
 253    /// <exception cref="ArgumentException"><see cref="PageTokenOptions.SigningKey"/> is too short.</exception>
 254    /// <exception cref="InvalidOperationException">The last item on a page that has a next page has a <c>null</c> key.<
 255    public static async Task<PaginatedOutput<T>> PaginateByTokenAsync<T, TKey>(
 256        this IQueryable<T> query,
 257        string? pageToken,
 258        int pageSize,
 259        Expression<Func<T, TKey>> keySelector,
 260        PageTokenOptions options,
 261        SortDirection direction = SortDirection.Ascending,
 262        bool includeTotalCount = false,
 263        CancellationToken cancellationToken = default) =>
 38264        TryPlanKeysetPage<T, TKey, object?>(query, pageToken, pageSize, keySelector, null, options, direction,
 38265            out var plan, out var rejection)
 38266            ? await ReadKeysetPageAsync(plan, includeTotalCount, cancellationToken).ConfigureAwait(false)
 38267            : rejection;
 268
 269    /// <summary>
 270    /// Asynchronously returns the page after the one that issued <paramref name="pageToken"/>, or the first page when
 271    /// no token is given, ordered by key and tiebreaker. See
 272    /// <see cref="PaginateByToken{T, TKey, TTieBreaker}(IQueryable{T}, string?, int, Expression{Func{T, TKey}}, Express
 273    /// </summary>
 274    /// <typeparam name="T">Element type of the queryable.</typeparam>
 275    /// <typeparam name="TKey">Type of the key the listing is ordered and resumed by.</typeparam>
 276    /// <typeparam name="TTieBreaker">Type of the tiebreaker that orders items with equal keys.</typeparam>
 277    /// <param name="query">The source query.</param>
 278    /// <param name="pageToken">
 279    /// <see cref="PaginatedOutput{T}.NextPageToken"/> from the previous page, or <c>null</c> (or blank) for the first
 280    /// page.
 281    /// </param>
 282    /// <param name="pageSize">Number of items per page. Values below <c>1</c> are clamped to <c>1</c>.</param>
 283    /// <param name="keySelector">The non-null key the listing is ordered and resumed by.</param>
 284    /// <param name="tieBreakerSelector">The non-null value that orders items with equal keys.</param>
 285    /// <param name="options">Signing key, lifetime and scope of the tokens.</param>
 286    /// <param name="direction">Whether the listing walks the key and tiebreaker ascending or descending.</param>
 287    /// <param name="includeTotalCount">
 288    /// When <c>true</c>, the whole query is counted and reported as <see cref="PaginatedOutput{T}.TotalItems"/>.
 289    /// </param>
 290    /// <param name="cancellationToken">Cancellation token.</param>
 291    /// <returns>A task that resolves to the page, or to an empty output carrying one of the <see cref="PageTokenErrors"
 292    /// <exception cref="ArgumentNullException">
 293    /// <paramref name="query"/>, <paramref name="keySelector"/>, <paramref name="tieBreakerSelector"/> or
 294    /// <paramref name="options"/> is <c>null</c>.
 295    /// </exception>
 296    /// <exception cref="ArgumentException"><see cref="PageTokenOptions.SigningKey"/> is too short.</exception>
 297    /// <exception cref="InvalidOperationException">
 298    /// The last item on a page that has a next page has a <c>null</c> key or tiebreaker.
 299    /// </exception>
 300    public static async Task<PaginatedOutput<T>> PaginateByTokenAsync<T, TKey, TTieBreaker>(
 301        this IQueryable<T> query,
 302        string? pageToken,
 303        int pageSize,
 304        Expression<Func<T, TKey>> keySelector,
 305        Expression<Func<T, TTieBreaker>> tieBreakerSelector,
 306        PageTokenOptions options,
 307        SortDirection direction = SortDirection.Ascending,
 308        bool includeTotalCount = false,
 309        CancellationToken cancellationToken = default)
 310    {
 23311        ArgumentNullException.ThrowIfNull(tieBreakerSelector);
 312
 23313        return TryPlanKeysetPage(query, pageToken, pageSize, keySelector, tieBreakerSelector, options, direction,
 23314            out var plan, out var rejection)
 23315            ? await ReadKeysetPageAsync(plan, includeTotalCount, cancellationToken).ConfigureAwait(false)
 23316            : rejection;
 23317    }
 318
 319    /// <summary>
 320    /// A token-paginated request, checked and composed but not yet run.
 321    /// </summary>
 322    /// <param name="CountQuery">The caller's query, counted when the total is requested.</param>
 323    /// <param name="PageQuery">The ordered, keyset-filtered query, fetching one row more than a page.</param>
 324    /// <param name="PageNumber">The page this request reads.</param>
 325    /// <param name="PageSize">The clamped page size.</param>
 326    /// <param name="IssueNextToken">Issues the token that resumes after the given item.</param>
 115327    private sealed record KeysetPlan<T>(
 15328        IQueryable<T> CountQuery,
 171329        IQueryable<T> PageQuery,
 113330        int PageNumber,
 227331        int PageSize,
 209332        Func<T, string> IssueNextToken);
 333
 334    /// <summary>
 335    /// Checks the arguments and the token, then composes the page query. Returns <c>false</c> with an error output in
 336    /// <paramref name="rejection"/> when the token is rejected; throws for programmer errors.
 337    /// </summary>
 338    private static bool TryPlanKeysetPage<T, TKey, TTieBreaker>(
 339        IQueryable<T> query,
 340        string? pageToken,
 341        int pageSize,
 342        Expression<Func<T, TKey>> keySelector,
 343        Expression<Func<T, TTieBreaker>>? tieBreakerSelector,
 344        PageTokenOptions options,
 345        SortDirection direction,
 346        [NotNullWhen(true)] out KeysetPlan<T>? plan,
 347        [NotNullWhen(false)] out PaginatedOutput<T>? rejection)
 348    {
 132349        ArgumentNullException.ThrowIfNull(query);
 130350        ArgumentNullException.ThrowIfNull(keySelector);
 129351        PageToken.ValidateOptions(options);
 352
 125353        pageSize = Math.Max(1, pageSize);
 354
 125355        var descending = direction == SortDirection.Descending;
 125356        var fingerprint = PageToken.CreateFingerprint(options, keySelector, tieBreakerSelector, direction, pageSize);
 125357        var pageNumber = 1;
 125358        var page = query;
 359
 125360        if (!string.IsNullOrWhiteSpace(pageToken))
 361        {
 86362            if (!PageToken.TryRead(pageToken, options, fingerprint, out var payload, out var error))
 363            {
 10364                plan = null;
 10365                rejection = PaginatedOutput<T>.New.WithEmptyData().WithError(error!);
 366
 10367                return false;
 368            }
 369
 76370            TTieBreaker? lastTieBreaker = default;
 371
 372            // A signed payload whose values no longer read as the key types — a property whose type changed between
 373            // deployments, say — cannot resume the listing.
 76374            if (!TryReadValue<TKey>(payload!.Key, out var lastKey) ||
 76375                (tieBreakerSelector is not null && !TryReadValue(payload.TieBreaker, out lastTieBreaker)))
 376            {
 0377                plan = null;
 0378                rejection = PaginatedOutput<T>.New.WithEmptyData().WithError(PageTokenErrors.Invalid);
 379
 0380                return false;
 381            }
 382
 76383            page = page.Where(After(keySelector, lastKey, tieBreakerSelector, lastTieBreaker, descending));
 76384            pageNumber = payload.PageNumber;
 385        }
 386
 115387        var ordered = descending ? page.OrderByDescending(keySelector) : page.OrderBy(keySelector);
 388
 115389        if (tieBreakerSelector is not null)
 390        {
 41391            ordered = descending ? ordered.ThenByDescending(tieBreakerSelector) : ordered.ThenBy(tieBreakerSelector);
 392        }
 393
 115394        var getKey = keySelector.Compile();
 115395        var getTieBreaker = tieBreakerSelector?.Compile();
 115396        var nextPageNumber = pageNumber + 1;
 397
 115398        plan = new KeysetPlan<T>(query, ordered.Take(pageSize + 1), pageNumber, pageSize, IssueNextToken);
 115399        rejection = null;
 400
 115401        return true;
 402
 403        string IssueNextToken(T last)
 404        {
 94405            var key = getKey(last) ?? throw new InvalidOperationException(
 94406                "Token pagination needs a non-null key, but the last item on the page has a null key.");
 407
 93408            JsonElement? tieBreaker = null;
 409
 93410            if (getTieBreaker is not null)
 411            {
 36412                tieBreaker = JsonSerializer.SerializeToElement(getTieBreaker(last) ??
 36413                    throw new InvalidOperationException(
 36414                        "Token pagination needs a non-null tiebreaker, but the last item on the page has a null one."));
 415            }
 416
 93417            return PageToken.Create(options, fingerprint, nextPageNumber, JsonSerializer.SerializeToElement(key),
 93418                tieBreaker);
 419        }
 420    }
 421
 422    private static PaginatedOutput<T> ReadKeysetPage<T>(KeysetPlan<T> plan, bool includeTotalCount)
 423    {
 59424        int? totalItems = includeTotalCount ? plan.CountQuery.Count() : null;
 425
 59426        return CompleteKeysetPage(plan, plan.PageQuery.ToList(), totalItems);
 427    }
 428
 429    private static async Task<PaginatedOutput<T>> ReadKeysetPageAsync<T>(KeysetPlan<T> plan, bool includeTotalCount,
 430        CancellationToken cancellationToken)
 431    {
 56432        int? totalItems = null;
 433
 56434        if (includeTotalCount)
 435        {
 6436            totalItems = plan.CountQuery.Provider is IAsyncQueryProvider
 6437                ? await plan.CountQuery.CountAsync(cancellationToken).ConfigureAwait(false)
 6438                : plan.CountQuery.Count();
 439        }
 440
 56441        var rows = plan.PageQuery.Provider is IAsyncQueryProvider
 56442            ? await plan.PageQuery.ToListAsync(cancellationToken).ConfigureAwait(false)
 56443            : plan.PageQuery.ToList();
 444
 55445        return CompleteKeysetPage(plan, rows, totalItems);
 55446    }
 447
 448    /// <summary>
 449    /// Drops the probe row fetched past the page and, when there was one, issues the token for the next page.
 450    /// </summary>
 451    private static PaginatedOutput<T> CompleteKeysetPage<T>(KeysetPlan<T> plan, List<T> rows, int? totalItems)
 452    {
 114453        string? nextPageToken = null;
 454
 114455        if (rows.Count > plan.PageSize)
 456        {
 94457            rows.RemoveAt(rows.Count - 1);
 94458            nextPageToken = plan.IssueNextToken(rows[^1]);
 459        }
 460
 113461        return PaginatedOutput<T>.New
 113462            .WithData(rows)
 113463            .WithPagination(plan.PageNumber, plan.PageSize, totalItems ?? 0)
 113464            .WithNextPageToken(nextPageToken);
 465    }
 466
 467    private static bool TryReadValue<TValue>(JsonElement? element, [NotNullWhen(true)] out TValue? value)
 468    {
 112469        value = default;
 470
 112471        if (element is null)
 472        {
 0473            return false;
 474        }
 475
 476        try
 477        {
 112478            value = element.Value.Deserialize<TValue>();
 112479        }
 0480        catch (JsonException)
 481        {
 0482            return false;
 483        }
 484
 112485        return value is not null;
 0486    }
 487
 488    /// <summary>
 489    /// Builds the keyset filter: items past <paramref name="lastKey"/> in the walk direction, or — with a tiebreaker —
 490    /// items with the same key and a tiebreaker past <paramref name="lastTieBreaker"/>.
 491    /// </summary>
 492    private static Expression<Func<T, bool>> After<T, TKey, TTieBreaker>(
 493        Expression<Func<T, TKey>> keySelector,
 494        TKey lastKey,
 495        Expression<Func<T, TTieBreaker>>? tieBreakerSelector,
 496        TTieBreaker? lastTieBreaker,
 497        bool descending)
 498    {
 76499        var parameter = keySelector.Parameters[0];
 76500        var key = keySelector.Body;
 76501        var capturedKey = Captured(lastKey, key.Type);
 502
 76503        var body = Beyond(key, capturedKey, descending);
 504
 76505        if (tieBreakerSelector is not null)
 506        {
 36507            var tieBreaker = new ParameterReplacer(tieBreakerSelector.Parameters[0], parameter)
 36508                .Visit(tieBreakerSelector.Body);
 509
 36510            body = Expression.OrElse(body, Expression.AndAlso(
 36511                Expression.Equal(key, capturedKey),
 36512                Beyond(tieBreaker, Captured(lastTieBreaker, tieBreaker.Type), descending)));
 513        }
 514
 76515        return Expression.Lambda<Func<T, bool>>(body, parameter);
 516    }
 517
 518    /// <summary>
 519    /// <c>left &gt; right</c> (or <c>&lt;</c> when descending), through the type's comparison operator when it has
 520    /// one and through <c>left.CompareTo(right) &gt; 0</c> otherwise (strings, for example), which EF Core translates
 521    /// to a plain SQL comparison too.
 522    /// </summary>
 523    private static Expression Beyond(Expression left, Expression right, bool descending)
 524    {
 112525        var comparison = descending ? ExpressionType.LessThan : ExpressionType.GreaterThan;
 526
 112527        if (HasComparisonOperator(left.Type))
 528        {
 83529            return Expression.MakeBinary(comparison, left, right);
 530        }
 531
 29532        var compareTo = left.Type.GetMethod(nameof(IComparable.CompareTo), [left.Type])
 29533                        ?? throw new InvalidOperationException(
 29534                            $"Token pagination cannot compare keys of type {left.Type}: it has neither a comparison oper
 535
 29536        return Expression.MakeBinary(comparison, Expression.Call(left, compareTo, right), Expression.Constant(0));
 537    }
 538
 539    private static bool HasComparisonOperator(Type type) =>
 112540        ComparisonOperatorSupport.GetOrAdd(type, static t =>
 112541        {
 112542            try
 112543            {
 4544                Expression.GreaterThan(Expression.Default(t), Expression.Default(t));
 112545
 3546                return true;
 112547            }
 1548            catch (InvalidOperationException)
 112549            {
 1550                return false;
 112551            }
 116552        });
 553
 554    /// <summary>
 555    /// Whether each key type has a comparison operator, cached because finding out costs an exception for the types
 556    /// that do not.
 557    /// </summary>
 1558    private static readonly ConcurrentDictionary<Type, bool> ComparisonOperatorSupport = new();
 559
 560    /// <summary>
 561    /// Wraps a value the way the compiler wraps a captured variable — a field read off a constant — so EF Core sends
 562    /// it as a SQL parameter rather than inlining a literal, and every page shares one cached query plan.
 563    /// </summary>
 564    private static Expression Captured<TValue>(TValue value, Type type)
 565    {
 112566        Expression field = Expression.Field(Expression.Constant(new CapturedValue<TValue>(value)),
 112567            nameof(CapturedValue<TValue>.Value));
 568
 112569        return field.Type == type ? field : Expression.Convert(field, type);
 570    }
 571
 112572    private sealed class CapturedValue<TValue>(TValue value)
 573    {
 112574        public readonly TValue Value = value;
 575    }
 576
 36577    private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
 578    {
 36579        protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : node;
 580    }
 581
 582    /// <summary>
 583    /// Computes how many items precede the page, failing when that number does not fit in an
 584    /// <see cref="int"/>.
 585    /// </summary>
 586    /// <remarks>
 587    /// Computed in 64 bits because <c>(pageNumber - 1) * pageSize</c> overflows for a far-out page — both are
 588    /// caller input — and the wrapped value can be negative, which LINQ and SQLite both treat as no offset at
 589    /// all, handing back the first page under the far-out page number. An offset past
 590    /// <see cref="int.MaxValue"/> is past every item <c>Skip</c> can address, so that page is empty.
 591    /// </remarks>
 592    private static bool TryGetOffset(int pageNumber, int pageSize, out int offset)
 593    {
 35594        var wideOffset = (long)(pageNumber - 1) * pageSize;
 595
 35596        offset = wideOffset <= int.MaxValue ? (int)wideOffset : 0;
 597
 35598        return wideOffset <= int.MaxValue;
 599    }
 600
 601    /// <summary>
 602    /// Builds an <c>OrderBy</c> call dynamically from a lambda expression when
 603    /// the expression's return type is <see cref="object"/> and EF Core's
 604    /// expression translation would otherwise lose the typed delegate.
 605    /// </summary>
 606    private static IQueryable<T> OrderByExpression<T>(IQueryable<T> source, LambdaExpression keySelector)
 607    {
 19608        var body = keySelector.Body;
 609
 19610        if (body is UnaryExpression unary && body.NodeType == ExpressionType.Convert)
 611        {
 16612            body = unary.Operand;
 613        }
 614
 19615        var keyType = body.Type;
 19616        var parameter = keySelector.Parameters[0];
 617
 19618        var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), keyType);
 19619        var typedLambda = Expression.Lambda(delegateType, body, parameter);
 620
 19621        var orderByMethod = typeof(Queryable)
 19622            .GetMethods(BindingFlags.Public | BindingFlags.Static)
 399623            .First(m => m.Name == "OrderBy" && m.GetParameters().Length == 2)
 19624            .MakeGenericMethod(typeof(T), keyType);
 625
 19626        return (IQueryable<T>)orderByMethod.Invoke(null, [source, typedLambda])!;
 627    }
 628}

Methods/Properties

PaginateAsync()
Paginate(System.Linq.IQueryable`1<T>,System.Int32,System.Int32,System.Linq.Expressions.Expression`1<System.Func`2<T,System.Object>>,System.Nullable`1<System.Int32>)
PaginateByToken(System.Linq.IQueryable`1<T>,System.String,System.Int32,System.Linq.Expressions.Expression`1<System.Func`2<T,TKey>>,ArturRios.Output.PageTokenOptions,ArturRios.Output.SortDirection,System.Boolean)
PaginateByToken(System.Linq.IQueryable`1<T>,System.String,System.Int32,System.Linq.Expressions.Expression`1<System.Func`2<T,TKey>>,System.Linq.Expressions.Expression`1<System.Func`2<T,TTieBreaker>>,ArturRios.Output.PageTokenOptions,ArturRios.Output.SortDirection,System.Boolean)
PaginateByTokenAsync()
PaginateByTokenAsync()
.ctor(System.Linq.IQueryable`1<T>,System.Linq.IQueryable`1<T>,System.Int32,System.Int32,System.Func`2<T,System.String>)
get_CountQuery()
get_PageQuery()
get_PageNumber()
get_PageSize()
get_IssueNextToken()
TryPlanKeysetPage(System.Linq.IQueryable`1<T>,System.String,System.Int32,System.Linq.Expressions.Expression`1<System.Func`2<T,TKey>>,System.Linq.Expressions.Expression`1<System.Func`2<T,TTieBreaker>>,ArturRios.Output.PageTokenOptions,ArturRios.Output.SortDirection,ArturRios.Output.PaginatedOutputExtensions/KeysetPlan`1<T>&,ArturRios.Output.PaginatedOutput`1<T>&)
IssueNextToken()
ReadKeysetPage(ArturRios.Output.PaginatedOutputExtensions/KeysetPlan`1<T>,System.Boolean)
ReadKeysetPageAsync()
CompleteKeysetPage(ArturRios.Output.PaginatedOutputExtensions/KeysetPlan`1<T>,System.Collections.Generic.List`1<T>,System.Nullable`1<System.Int32>)
TryReadValue(System.Nullable`1<System.Text.Json.JsonElement>,TValue&)
After(System.Linq.Expressions.Expression`1<System.Func`2<T,TKey>>,TKey,System.Linq.Expressions.Expression`1<System.Func`2<T,TTieBreaker>>,TTieBreaker,System.Boolean)
Beyond(System.Linq.Expressions.Expression,System.Linq.Expressions.Expression,System.Boolean)
HasComparisonOperator(System.Type)
.cctor()
Captured(TValue,System.Type)
.ctor(TValue)
.ctor(System.Linq.Expressions.ParameterExpression,System.Linq.Expressions.ParameterExpression)
VisitParameter(System.Linq.Expressions.ParameterExpression)
TryGetOffset(System.Int32,System.Int32,System.Int32&)
OrderByExpression(System.Linq.IQueryable`1<T>,System.Linq.Expressions.LambdaExpression)