Skip to content

Commit 87f0daa

Browse files
Copilotwillysoft
andcommitted
Unify cursor pagination: single async method supporting both IQueryable and IAsyncEnumerable
Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com>
1 parent ad700ee commit 87f0daa

3 files changed

Lines changed: 102 additions & 92 deletions

File tree

sample/AutoQueryApiDemo/Controllers/UsersController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public IActionResult Get(UserQueryOptions queryOptions)
4040
public IActionResult GetWithCursor(UserCursorQueryOptions queryOptions)
4141
{
4242
var result = users.AsQueryable()
43-
.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions);
43+
.ApplyQueryCursorPagedResultAsync(_queryProcessor, queryOptions).Result;
4444
return Ok(result);
4545
}
4646
}

src/AutoQuery/Extensions/QueryExtensions.cs

Lines changed: 86 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -86,36 +86,29 @@ public static PagedResult<TData> ApplyQueryPagedResult<TData, TQueryOptions>(thi
8686
query = query.ApplySort(queryOption).ApplyPaging(queryOption);
8787
return new PagedResult<TData>(query, page, totalPages, count);
8888
}
89-
9089
/// <summary>
91-
/// Applies query conditions and cursor-based pagination.
90+
/// Applies query conditions and cursor-based pagination with support for both IQueryable and IAsyncEnumerable sources.
9291
/// </summary>
9392
/// <remarks>
9493
/// Supports sorting by any field using composite cursors.
9594
/// The page token encodes all sort field values plus the cursor key for accurate pagination.
95+
/// Works with both synchronous (IQueryable) and asynchronous (IAsyncEnumerable) data sources.
9696
/// </remarks>
9797
/// <typeparam name="TData">The type of the entity being queried.</typeparam>
9898
/// <typeparam name="TQueryOptions">The type of the query options.</typeparam>
99-
/// <param name="query">The query object.</param>
99+
/// <param name="source">The queryable or async enumerable source.</param>
100100
/// <param name="queryProcessor">The query processor.</param>
101101
/// <param name="queryOption">The query options.</param>
102+
/// <param name="cancellationToken">Cancellation token for async operations.</param>
102103
/// <returns>The cursor-based paginated result.</returns>
103-
public static CursorPagedResult<TData> ApplyQueryCursorPagedResult<TData, TQueryOptions>(
104-
this IQueryable<TData> query,
105-
IQueryProcessor queryProcessor,
106-
TQueryOptions queryOption)
104+
public static async Task<CursorPagedResult<TData>> ApplyQueryCursorPagedResultAsync<TData, TQueryOptions>(
105+
this object source,
106+
IQueryProcessor queryProcessor,
107+
TQueryOptions queryOption,
108+
CancellationToken cancellationToken = default)
107109
where TQueryOptions : IQueryCursorOptions
108110
where TData : class
109111
{
110-
var filterExpression = queryProcessor.BuildFilterExpression<TData, TQueryOptions>(queryOption);
111-
var selectorExpression = queryProcessor.BuildSelectorExpression<TData, TQueryOptions>(queryOption);
112-
113-
if (filterExpression != null)
114-
query = query.Where(filterExpression);
115-
116-
if (selectorExpression != null)
117-
query = query.Select(selectorExpression);
118-
119112
var cursorKeySelector = queryProcessor.GetCursorKeySelector<TQueryOptions, TData>();
120113
if (cursorKeySelector == null)
121114
throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration.");
@@ -129,89 +122,106 @@ public static CursorPagedResult<TData> ApplyQueryCursorPagedResult<TData, TQuery
129122
sortFields.Add(new SortField { PropertyName = cursorPropertyName, IsDescending = false });
130123
}
131124

132-
query = ApplySort(query, sortFields);
125+
var pageSize = queryOption.PageSize ?? 10;
126+
IQueryable<TData> resultQuery;
127+
string? nextPageToken = null;
128+
int count;
133129

134-
if (!string.IsNullOrWhiteSpace(queryOption.PageToken))
130+
if (source is IQueryable<TData> queryable)
135131
{
136-
query = ApplyCompositeCursorFilter(query, sortFields, queryOption.PageToken);
137-
}
132+
var filterExpression = queryProcessor.BuildFilterExpression<TData, TQueryOptions>(queryOption);
133+
var selectorExpression = queryProcessor.BuildSelectorExpression<TData, TQueryOptions>(queryOption);
134+
135+
if (filterExpression != null)
136+
queryable = queryable.Where(filterExpression);
137+
138+
if (selectorExpression != null)
139+
queryable = queryable.Select(selectorExpression);
138140

139-
var pageSize = queryOption.PageSize ?? 10;
140-
141-
var buffer = new List<TData>(pageSize);
142-
string? nextPageToken = null;
141+
queryable = ApplySort(queryable, sortFields);
142+
143+
if (!string.IsNullOrWhiteSpace(queryOption.PageToken))
144+
{
145+
queryable = ApplyCompositeCursorFilter(queryable, sortFields, queryOption.PageToken);
146+
}
143147

144-
using (var enumerator = query.Take(pageSize + 1).GetEnumerator())
148+
resultQuery = queryable.Take(pageSize);
149+
150+
var buffer = new List<TData>(pageSize);
151+
using (var enumerator = queryable.Take(pageSize + 1).GetEnumerator())
152+
{
153+
while (buffer.Count < pageSize && enumerator.MoveNext())
154+
{
155+
buffer.Add(enumerator.Current);
156+
}
157+
158+
if (enumerator.MoveNext())
159+
{
160+
nextPageToken = CreateCompositeCursorToken(buffer[^1], sortFields);
161+
}
162+
}
163+
164+
count = buffer.Count;
165+
}
166+
else if (source is IAsyncEnumerable<TData> asyncEnumerable)
145167
{
146-
while (buffer.Count < pageSize && enumerator.MoveNext())
168+
if (!string.IsNullOrWhiteSpace(queryOption.PageToken))
147169
{
148-
buffer.Add(enumerator.Current);
170+
var cursorValues = PageToken.DecodeComposite(queryOption.PageToken);
171+
asyncEnumerable = ApplyCompositeCursorFilterAsync(asyncEnumerable, sortFields, cursorValues);
149172
}
150173

151-
if (enumerator.MoveNext())
174+
var buffer = new List<TData>(pageSize);
175+
await foreach (var item in asyncEnumerable.WithCancellation(cancellationToken))
152176
{
153-
nextPageToken = CreateCompositeCursorToken(buffer[^1], sortFields);
177+
if (buffer.Count < pageSize)
178+
{
179+
buffer.Add(item);
180+
}
181+
else
182+
{
183+
nextPageToken = CreateCompositeCursorToken(buffer[^1], sortFields);
184+
break;
185+
}
154186
}
187+
188+
count = buffer.Count;
189+
resultQuery = buffer.Take(pageSize).AsQueryable();
190+
}
191+
else
192+
{
193+
throw new ArgumentException($"Source must be IQueryable<{typeof(TData).Name}> or IAsyncEnumerable<{typeof(TData).Name}>", nameof(source));
155194
}
156195

157-
return new CursorPagedResult<TData>(buffer.AsQueryable(), nextPageToken, buffer.Count);
196+
return new CursorPagedResult<TData>(resultQuery, nextPageToken, count);
158197
}
159198

160199
/// <summary>
161-
/// Applies cursor-based pagination to the async query results.
200+
/// Applies cursor-based pagination to an IQueryable source.
162201
/// </summary>
163-
/// <typeparam name="TData">The element type.</typeparam>
164-
/// <typeparam name="TQueryOptions">The query options type.</typeparam>
165-
/// <param name="query">The async enumerable query.</param>
166-
/// <param name="queryProcessor">The query processor.</param>
167-
/// <param name="queryOption">The query options.</param>
168-
/// <param name="cancellationToken">Cancellation token.</param>
169-
/// <returns>The cursor-based paginated result.</returns>
170-
public static async Task<CursorPagedResult<TData>> ApplyQueryCursorPagedResultAsync<TData, TQueryOptions>(
171-
this IAsyncEnumerable<TData> query,
202+
public static Task<CursorPagedResult<TData>> ApplyQueryCursorPagedResultAsync<TData, TQueryOptions>(
203+
this IQueryable<TData> query,
172204
IQueryProcessor queryProcessor,
173205
TQueryOptions queryOption,
174206
CancellationToken cancellationToken = default)
175207
where TQueryOptions : IQueryCursorOptions
176208
where TData : class
177209
{
178-
var cursorKeySelector = queryProcessor.GetCursorKeySelector<TQueryOptions, TData>();
179-
if (cursorKeySelector == null)
180-
throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration.");
181-
182-
var sortFields = ParseSortFields(queryOption.Sort);
183-
var cursorPropertyName = GetPropertyName(cursorKeySelector);
184-
185-
if (!string.IsNullOrEmpty(cursorPropertyName) &&
186-
!sortFields.Any(sf => string.Equals(sf.PropertyName, cursorPropertyName, StringComparison.OrdinalIgnoreCase)))
187-
{
188-
sortFields.Add(new SortField { PropertyName = cursorPropertyName, IsDescending = false });
189-
}
190-
191-
if (!string.IsNullOrWhiteSpace(queryOption.PageToken))
192-
{
193-
var cursorValues = PageToken.DecodeComposite(queryOption.PageToken);
194-
query = ApplyCompositeCursorFilterAsync(query, sortFields, cursorValues);
195-
}
196-
197-
var pageSize = queryOption.PageSize ?? 10;
198-
var buffer = new List<TData>(pageSize);
199-
string? nextPageToken = null;
200-
201-
await foreach (var item in query.WithCancellation(cancellationToken))
202-
{
203-
if (buffer.Count < pageSize)
204-
{
205-
buffer.Add(item);
206-
}
207-
else
208-
{
209-
nextPageToken = CreateCompositeCursorToken(buffer[^1], sortFields);
210-
break;
211-
}
212-
}
210+
return ApplyQueryCursorPagedResultAsync<TData, TQueryOptions>((object)query, queryProcessor, queryOption, cancellationToken);
211+
}
213212

214-
return new CursorPagedResult<TData>(buffer.AsQueryable(), nextPageToken, buffer.Count);
213+
/// <summary>
214+
/// Applies cursor-based pagination to an IAsyncEnumerable source.
215+
/// </summary>
216+
public static Task<CursorPagedResult<TData>> ApplyQueryCursorPagedResultAsync<TData, TQueryOptions>(
217+
this IAsyncEnumerable<TData> query,
218+
IQueryProcessor queryProcessor,
219+
TQueryOptions queryOption,
220+
CancellationToken cancellationToken = default)
221+
where TQueryOptions : IQueryCursorOptions
222+
where TData : class
223+
{
224+
return ApplyQueryCursorPagedResultAsync<TData, TQueryOptions>((object)query, queryProcessor, queryOption, cancellationToken);
215225
}
216226

217227
private static async IAsyncEnumerable<TData> ApplyCompositeCursorFilterAsync<TData>(

test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ public void ApplyQueryCursorPagedResult_ShouldReturnFirstPage_WhenNoTokenProvide
445445
var queryOptions = new TestCursorQueryOptions { PageSize = 2 };
446446

447447
// Act
448-
var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions);
448+
var result = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, queryOptions).Result;
449449

450450
// Assert
451451
Assert.Equal(2, result.Count);
@@ -458,7 +458,7 @@ public void ApplyQueryCursorPagedResult_ShouldReturnNextPage_WhenTokenProvided()
458458
{
459459
// Arrange
460460
var firstPageOptions = new TestCursorQueryOptions { PageSize = 2 };
461-
var firstPageResult = _testData.ApplyQueryCursorPagedResult(_queryProcessor, firstPageOptions);
461+
var firstPageResult = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, firstPageOptions).Result;
462462

463463
var secondPageOptions = new TestCursorQueryOptions
464464
{
@@ -467,7 +467,7 @@ public void ApplyQueryCursorPagedResult_ShouldReturnNextPage_WhenTokenProvided()
467467
};
468468

469469
// Act
470-
var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, secondPageOptions);
470+
var result = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, secondPageOptions).Result;
471471

472472
// Assert
473473
Assert.Equal(2, result.Count);
@@ -481,15 +481,15 @@ public void ApplyQueryCursorPagedResult_ShouldReturnNullNextToken_WhenNoMoreResu
481481
var queryOptions = new TestCursorQueryOptions { PageSize = 10 };
482482

483483
// Act
484-
var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions);
484+
var result = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, queryOptions).Result;
485485

486486
// Assert
487487
Assert.Equal(5, result.Count);
488488
Assert.Null(result.NextPageToken);
489489
}
490490

491491
[Fact]
492-
public void ApplyQueryCursorPagedResult_ShouldThrowException_WhenCursorKeyNotConfigured()
492+
public async Task ApplyQueryCursorPagedResult_ShouldThrowException_WhenCursorKeyNotConfigured()
493493
{
494494
// Arrange
495495
var queryProcessor = new QueryProcessor();
@@ -500,8 +500,8 @@ public void ApplyQueryCursorPagedResult_ShouldThrowException_WhenCursorKeyNotCon
500500
var queryOptions = new TestCursorQueryOptions { PageSize = 2 };
501501

502502
// Act & Assert
503-
Assert.Throws<InvalidOperationException>(() =>
504-
_testData.ApplyQueryCursorPagedResult(queryProcessor, queryOptions));
503+
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
504+
await _testData.ApplyQueryCursorPagedResultAsync(queryProcessor, queryOptions));
505505
}
506506

507507
[Fact]
@@ -515,7 +515,7 @@ public void ApplyQueryCursorPagedResult_ShouldApplyFilters_WithCursorPagination(
515515
};
516516

517517
// Act
518-
var result = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions);
518+
var result = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, queryOptions).Result;
519519

520520
// Assert
521521
Assert.Single(result.Datas);
@@ -534,14 +534,14 @@ public void ApplyQueryCursorPagedResult_ShouldWorkWithCustomSorting()
534534
};
535535

536536
// Act
537-
var firstPage = _testData.ApplyQueryCursorPagedResult(_queryProcessor, queryOptions);
537+
var firstPage = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, queryOptions).Result;
538538
var secondPageOptions = new TestCursorQueryOptions
539539
{
540540
PageSize = 2,
541541
Sort = "id",
542542
PageToken = firstPage.NextPageToken
543543
};
544-
var secondPage = _testData.ApplyQueryCursorPagedResult(_queryProcessor, secondPageOptions);
544+
var secondPage = _testData.ApplyQueryCursorPagedResultAsync(_queryProcessor, secondPageOptions).Result;
545545

546546
// Assert
547547
Assert.Equal(2, firstPage.Count);
@@ -569,7 +569,7 @@ public void ApplyQueryCursorPagedResult_WithLongCursorKey()
569569
var queryOptions = new TestCursorQueryOptionsLong { PageSize = 2 };
570570

571571
// Act
572-
var result = testData.ApplyQueryCursorPagedResult(queryProcessor, queryOptions);
572+
var result = testData.ApplyQueryCursorPagedResultAsync(queryProcessor, queryOptions).Result;
573573

574574
// Assert
575575
Assert.Equal(2, result.Count);
@@ -595,7 +595,7 @@ public void ApplyQueryCursorPagedResult_WithStringCursorKey()
595595
var queryOptions = new TestCursorQueryOptionsString { PageSize = 2, Sort = "code" };
596596

597597
// Act
598-
var result = testData.ApplyQueryCursorPagedResult(queryProcessor, queryOptions);
598+
var result = testData.ApplyQueryCursorPagedResultAsync(queryProcessor, queryOptions).Result;
599599

600600
// Assert
601601
Assert.Equal(2, result.Count);
@@ -623,7 +623,7 @@ public void ApplyQueryCursorPagedResult_WithDescendingSort()
623623
var firstPageOptions = new TestCursorQueryOptions { PageSize = 2, Sort = "-id" };
624624

625625
// Act - First page
626-
var firstPage = testData.ApplyQueryCursorPagedResult(queryProcessor, firstPageOptions);
626+
var firstPage = testData.ApplyQueryCursorPagedResultAsync(queryProcessor, firstPageOptions).Result;
627627

628628
// Assert - First page should have items 5 and 4 (descending order)
629629
Assert.Equal(2, firstPage.Count);
@@ -638,7 +638,7 @@ public void ApplyQueryCursorPagedResult_WithDescendingSort()
638638
Sort = "-id",
639639
PageToken = firstPage.NextPageToken
640640
};
641-
var secondPage = testData.ApplyQueryCursorPagedResult(queryProcessor, secondPageOptions);
641+
var secondPage = testData.ApplyQueryCursorPagedResultAsync(queryProcessor, secondPageOptions).Result;
642642

643643
// Assert - Second page should have items 3 and 2 (descending order)
644644
Assert.Equal(2, secondPage.Count);
@@ -653,7 +653,7 @@ public void ApplyQueryCursorPagedResult_WithDescendingSort()
653653
Sort = "-id",
654654
PageToken = secondPage.NextPageToken
655655
};
656-
var thirdPage = testData.ApplyQueryCursorPagedResult(queryProcessor, thirdPageOptions);
656+
var thirdPage = testData.ApplyQueryCursorPagedResultAsync(queryProcessor, thirdPageOptions).Result;
657657

658658
// Assert - Third page should have item 1 only
659659
Assert.Equal(1, thirdPage.Count);

0 commit comments

Comments
 (0)