feat: EntityPagination — lazily loaded paginated view over a select (v1.12.0) - #146
Merged
Conversation
A handler that keeps the pages it has already loaded, over a select that does not know its total length upfront. - Pages are 1-based (matching the `page` parameter of the `select*` methods); entry indexes are 0-based (matching a Dart `List`). - Pages can be loaded out of order, leaving gaps: `getAt(45)` with a limit of 20 loads only page 3. - Synchronous access (`operator []`, `loadedEntities`) never fetches; only the `FutureOr` methods do. `operator []` returns null for a gap, an unloaded page or out of range alike. - Deliberately NOT a `List`/`Iterable`: both require a `length`, which is exactly what a paginated select can't answer until it reaches the end. A lying `length` would silently break every `for`/`map`/`toList`. Since every page but the last holds exactly `limit` entries, identifying the final page yields the total even with gaps: `totalLength == (finalPage - 1) * limit + entries(finalPage)`. The end resolves when a page comes back short, when an empty page has a loaded full predecessor, or when page 1 is empty. An empty page *without* a loaded predecessor does NOT resolve it -- jumping to page 50 of a 3-page result only proves the end is somewhere before 50 -- but it is still recorded to avoid re-fetching that page or any after it. Concurrent requests for the same page share one fetch, and a failed load is evicted so a retry actually retries. Tests: 38 unit tests over a fake page loader, covering the full state machine -- sparse gaps, all three end-resolution rules, the jump-past-the-end case, dedupe, failure eviction, reset/refresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds an `EntityPagination` over the existing select path, on `EntitySource`, `EntityRepository` (adding `resolutionRules`, mirroring how `selectByQuery` is split) and the `APIRepository` facade. Each page is `selectByQuery(..., limit: limit, page: page)`, so it rides entirely on the pagination shipped in 1.11.0. `orderByID` defaults to `true` rather than following the `offset != null` rule: a paginated read is only meaningful over a stable order, so it should not be opt-in here. Also fixes `loadAll()`, caught by the integration test: it returned as soon as the end was resolved, so a sparse `getAt` that had already resolved the final page left its gap unfilled and `loadedEntities` was missing entries. It now walks from page 1, skipping already-loaded pages without re-fetching them (and without spending the `maxPages` budget), so afterwards the result is complete and gap-free. Tests: an `EntityPagination: lazy paged access` test in the shared adapter template -- lazy start, page 1, a jump to page 3 leaving a gap, end resolution from a short page, `loadAll` filling the gap, `getRange`, descending, streaming, and an empty query. Runs against the in-memory, PostgreSQL, MySQL and object-directory adapters; PostgreSQL and MySQL verified on real containers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents `EntityPagination` and the `paginate*` entry points: the 1-based page / 0-based index split, the sparse gap behaviour, why synchronous access never fetches, why it is deliberately not a `List`, how the end (and therefore the total) is resolved, and the consistency caveat of offset-based pagination across independent page loads. Minor bump: purely additive. No existing signature or behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #146 +/- ##
==========================================
+ Coverage 67.49% 67.75% +0.25%
==========================================
Files 63 64 +1
Lines 21347 21544 +197
==========================================
+ Hits 14409 14597 +188
- Misses 6938 6947 +9
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
1.11.0 added
limit/offset/pageto theselect*methods, but using them still means the caller tracks which pages it has fetched, stitches them together, and works out where the end is. This adds a handler that owns that state.EntityPagination<O>is a lazily loaded, paginated view over a select: it keeps the pages it has already loaded, exposes the loaded entries like a list, and reports what it knows about the end — without ever needing the total upfront.Design
pageparameter ofselect*), entry indexes 0-based (matching a DartList).indexOfPage/pageOfIndexconvert.getAt(45)withlimit: 20fetches only page 3.operator []andloadedEntitiesnever fetch.operator []returnsnullfor a gap, an unloaded page, or out of range alike.FutureOrmethods fetch:getAt,getPage,getRange,loadNextPage,loadPage,loadAll,stream.orderByIDdefaults totrueinpaginate*, not theoffset != nullrule — a paginated read is only meaningful over a stable order.Why it is not a
ListDeliberately not a
ListorIterable. Both require alength, which is exactly what a paginated select cannot answer until it reaches the end. Any implementation would have to lie or throw, and thenfor (var i = 0; i < p.length; i++),p.map(...),p.toList()all silently do the wrong thing on a partially loaded handler.operator []gives list-like access;loadAll()gives a complete list when one is genuinely needed.Resolving the end
Every page except the last holds exactly
limitentries, so identifying the final page yields the total — even with gaps:1..limit-1entriesp * limitlimitAn empty page without a loaded, full predecessor does not resolve the end — jumping to page 50 of a 3-page result only proves the end is somewhere before 50. It is still recorded, so that page and every page after it are never re-fetched.
What it exposes
loadedPages,loadedPagesLength,loadedEntities,loadedEntitiesLength,maxLoadedPage,maxLoadedIndex,maxKnownPage,isFinalPageResolved,finalPage,totalLength,isKnownEmpty,isIndexKnownOutOfRange,isPageLoaded,isIndexLoaded, plusreset(),refresh()andinformation().Concurrent requests for the same page share a single fetch (same pattern
SchemeProvideruses for concurrent scheme resolution), and a failed load is evicted so a retry actually retries.A bug the integration test caught
loadAll()originally returned as soon as the end was resolved. But a sparsegetAtcan resolve the final page while leaving a gap behind — soloadAll()would return a "complete" result that was silently missing entries. It now walks from page 1, skipping already-loaded pages without re-fetching them (and without spending themaxPagesbudget), so afterwards the result really is complete and gap-free.Commits
7bd5695EntityPaginationcore + 38 unit testsb13a8fapaginateByQuery/paginate/paginateAllentry points + integration tests11b801cTesting
805 VM tests + 505 Chrome tests pass, with the Docker daemon running so the PostgreSQL and MySQL suites actually executed rather than self-skipping.
bones_api_entity_pagination_test.dart— 38 unit tests over a fake page loader that records every fetch, covering the whole state machine: sparse gaps, all three end-resolution rules, the jump-past-the-end case, fetch dedupe, failure eviction,reset/refresh,maxPages.EntityPagination: lazy paged accessin the shared adapter template — lazy start, page 1, a jump leaving a gap, end resolution,loadAllfilling the gap,getRange, descending, streaming, empty query. Runs against the in-memory, PostgreSQL, MySQL and object-directory adapters.bones_api_entity_db_sql_select_test.dart— the same over the real memory SQL select path, pluspaginate()andpaginateAll().dart format,dart analyze --fatal-infos --fatal-warnings,dart pub publish --dry-runandensure_build_testare clean.Backward compatibility
Purely additive — no existing signature or behaviour changes. Hence the minor bump.
Known limitation (documented, not fixed)
Each page is an independent select, without a shared
Transaction. Entries inserted or deleted between two page loads shift the offsets, so a later page can repeat or skip entries. That is inherent to offset-based pagination; ordering by ID makes it as stable as it can be. A true snapshot would need keyset pagination (WHERE id > :lastId), which would be a separate feature.🤖 Generated with Claude Code