Skip to content

feat: EntityPagination — lazily loaded paginated view over a select (v1.12.0) - #146

Merged
gmpassos merged 3 commits into
masterfrom
feat/entity-pagination
Aug 1, 2026
Merged

feat: EntityPagination — lazily loaded paginated view over a select (v1.12.0)#146
gmpassos merged 3 commits into
masterfrom
feat/entity-pagination

Conversation

@gmpassos

@gmpassos gmpassos commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Context

1.11.0 added limit/offset/page to the select* 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.

var page = accountRepository.paginateByQuery(
  ' address.state == ? ', parameters: ['NY'], limit: 20);

await page.loadNextPage();   // page 1
page[0];                     // synchronous: already loaded
page[45];                    // null — not loaded, and never fetches

await page.getAt(45);        // loads page 3 on demand
page.loadedPages;            // [1, 3] — page 2 is a gap
page.maxLoadedIndex;         // 59
page.totalLength;            // null — the end is not known yet

await page.loadAll();        // fills the gaps and resolves the end
page.totalLength;            // 57
page.finalPage;              // 3

Design

Decision Behaviour
Numbering Pages 1-based (matching the page parameter of select*), entry indexes 0-based (matching a Dart List). indexOfPage / pageOfIndex convert.
Sparse loading Pages may be loaded out of order, leaving gaps. getAt(45) with limit: 20 fetches only page 3.
Sync access operator [] and loadedEntities never fetch. operator [] returns null for a gap, an unloaded page, or out of range alike.
Load trigger Only the FutureOr methods fetch: getAt, getPage, getRange, loadNextPage, loadPage, loadAll, stream.
Ordering orderByID defaults to true in paginate*, not the offset != null rule — a paginated read is only meaningful over a stable order.

Why it is not a List

Deliberately not a List or Iterable. Both require a length, which is exactly what a paginated select cannot answer until it reaches the end. Any implementation would have to lie or throw, and then for (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 limit entries, so identifying the final page yields the total — even with gaps:

totalLength == (finalPage - 1) * limit + entries(finalPage)
Observation Conclusion
page p returns 1..limit-1 entries p is final; total known
page p full, page p+1 empty p is final; total = p * limit
page 1 empty matches nothing; total 0
page p returns exactly limit p exists; p+1 unknown

An 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, plus reset(), refresh() and information().

Concurrent requests for the same page share a single fetch (same pattern SchemeProvider uses 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 sparse getAt can resolve the final page while leaving a gap behind — so loadAll() 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 the maxPages budget), so afterwards the result really is complete and gap-free.

Commits

7bd5695 EntityPagination core + 38 unit tests
b13a8fa paginateByQuery / paginate / paginateAll entry points + integration tests
11b801c v1.12.0 chores

Testing

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 access in the shared adapter template — lazy start, page 1, a jump leaving a gap, end resolution, loadAll filling 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, plus paginate() and paginateAll().

dart format, dart analyze --fatal-infos --fatal-warnings, dart pub publish --dry-run and ensure_build_test are 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

gmpassos and others added 3 commits August 1, 2026 18:11
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

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.35533% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.75%. Comparing base (7b4ac46) to head (11b801c).

Files with missing lines Patch % Lines
lib/src/bones_api_entity.dart 50.00% 13 Missing ⚠️
lib/src/bones_api_repository.dart 33.33% 4 Missing ⚠️
lib/src/bones_api_entity_pagination.dart 98.78% 2 Missing ⚠️
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     
Flag Coverage Δ
unittests 67.75% <90.35%> (+0.25%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gmpassos
gmpassos merged commit 8a81d56 into master Aug 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant