| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using System.Diagnostics.CodeAnalysis; |
| | | 3 | | using System.Linq.Expressions; |
| | | 4 | | using System.Reflection; |
| | | 5 | | using System.Text.Json; |
| | | 6 | | using Microsoft.EntityFrameworkCore; |
| | | 7 | | using Microsoft.EntityFrameworkCore.Query; |
| | | 8 | | |
| | | 9 | | namespace 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> |
| | | 15 | | public 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 | | { |
| | 22 | 40 | | ArgumentNullException.ThrowIfNull(query); |
| | | 41 | | |
| | 21 | 42 | | pageNumber = Math.Max(1, pageNumber); |
| | 21 | 43 | | pageSize = Math.Max(1, pageSize); |
| | | 44 | | |
| | 21 | 45 | | if (orderBy is not null) |
| | | 46 | | { |
| | 12 | 47 | | query = OrderByExpression(query, orderBy); |
| | | 48 | | } |
| | | 49 | | |
| | 21 | 50 | | var countWasSupplied = totalCount.HasValue; |
| | | 51 | | |
| | 21 | 52 | | totalCount = countWasSupplied |
| | 21 | 53 | | ? Math.Max(0, totalCount!.Value) |
| | 21 | 54 | | : query.Provider is IAsyncQueryProvider |
| | 21 | 55 | | ? await query.CountAsync(cancellationToken).ConfigureAwait(false) |
| | 21 | 56 | | : query.Count(); |
| | | 57 | | |
| | | 58 | | List<T> items; |
| | | 59 | | |
| | 20 | 60 | | if ((!countWasSupplied && totalCount.Value == 0) || !TryGetOffset(pageNumber, pageSize, out var offset)) |
| | | 61 | | { |
| | 5 | 62 | | items = []; |
| | | 63 | | } |
| | | 64 | | else |
| | | 65 | | { |
| | 15 | 66 | | var pageQuery = query.Skip(offset).Take(pageSize); |
| | | 67 | | |
| | 15 | 68 | | items = pageQuery.Provider is IAsyncQueryProvider |
| | 15 | 69 | | ? await pageQuery.ToListAsync(cancellationToken).ConfigureAwait(false) |
| | 15 | 70 | | : pageQuery.ToList(); |
| | | 71 | | } |
| | | 72 | | |
| | 20 | 73 | | return PaginatedOutput<T>.New |
| | 20 | 74 | | .WithData(items) |
| | 20 | 75 | | .WithPagination(pageNumber, pageSize, totalCount.Value); |
| | 20 | 76 | | } |
| | | 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 | | { |
| | 19 | 99 | | ArgumentNullException.ThrowIfNull(query); |
| | | 100 | | |
| | 18 | 101 | | pageNumber = Math.Max(1, pageNumber); |
| | 18 | 102 | | pageSize = Math.Max(1, pageSize); |
| | | 103 | | |
| | 18 | 104 | | if (orderBy is not null) |
| | | 105 | | { |
| | 7 | 106 | | query = OrderByExpression(query, orderBy); |
| | | 107 | | } |
| | | 108 | | |
| | 18 | 109 | | var countWasSupplied = totalCount.HasValue; |
| | | 110 | | |
| | 18 | 111 | | totalCount = countWasSupplied ? Math.Max(0, totalCount!.Value) : query.Count(); |
| | | 112 | | |
| | 18 | 113 | | var items = (!countWasSupplied && totalCount.Value == 0) || !TryGetOffset(pageNumber, pageSize, out var offset) |
| | 18 | 114 | | ? [] |
| | 18 | 115 | | : query |
| | 18 | 116 | | .Skip(offset) |
| | 18 | 117 | | .Take(pageSize) |
| | 18 | 118 | | .ToList(); |
| | | 119 | | |
| | 18 | 120 | | return PaginatedOutput<T>.New |
| | 18 | 121 | | .WithData(items) |
| | 18 | 122 | | .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) => |
| | 52 | 167 | | TryPlanKeysetPage<T, TKey, object?>(query, pageToken, pageSize, keySelector, null, options, direction, |
| | 52 | 168 | | out var plan, out var rejection) |
| | 52 | 169 | | ? ReadKeysetPage(plan, includeTotalCount) |
| | 52 | 170 | | : 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 | | { |
| | 20 | 221 | | ArgumentNullException.ThrowIfNull(tieBreakerSelector); |
| | | 222 | | |
| | 19 | 223 | | return TryPlanKeysetPage(query, pageToken, pageSize, keySelector, tieBreakerSelector, options, direction, |
| | 19 | 224 | | out var plan, out var rejection) |
| | 19 | 225 | | ? ReadKeysetPage(plan, includeTotalCount) |
| | 19 | 226 | | : 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) => |
| | 38 | 264 | | TryPlanKeysetPage<T, TKey, object?>(query, pageToken, pageSize, keySelector, null, options, direction, |
| | 38 | 265 | | out var plan, out var rejection) |
| | 38 | 266 | | ? await ReadKeysetPageAsync(plan, includeTotalCount, cancellationToken).ConfigureAwait(false) |
| | 38 | 267 | | : 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 | | { |
| | 23 | 311 | | ArgumentNullException.ThrowIfNull(tieBreakerSelector); |
| | | 312 | | |
| | 23 | 313 | | return TryPlanKeysetPage(query, pageToken, pageSize, keySelector, tieBreakerSelector, options, direction, |
| | 23 | 314 | | out var plan, out var rejection) |
| | 23 | 315 | | ? await ReadKeysetPageAsync(plan, includeTotalCount, cancellationToken).ConfigureAwait(false) |
| | 23 | 316 | | : rejection; |
| | 23 | 317 | | } |
| | | 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> |
| | 115 | 327 | | private sealed record KeysetPlan<T>( |
| | 15 | 328 | | IQueryable<T> CountQuery, |
| | 171 | 329 | | IQueryable<T> PageQuery, |
| | 113 | 330 | | int PageNumber, |
| | 227 | 331 | | int PageSize, |
| | 209 | 332 | | 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 | | { |
| | 132 | 349 | | ArgumentNullException.ThrowIfNull(query); |
| | 130 | 350 | | ArgumentNullException.ThrowIfNull(keySelector); |
| | 129 | 351 | | PageToken.ValidateOptions(options); |
| | | 352 | | |
| | 125 | 353 | | pageSize = Math.Max(1, pageSize); |
| | | 354 | | |
| | 125 | 355 | | var descending = direction == SortDirection.Descending; |
| | 125 | 356 | | var fingerprint = PageToken.CreateFingerprint(options, keySelector, tieBreakerSelector, direction, pageSize); |
| | 125 | 357 | | var pageNumber = 1; |
| | 125 | 358 | | var page = query; |
| | | 359 | | |
| | 125 | 360 | | if (!string.IsNullOrWhiteSpace(pageToken)) |
| | | 361 | | { |
| | 86 | 362 | | if (!PageToken.TryRead(pageToken, options, fingerprint, out var payload, out var error)) |
| | | 363 | | { |
| | 10 | 364 | | plan = null; |
| | 10 | 365 | | rejection = PaginatedOutput<T>.New.WithEmptyData().WithError(error!); |
| | | 366 | | |
| | 10 | 367 | | return false; |
| | | 368 | | } |
| | | 369 | | |
| | 76 | 370 | | 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. |
| | 76 | 374 | | if (!TryReadValue<TKey>(payload!.Key, out var lastKey) || |
| | 76 | 375 | | (tieBreakerSelector is not null && !TryReadValue(payload.TieBreaker, out lastTieBreaker))) |
| | | 376 | | { |
| | 0 | 377 | | plan = null; |
| | 0 | 378 | | rejection = PaginatedOutput<T>.New.WithEmptyData().WithError(PageTokenErrors.Invalid); |
| | | 379 | | |
| | 0 | 380 | | return false; |
| | | 381 | | } |
| | | 382 | | |
| | 76 | 383 | | page = page.Where(After(keySelector, lastKey, tieBreakerSelector, lastTieBreaker, descending)); |
| | 76 | 384 | | pageNumber = payload.PageNumber; |
| | | 385 | | } |
| | | 386 | | |
| | 115 | 387 | | var ordered = descending ? page.OrderByDescending(keySelector) : page.OrderBy(keySelector); |
| | | 388 | | |
| | 115 | 389 | | if (tieBreakerSelector is not null) |
| | | 390 | | { |
| | 41 | 391 | | ordered = descending ? ordered.ThenByDescending(tieBreakerSelector) : ordered.ThenBy(tieBreakerSelector); |
| | | 392 | | } |
| | | 393 | | |
| | 115 | 394 | | var getKey = keySelector.Compile(); |
| | 115 | 395 | | var getTieBreaker = tieBreakerSelector?.Compile(); |
| | 115 | 396 | | var nextPageNumber = pageNumber + 1; |
| | | 397 | | |
| | 115 | 398 | | plan = new KeysetPlan<T>(query, ordered.Take(pageSize + 1), pageNumber, pageSize, IssueNextToken); |
| | 115 | 399 | | rejection = null; |
| | | 400 | | |
| | 115 | 401 | | return true; |
| | | 402 | | |
| | | 403 | | string IssueNextToken(T last) |
| | | 404 | | { |
| | 94 | 405 | | var key = getKey(last) ?? throw new InvalidOperationException( |
| | 94 | 406 | | "Token pagination needs a non-null key, but the last item on the page has a null key."); |
| | | 407 | | |
| | 93 | 408 | | JsonElement? tieBreaker = null; |
| | | 409 | | |
| | 93 | 410 | | if (getTieBreaker is not null) |
| | | 411 | | { |
| | 36 | 412 | | tieBreaker = JsonSerializer.SerializeToElement(getTieBreaker(last) ?? |
| | 36 | 413 | | throw new InvalidOperationException( |
| | 36 | 414 | | "Token pagination needs a non-null tiebreaker, but the last item on the page has a null one.")); |
| | | 415 | | } |
| | | 416 | | |
| | 93 | 417 | | return PageToken.Create(options, fingerprint, nextPageNumber, JsonSerializer.SerializeToElement(key), |
| | 93 | 418 | | tieBreaker); |
| | | 419 | | } |
| | | 420 | | } |
| | | 421 | | |
| | | 422 | | private static PaginatedOutput<T> ReadKeysetPage<T>(KeysetPlan<T> plan, bool includeTotalCount) |
| | | 423 | | { |
| | 59 | 424 | | int? totalItems = includeTotalCount ? plan.CountQuery.Count() : null; |
| | | 425 | | |
| | 59 | 426 | | 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 | | { |
| | 56 | 432 | | int? totalItems = null; |
| | | 433 | | |
| | 56 | 434 | | if (includeTotalCount) |
| | | 435 | | { |
| | 6 | 436 | | totalItems = plan.CountQuery.Provider is IAsyncQueryProvider |
| | 6 | 437 | | ? await plan.CountQuery.CountAsync(cancellationToken).ConfigureAwait(false) |
| | 6 | 438 | | : plan.CountQuery.Count(); |
| | | 439 | | } |
| | | 440 | | |
| | 56 | 441 | | var rows = plan.PageQuery.Provider is IAsyncQueryProvider |
| | 56 | 442 | | ? await plan.PageQuery.ToListAsync(cancellationToken).ConfigureAwait(false) |
| | 56 | 443 | | : plan.PageQuery.ToList(); |
| | | 444 | | |
| | 55 | 445 | | return CompleteKeysetPage(plan, rows, totalItems); |
| | 55 | 446 | | } |
| | | 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 | | { |
| | 114 | 453 | | string? nextPageToken = null; |
| | | 454 | | |
| | 114 | 455 | | if (rows.Count > plan.PageSize) |
| | | 456 | | { |
| | 94 | 457 | | rows.RemoveAt(rows.Count - 1); |
| | 94 | 458 | | nextPageToken = plan.IssueNextToken(rows[^1]); |
| | | 459 | | } |
| | | 460 | | |
| | 113 | 461 | | return PaginatedOutput<T>.New |
| | 113 | 462 | | .WithData(rows) |
| | 113 | 463 | | .WithPagination(plan.PageNumber, plan.PageSize, totalItems ?? 0) |
| | 113 | 464 | | .WithNextPageToken(nextPageToken); |
| | | 465 | | } |
| | | 466 | | |
| | | 467 | | private static bool TryReadValue<TValue>(JsonElement? element, [NotNullWhen(true)] out TValue? value) |
| | | 468 | | { |
| | 112 | 469 | | value = default; |
| | | 470 | | |
| | 112 | 471 | | if (element is null) |
| | | 472 | | { |
| | 0 | 473 | | return false; |
| | | 474 | | } |
| | | 475 | | |
| | | 476 | | try |
| | | 477 | | { |
| | 112 | 478 | | value = element.Value.Deserialize<TValue>(); |
| | 112 | 479 | | } |
| | 0 | 480 | | catch (JsonException) |
| | | 481 | | { |
| | 0 | 482 | | return false; |
| | | 483 | | } |
| | | 484 | | |
| | 112 | 485 | | return value is not null; |
| | 0 | 486 | | } |
| | | 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 | | { |
| | 76 | 499 | | var parameter = keySelector.Parameters[0]; |
| | 76 | 500 | | var key = keySelector.Body; |
| | 76 | 501 | | var capturedKey = Captured(lastKey, key.Type); |
| | | 502 | | |
| | 76 | 503 | | var body = Beyond(key, capturedKey, descending); |
| | | 504 | | |
| | 76 | 505 | | if (tieBreakerSelector is not null) |
| | | 506 | | { |
| | 36 | 507 | | var tieBreaker = new ParameterReplacer(tieBreakerSelector.Parameters[0], parameter) |
| | 36 | 508 | | .Visit(tieBreakerSelector.Body); |
| | | 509 | | |
| | 36 | 510 | | body = Expression.OrElse(body, Expression.AndAlso( |
| | 36 | 511 | | Expression.Equal(key, capturedKey), |
| | 36 | 512 | | Beyond(tieBreaker, Captured(lastTieBreaker, tieBreaker.Type), descending))); |
| | | 513 | | } |
| | | 514 | | |
| | 76 | 515 | | return Expression.Lambda<Func<T, bool>>(body, parameter); |
| | | 516 | | } |
| | | 517 | | |
| | | 518 | | /// <summary> |
| | | 519 | | /// <c>left > right</c> (or <c><</c> when descending), through the type's comparison operator when it has |
| | | 520 | | /// one and through <c>left.CompareTo(right) > 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 | | { |
| | 112 | 525 | | var comparison = descending ? ExpressionType.LessThan : ExpressionType.GreaterThan; |
| | | 526 | | |
| | 112 | 527 | | if (HasComparisonOperator(left.Type)) |
| | | 528 | | { |
| | 83 | 529 | | return Expression.MakeBinary(comparison, left, right); |
| | | 530 | | } |
| | | 531 | | |
| | 29 | 532 | | var compareTo = left.Type.GetMethod(nameof(IComparable.CompareTo), [left.Type]) |
| | 29 | 533 | | ?? throw new InvalidOperationException( |
| | 29 | 534 | | $"Token pagination cannot compare keys of type {left.Type}: it has neither a comparison oper |
| | | 535 | | |
| | 29 | 536 | | return Expression.MakeBinary(comparison, Expression.Call(left, compareTo, right), Expression.Constant(0)); |
| | | 537 | | } |
| | | 538 | | |
| | | 539 | | private static bool HasComparisonOperator(Type type) => |
| | 112 | 540 | | ComparisonOperatorSupport.GetOrAdd(type, static t => |
| | 112 | 541 | | { |
| | 112 | 542 | | try |
| | 112 | 543 | | { |
| | 4 | 544 | | Expression.GreaterThan(Expression.Default(t), Expression.Default(t)); |
| | 112 | 545 | | |
| | 3 | 546 | | return true; |
| | 112 | 547 | | } |
| | 1 | 548 | | catch (InvalidOperationException) |
| | 112 | 549 | | { |
| | 1 | 550 | | return false; |
| | 112 | 551 | | } |
| | 116 | 552 | | }); |
| | | 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> |
| | 1 | 558 | | 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 | | { |
| | 112 | 566 | | Expression field = Expression.Field(Expression.Constant(new CapturedValue<TValue>(value)), |
| | 112 | 567 | | nameof(CapturedValue<TValue>.Value)); |
| | | 568 | | |
| | 112 | 569 | | return field.Type == type ? field : Expression.Convert(field, type); |
| | | 570 | | } |
| | | 571 | | |
| | 112 | 572 | | private sealed class CapturedValue<TValue>(TValue value) |
| | | 573 | | { |
| | 112 | 574 | | public readonly TValue Value = value; |
| | | 575 | | } |
| | | 576 | | |
| | 36 | 577 | | private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to) : ExpressionVisitor |
| | | 578 | | { |
| | 36 | 579 | | 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 | | { |
| | 35 | 594 | | var wideOffset = (long)(pageNumber - 1) * pageSize; |
| | | 595 | | |
| | 35 | 596 | | offset = wideOffset <= int.MaxValue ? (int)wideOffset : 0; |
| | | 597 | | |
| | 35 | 598 | | 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 | | { |
| | 19 | 608 | | var body = keySelector.Body; |
| | | 609 | | |
| | 19 | 610 | | if (body is UnaryExpression unary && body.NodeType == ExpressionType.Convert) |
| | | 611 | | { |
| | 16 | 612 | | body = unary.Operand; |
| | | 613 | | } |
| | | 614 | | |
| | 19 | 615 | | var keyType = body.Type; |
| | 19 | 616 | | var parameter = keySelector.Parameters[0]; |
| | | 617 | | |
| | 19 | 618 | | var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), keyType); |
| | 19 | 619 | | var typedLambda = Expression.Lambda(delegateType, body, parameter); |
| | | 620 | | |
| | 19 | 621 | | var orderByMethod = typeof(Queryable) |
| | 19 | 622 | | .GetMethods(BindingFlags.Public | BindingFlags.Static) |
| | 399 | 623 | | .First(m => m.Name == "OrderBy" && m.GetParameters().Length == 2) |
| | 19 | 624 | | .MakeGenericMethod(typeof(T), keyType); |
| | | 625 | | |
| | 19 | 626 | | return (IQueryable<T>)orderByMethod.Invoke(null, [source, typedLambda])!; |
| | | 627 | | } |
| | | 628 | | } |