-
Notifications
You must be signed in to change notification settings - Fork 0
Add cursor-based pagination with composite Page Token implementation and unified async API #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
6138eda
a3eb233
417c3ff
26f6f57
273c935
4e0c18b
c611f74
7397df4
9439bb3
4c44cf4
36d548b
f4282a7
7dc2245
d858082
d913919
3a7287f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| using AutoQuery; | ||
| using AutoQuery.Abstractions; | ||
| using AutoQuery.Extensions; | ||
| using AutoQueryApiDemo.Models; | ||
|
|
||
| namespace AutoQueryApiDemo.Configurations; | ||
|
|
||
| public class UserCursorQueryConfiguration : IFilterQueryConfiguration<UserCursorQueryOptions, User> | ||
| { | ||
| public void Configure(FilterQueryBuilder<UserCursorQueryOptions, User> builder) | ||
| { | ||
| // Configure cursor key for cursor-based pagination | ||
| builder.HasCursorKey(d => d.Id); | ||
|
|
||
| // Configure filter properties | ||
| builder.Property(q => q.FilterIds, d => d.Id) | ||
| .HasCollectionContains(); | ||
| builder.Property(q => q.FilterName, d => d.Name) | ||
| .HasEqual(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| using AutoQuery.Abstractions; | ||
| using Microsoft.AspNetCore.Mvc; | ||
|
|
||
| namespace AutoQueryApiDemo.Models; | ||
|
|
||
| public class UserCursorQueryOptions : IQueryCursorOptions | ||
| { | ||
| [FromQuery(Name = "filter[ids]")] | ||
| public int[]? FilterIds { get; set; } | ||
| [FromQuery(Name = "filter[name]")] | ||
| public string? FilterName { get; set; } | ||
| [FromQuery(Name = "fields")] | ||
| public string? Fields { get; set; } | ||
| [FromQuery(Name = "sort")] | ||
| public string? Sort { get; set; } | ||
| [FromQuery(Name = "pageToken")] | ||
| public string? PageToken { get; set; } | ||
| [FromQuery(Name = "pageSize")] | ||
| public int? PageSize { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| namespace AutoQuery.Abstractions; | ||
|
|
||
| /// <summary> | ||
| /// Query parameters for cursor-based pagination. | ||
| /// </summary> | ||
| public interface IQueryCursorOptions : IQueryOptions | ||
| { | ||
| /// <summary> | ||
| /// Page token representing the cursor position for pagination. | ||
| /// </summary> | ||
| string? PageToken { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Number of items to return. | ||
| /// </summary> | ||
| int? PageSize { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| namespace AutoQuery; | ||
|
|
||
| /// <summary> | ||
| /// Represents a cursor-based paginated result. | ||
| /// </summary> | ||
| /// <typeparam name="TData">The type of data contained in the result set.</typeparam> | ||
| /// <param name="Datas">The data collection of the paginated result, represented as <see cref="IEnumerable{T}"/>.</param> | ||
| /// <param name="NextPageToken">The page token for the next page, or null if there are no more results.</param> | ||
| /// <param name="Count">The number of items in the current result set.</param> | ||
| public record CursorPagedResult<TData>(IEnumerable<TData> Datas, string? NextPageToken, int Count); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 請使用 IQueryable
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已將 Commit: f4282a7 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -158,4 +158,102 @@ public static IQueryable<T> ApplyPaging<T>(this IQueryable<T> query, IQueryPaged | |
|
|
||
| return query; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Applies query conditions and cursor-based pagination. | ||
| /// </summary> | ||
| /// <typeparam name="TData">The type of the entity being queried.</typeparam> | ||
| /// <typeparam name="TQueryOptions">The type of the query options.</typeparam> | ||
| /// <param name="query">The query object.</param> | ||
| /// <param name="queryProcessor">The query processor.</param> | ||
| /// <param name="queryOption">The query options.</param> | ||
| /// <returns>The cursor-based paginated result.</returns> | ||
| public static CursorPagedResult<TData> ApplyQueryCursorPaged<TData, TQueryOptions>( | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 方法命名為 ApplyQueryCursorPagedResult,並將方法順序調整至 ApplyQueryPagedResult 下面
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已重新命名為 Commit: f4282a7 |
||
| this IQueryable<TData> query, | ||
| IQueryProcessor queryProcessor, | ||
| TQueryOptions queryOption) | ||
| where TQueryOptions : IQueryCursorOptions | ||
| where TData : class | ||
| { | ||
| var filterExpression = queryProcessor.BuildFilterExpression<TData, TQueryOptions>(queryOption); | ||
| var selectorExpression = queryProcessor.BuildSelectorExpression<TData, TQueryOptions>(queryOption); | ||
|
|
||
| if (filterExpression != null) | ||
| query = query.Where(filterExpression); | ||
|
|
||
| if (selectorExpression != null) | ||
| query = query.Select(selectorExpression); | ||
|
|
||
| // Get cursor key selector from the query processor | ||
| var cursorKeySelector = queryProcessor.GetCursorKeySelector<TQueryOptions, TData>(); | ||
|
|
||
| if (cursorKeySelector == null) | ||
| throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration."); | ||
|
|
||
| // Apply sorting - cursor pagination requires consistent ordering | ||
| query = query.ApplySort(queryOption); | ||
|
|
||
| // Apply cursor-based filtering if page token is provided | ||
| if (!string.IsNullOrWhiteSpace(queryOption.PageToken)) | ||
| { | ||
| query = ApplyCursorFilter(query, cursorKeySelector, queryOption.PageToken); | ||
| } | ||
|
|
||
| // Fetch one extra item to determine if there are more results | ||
| var pageSize = queryOption.PageSize ?? 10; | ||
| var items = query.Take(pageSize + 1).ToList(); | ||
|
|
||
| // Determine if there are more results and generate next page token | ||
| string? nextPageToken = null; | ||
| var hasMore = items.Count > pageSize; | ||
|
|
||
| if (hasMore) | ||
| { | ||
| items = items.Take(pageSize).ToList(); | ||
| var lastItem = items.Last(); | ||
| var cursorValue = GetCursorValue(lastItem, cursorKeySelector); | ||
| if (cursorValue == null) | ||
| throw new InvalidOperationException($"Cursor key value cannot be null for {typeof(TData).Name}."); | ||
| nextPageToken = PageToken.Encode(cursorValue); | ||
| } | ||
|
|
||
| return new CursorPagedResult<TData>(items, nextPageToken, items.Count); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Applies cursor-based filtering to the query. | ||
| /// </summary> | ||
| private static IQueryable<TData> ApplyCursorFilter<TData>( | ||
| IQueryable<TData> query, | ||
| LambdaExpression cursorKeySelector, | ||
| string pageToken) | ||
| { | ||
| var returnType = ((cursorKeySelector.Body as MemberExpression)?.Type) | ||
| ?? ((cursorKeySelector.Body as UnaryExpression)?.Operand as MemberExpression)?.Type | ||
| ?? typeof(object); | ||
|
|
||
| var decodeMethod = typeof(PageToken).GetMethod(nameof(PageToken.Decode))!.MakeGenericMethod(returnType); | ||
| var cursorValue = decodeMethod.Invoke(null, new object[] { pageToken }); | ||
|
|
||
| if (cursorValue == null) | ||
| throw new InvalidOperationException("Decoded cursor value cannot be null."); | ||
|
|
||
| // Build the filter expression: entity => entity.CursorKey > cursorValue | ||
| var parameter = Expression.Parameter(typeof(TData), "entity"); | ||
| var cursorProperty = Expression.Invoke(cursorKeySelector, parameter); | ||
| var constant = Expression.Constant(cursorValue, returnType); | ||
| var greaterThan = Expression.GreaterThan(cursorProperty, constant); | ||
| var lambda = Expression.Lambda<Func<TData, bool>>(greaterThan, parameter); | ||
|
|
||
| return query.Where(lambda); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the cursor value from an entity. | ||
| /// </summary> | ||
| private static object? GetCursorValue<TData>(TData entity, LambdaExpression cursorKeySelector) | ||
| { | ||
| var compiled = cursorKeySelector.Compile(); | ||
| return compiled.DynamicInvoke(entity); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
移除方法內註解
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
已移除方法內註解。
Commit: f4282a7