Pagination
Inquiry supports two paging modes: offset pagination (LIMIT/OFFSET) and keyset pagination (also called cursor or seek paging). Both are opt-in via attribute named arguments.
Offset pagination
Add Paged = true to any [InquirySelectAll] or [InquirySelectAllByField] method, plus OrderBy = "..." for deterministic ordering. The method picks up two extra parameters: int offset, int limit.
You write
public partial class ProductStore : InquiryStore<Product>
{
[InquirySelectAll(OrderBy = "ProductName ASC", Paged = true)]
public partial Task<IReadOnlyList<Product>> SelectPagedAsync(
int offset,
int limit,
CancellationToken cancellationToken = default);
}
The generator emits
private const string _sqlSelectAllPaged = "SELECT \"ProductID\", \"ProductName\", ... FROM \"Products\" ORDER BY \"ProductName\" ASC LIMIT @__limit OFFSET @__offset";
public partial Task<IReadOnlyList<Product>> SelectPagedAsync(int offset, int limit, CancellationToken cancellationToken)
=> Inquiry.QueryListAsync<Product, (int, int), ProductInquiryEntityStructMaterializer>(
_sqlSelectAllPaged,
(offset, limit),
static (_cmd, _args) =>
{
var _p0 = _cmd.CreateParameter();
_p0.ParameterName = "@__offset";
_p0.DbType = global::System.Data.DbType.Int32;
_p0.Value = _args.Item1;
_cmd.Parameters.Add(_p0);
var _p1 = _cmd.CreateParameter();
_p1.ParameterName = "@__limit";
_p1.DbType = global::System.Data.DbType.Int32;
_p1.Value = _args.Item2;
_cmd.Parameters.Add(_p1);
},
default,
cancellationToken);
Per-dialect SQL
| Dialect | Paging clause |
|---|---|
| Sqlite / PostgreSQL / MySQL | LIMIT @__limit OFFSET @__offset |
| SQL Server | OFFSET @__offset ROWS FETCH NEXT @__limit ROWS ONLY |
| Oracle | OFFSET @__offset ROWS FETCH NEXT @__limit ROWS ONLY (12c+) |
Generated offset methods validate their arguments before touching the database: offset must be >= 0 and limit must be > 0, otherwise the method throws ArgumentOutOfRangeException.
Keyset pagination
Offset paging gets slower as you scroll deeper — the database has to skip N rows. Keyset paging is O(log n) regardless of page depth: you remember the last row's sort key(s) and ask for what's after them.
Use [InquiryKeysetPage("KeyColumn1", "KeyColumn2", …)]. The method takes a single nullable cursor parameter — the previous page's last key, a value for one key column or a tuple for several — plus int pageSize, and returns InquiryPage<TEntity, TCursor> (the page items, the next cursor, and HasMore). Pass null for the first page.
You write
public partial class OrderStore : InquiryStore<Order>
{
[InquiryKeysetPage("OrderDate", "OrderID")]
public partial Task<InquiryPage<Order, (DateTime, int)>> NextPageAsync(
(DateTime, int)? after,
int pageSize,
CancellationToken cancellationToken = default);
}
The keyset is (OrderDate, OrderID) in significance order — OrderID is the tiebreaker for orders on the same date. Including a unique column in the keyset is required for stable paging.
The generator emits
Keyset paging is emitted as two baked queries on purpose — a predicate-free first-page query and a sargable seek query:
// First page (cursor is null): no WHERE, just ORDER BY + limit.
private const string _sqlNextPage_first =
"SELECT \"OrderID\", \"CustomerID\", \"OrderDate\", ... FROM \"Orders\" " +
"ORDER BY \"OrderDate\" ASC, \"OrderID\" ASC LIMIT @__pageSize";
// Seek (cursor supplied): a plain, sargable comparison so the engine does an index seek.
// The portable dialects (SQLite/PostgreSQL/MySQL) emit a row-value comparison:
private const string _sqlNextPage =
"SELECT \"OrderID\", \"CustomerID\", \"OrderDate\", ... FROM \"Orders\" " +
"WHERE (\"OrderDate\", \"OrderID\") > (@__cursor0, @__cursor1) " +
"ORDER BY \"OrderDate\" ASC, \"OrderID\" ASC LIMIT @__pageSize";
public partial async Task<InquiryPage<Order, (DateTime, int)>> NextPageAsync(
(DateTime, int)? after, int pageSize, CancellationToken cancellationToken)
{
if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be > 0.");
if (pageSize == int.MaxValue) throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be less than int.MaxValue.");
// Runs the first-page query when `after` is null, the seek query otherwise (binding the cursor
// only on the seek path). Over-fetches pageSize + 1 rows to compute HasMore and the next cursor.
// ...
}
The two-query split matters for performance: the seek query uses a plain key > @cursor predicate so the engine can use an index seek, whereas a single (@cursor IS NULL OR key > @cursor) form is non-sargable and forces a full scan. For a multi-column keyset the portable dialects (SQLite, PostgreSQL, MySQL) emit a row-value comparison (a, b) > (@a, @b); SQL Server and Oracle, which lack row-value >, render the equivalent cascading (a > @a) OR (a = @a AND b > @b) form instead.
Walking backward
Use Direction = KeysetDirection.Backward to walk in descending order:
[InquiryKeysetPage("OrderDate", "OrderID", Direction = KeysetDirection.Backward)]
public partial Task<InquiryPage<Order, (DateTime, int)>> PreviousPageAsync(
(DateTime, int)? after, int pageSize, CancellationToken cancellationToken = default);
The generator flips the comparator and the ORDER BY: WHERE … < … ORDER BY … DESC. The result is naturally in descending order — reverse it client-side if you want oldest-first display.
When to use which
| Use offset paging when… | Use keyset paging when… |
|---|---|
| Showing "page 5 of 27" with a page-number UI | Infinite scroll, "next 50" / "previous 50" buttons |
| Result set is small (a few hundred rows total) | Result set is large (10k+) or grows over time |
| Skipping is acceptable performance | You need consistent latency on every page |
| Random page jumps are common | Forward/backward traversal is the access pattern |
Keyset is dramatically faster on large tables because the database can use an index seek to find the starting row instead of counting through OFFSET rows.
See also
- CRUD — the baseline
[InquirySelectAll]without paging. - Projections — return a column-subset DTO instead of the full entity.