Result Ordering & Pagination
Sorted, paged query results driven directly off a B+Tree index scan — no full-scan-then-sort.
Status: ✅ Implemented · Visibility: Public · Level: 🔵 Core · Category: Querying
🎯 What it solves
Leaderboards, inventory lists, and any paginated UI need results in a specific order, a small slice of which is shown at a time. Scanning every match, sorting it in memory, then slicing off a page wastes I/O and CPU on rows the caller throws away — and gets worse as the table grows. Result Ordering & Pagination instead walks the indexed field in sorted order and stops once the requested page is filled, so a "page 3 of 50,000 leaderboard entries" query touches roughly what it returns, not the whole table.
⚙️ How it works (in brief)
OrderByField/OrderByFieldDescending designate an indexed field on the component identified by a preceding WhereField call; when it's the same field the predicate narrowed, the planner reuses that already-narrowed index range as the iteration order directly — no separate sort step. Skip/Take bound how much of that ordered stream is materialized. ExecuteOrdered() is the only terminal that honors this ordering, returning a List<EntityId>; Execute(), Count(), and Any() ignore OrderByField/Skip/Take entirely. Queries spanning a polymorphic archetype subtree still come back as one globally ordered list — matching archetypes are merged transparently.
💻 Usage
// Page 3 of a Level-descending leaderboard (entries 21-30)
var page = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level >= 50)
.OrderByFieldDescending<Player, int>(p => p.Level)
.Skip(20)
.Take(10)
.ExecuteOrdered(); // → List<EntityId>, Level descending
foreach (var id in page)
{
var entity = tx.Open(id);
if (entity.TryRead<Player>(out var player))
{
Render(player);
}
}
// Unconditional sort: WhereField still required to identify the component table —
// an always-true comparison on the order field works fine.
var allByLevel = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level >= int.MinValue)
.OrderByField<Player, int>(p => p.Level)
.ExecuteOrdered();
| Method | Requires | Effect |
|---|---|---|
OrderByField<T,TKey> |
preceding WhereField<T> |
Ascending iteration order on an indexed field of T |
OrderByFieldDescending<T,TKey> |
preceding WhereField<T> |
Descending iteration order |
Skip(n) |
preceding OrderByField/OrderByFieldDescending |
Drops the first n ordered results |
Take(n) |
preceding OrderByField/OrderByFieldDescending |
Caps the result count at n |
ExecuteOrdered() |
OrderByField + WhereField |
Materializes the ordered, paged List<EntityId> |
⚠️ Guarantees & limits
WhereFieldis mandatory — it identifies the component tableOrderByFieldresolves against; callingOrderByField,Skip, orTakewithout it throwsInvalidOperationException.- Order field must be indexed —
OrderByField/OrderByFieldDescendingreject non-indexed fields at call time, not at execution. Skip/Takerequire an order first — paging an unordered result set would have no defined meaning, so both throwInvalidOperationExceptionifOrderByFieldhasn't been set.- Not supported on
ToView()— Views are unordered live sets; chainingOrderByField/Skip/Takebefore.ToView()throws. Re-runExecuteOrdered()per refresh cycle for "paged + live" use cases. - Not supported with OR predicates — a
WhereFieldwith||produces multiple independently-planned branches; merging them into one global order isn't implemented. Restructure as a single AND predicate, or run one ordered query per branch. - Not supported with spatial predicates or
.Where(lambda)—WhereNearby/WhereInAABB/WhereRayand the opaque post-filter are both unordered scans; sort the result client-side instead. Count()/Any()ignoreSkip/Take— they report the full match count / existence regardless of paging; useExecuteOrdered().Countfor a paged count.- No chained
.Where(lambda)— fold any extra condition into theWhereFieldexpression;ExecuteOrdered()throws if an opaque filter is attached.
🧪 Tests
- EcsQueryTargetedScanTests — ascending/descending
ExecuteOrdered,Skip/Takewindowing, and theOrderBy/Skip-without-prerequisite guard throws - SimdKwayMergeTests — k-way merge across a polymorphic archetype subtree stays globally ordered under
Skip/Take
🔗 Related
- Sibling: Indexed Field Predicates (WhereField) —
OrderByFieldrequires a precedingWhereFieldto identify the component table - Sibling: Execution Planning & Pipeline Execution —
OrderByFieldpins the primary scan, overriding pure selectivity-driven choice