From 6138eda9b387ac4c8b49a4f9485964173727b02c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 28 Jan 2026 14:21:55 +0000
Subject: [PATCH 1/6] Initial plan
From a3eb233f655a4930dcff7a1ed23484104e5e0653 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 28 Jan 2026 14:26:04 +0000
Subject: [PATCH 2/6] Add cursor-based pagination infrastructure with
HasCursorKey builder method
Co-authored-by: willysoft <63505597+willysoft@users.noreply.github.com>
---
.../Abstractions/IQueryCursorOptions.cs | 17 +++
src/AutoQuery/Abstractions/IQueryProcessor.cs | 9 +-
src/AutoQuery/CursorPagedResult.cs | 10 ++
src/AutoQuery/Extensions/QueryExtensions.cs | 101 ++++++++++++++++++
src/AutoQuery/FilterQueryBuilder.cs | 22 ++++
src/AutoQuery/PageToken.cs | 50 +++++++++
src/AutoQuery/QueryProcessor.cs | 12 +++
7 files changed, 220 insertions(+), 1 deletion(-)
create mode 100644 src/AutoQuery/Abstractions/IQueryCursorOptions.cs
create mode 100644 src/AutoQuery/CursorPagedResult.cs
create mode 100644 src/AutoQuery/PageToken.cs
diff --git a/src/AutoQuery/Abstractions/IQueryCursorOptions.cs b/src/AutoQuery/Abstractions/IQueryCursorOptions.cs
new file mode 100644
index 0000000..1a86c83
--- /dev/null
+++ b/src/AutoQuery/Abstractions/IQueryCursorOptions.cs
@@ -0,0 +1,17 @@
+namespace AutoQuery.Abstractions;
+
+///
+/// Query parameters for cursor-based pagination.
+///
+public interface IQueryCursorOptions : IQueryOptions
+{
+ ///
+ /// Page token representing the cursor position for pagination.
+ ///
+ string? PageToken { get; set; }
+
+ ///
+ /// Number of items to return.
+ ///
+ int? PageSize { get; set; }
+}
diff --git a/src/AutoQuery/Abstractions/IQueryProcessor.cs b/src/AutoQuery/Abstractions/IQueryProcessor.cs
index b588233..e23e238 100644
--- a/src/AutoQuery/Abstractions/IQueryProcessor.cs
+++ b/src/AutoQuery/Abstractions/IQueryProcessor.cs
@@ -26,5 +26,12 @@ public interface IQueryProcessor
/// The selector expression, or null if no selection conditions exist.
Expression>? BuildSelectorExpression(TQueryOptions queryOptions)
where TQueryOptions : IQueryOptions;
-}
+ ///
+ /// Gets the cursor key selector for cursor-based pagination.
+ ///
+ /// The type of the query options.
+ /// The type of the data.
+ /// The cursor key selector expression, or null if not configured.
+ LambdaExpression? GetCursorKeySelector();
+}
diff --git a/src/AutoQuery/CursorPagedResult.cs b/src/AutoQuery/CursorPagedResult.cs
new file mode 100644
index 0000000..4607bc5
--- /dev/null
+++ b/src/AutoQuery/CursorPagedResult.cs
@@ -0,0 +1,10 @@
+namespace AutoQuery;
+
+///
+/// Represents a cursor-based paginated result.
+///
+/// The type of data contained in the result set.
+/// The data collection of the paginated result, represented as .
+/// The page token for the next page, or null if there are no more results.
+/// The number of items in the current result set.
+public record CursorPagedResult(IQueryable Datas, string? NextPageToken, int Count);
diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs
index 3991f21..ff85332 100644
--- a/src/AutoQuery/Extensions/QueryExtensions.cs
+++ b/src/AutoQuery/Extensions/QueryExtensions.cs
@@ -158,4 +158,105 @@ public static IQueryable ApplyPaging(this IQueryable query, IQueryPaged
return query;
}
+
+ ///
+ /// Applies query conditions and cursor-based pagination.
+ ///
+ /// The type of the entity being queried.
+ /// The type of the query options.
+ /// The query object.
+ /// The query processor.
+ /// The query options.
+ /// The cursor-based paginated result.
+ public static CursorPagedResult ApplyQueryCursorPaged(
+ this IQueryable query,
+ IQueryProcessor queryProcessor,
+ TQueryOptions queryOption)
+ where TQueryOptions : IQueryCursorOptions
+ where TData : class
+ {
+ var filterExpression = queryProcessor.BuildFilterExpression(queryOption);
+ var selectorExpression = queryProcessor.BuildSelectorExpression(queryOption);
+
+ if (filterExpression != null)
+ query = query.Where(filterExpression);
+
+ if (selectorExpression != null)
+ query = query.Select(selectorExpression);
+
+ query = query.ApplySort(queryOption);
+
+ // Get cursor key selector from the query processor
+ var cursorKeySelector = queryProcessor.GetCursorKeySelector();
+
+ if (cursorKeySelector == null)
+ throw new InvalidOperationException($"Cursor key selector not configured for {typeof(TData).Name}. Use HasCursorKey() in your configuration.");
+
+ // 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);
+ nextPageToken = PageToken.Encode(cursorValue);
+ }
+
+ return new CursorPagedResult(items.AsQueryable(), nextPageToken, items.Count);
+ }
+
+ ///
+ /// Applies cursor-based filtering to the query.
+ ///
+ private static IQueryable ApplyCursorFilter(
+ IQueryable query,
+ LambdaExpression cursorKeySelector,
+ string pageToken)
+ {
+ // Decode the cursor value
+ var cursorKeySelectorTyped = (Expression>)Expression.Lambda(
+ Expression.Convert(cursorKeySelector.Body, typeof(object)),
+ cursorKeySelector.Parameters[0]
+ );
+
+ 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)
+ return query;
+
+ // 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>(greaterThan, parameter);
+
+ return query.Where(lambda);
+ }
+
+ ///
+ /// Gets the cursor value from an entity.
+ ///
+ private static object GetCursorValue(TData entity, LambdaExpression cursorKeySelector)
+ {
+ var compiled = cursorKeySelector.Compile();
+ return compiled.DynamicInvoke(entity)!;
+ }
}
diff --git a/src/AutoQuery/FilterQueryBuilder.cs b/src/AutoQuery/FilterQueryBuilder.cs
index f4a1b27..ee2ce05 100644
--- a/src/AutoQuery/FilterQueryBuilder.cs
+++ b/src/AutoQuery/FilterQueryBuilder.cs
@@ -16,6 +16,7 @@ public class FilterQueryBuilder
private readonly ConcurrentDictionary<(Type BuilderType, Type QueryPropertyType), Func