From 4c108a0cfd25cc6b80b90998cc83937d83bacb81 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sat, 1 Aug 2026 21:31:08 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20EntityPagination.onEvent=20=E2=80=94=20?= =?UTF-8?q?an=20optional=20hook=20of=20what=20is=20being=20fetched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `EntityPaginationEvent`, a `sealed` hierarchy so a `switch` over it is exhaustive, with `EntityPaginationListener` as the callback type: `EntityPaginationPageLoading`, `EntityPaginationPageLoaded` (entries, elapsedTime, isFinalPage), `EntityPaginationPageError` (error, stackTrace), `EntityPaginationPageSkipped` (`alreadyLoaded` / `inFlight` / `knownEmpty`), `EntityPaginationEnd` (finalPage, totalLength) and `EntityPaginationReset` (discardedPages, discardedEntitiesLength, isRefresh). Everything but the reset is an `EntityPaginationPageEvent`, carrying the page. - Delivered synchronously and in order, so the sequence is also correct for a synchronous `EntityPageLoader` (a `Stream` would only deliver in a later microtask, after a sync read already finished). Forward it to get a stream: `onEvent: myEventStream.add`. - `onEvent` is not `final`, so it can be attached after construction. A listener that throws is reported to the current `Zone` and does not break the fetch. Nothing is allocated (not even the fetch timer) while it is `null`. - `paginateByQuery`, `paginate` and `paginateAll` gained the optional `onEvent` parameter, on `EntitySource`, `EntityRepository` and `APIRepository`. - `_setPage` now reports whether it resolved the final page, so `EntityPaginationEnd` is emitted exactly once and *after* the `EntityPaginationPageLoaded` that caused it. - Version 1.12.0 -> 1.13.0. 15 new tests. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 77 +++++ README.md | 31 ++ lib/src/bones_api_base.dart | 2 +- lib/src/bones_api_entity.dart | 14 + lib/src/bones_api_entity_pagination.dart | 345 ++++++++++++++++++++- lib/src/bones_api_repository.dart | 6 + pubspec.yaml | 2 +- test/bones_api_entity_pagination_test.dart | 320 ++++++++++++++++++- 8 files changed, 782 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e42689f..63a0832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,80 @@ +## 1.13.0 + +- New `EntityPagination.onEvent`: an optional hook notified of what is being + fetched, for progress reporting and logging. + + ```dart + var p = userRepository.paginateByQuery(' state == ? ', + parameters: ['NY'], limit: 20, onEvent: (event) { + switch (event) { + case EntityPaginationPageLoading(:var page): + print('fetching page $page...'); + case EntityPaginationPageLoaded(:var page, :var entriesLength): + print('page $page: $entriesLength entries'); + case EntityPaginationPageError(:var page, :var error): + print('page $page failed: $error'); + case EntityPaginationPageSkipped(:var page, :var reason): + print('page $page not fetched: ${reason.name}'); + case EntityPaginationEnd(:var totalLength): + print('done: $totalLength entries'); + case EntityPaginationReset(:var discardedPages): + print('discarded ${discardedPages.length} pages'); + } + }); + ``` + + - Delivered **synchronously**, at the point where it happens and in order, so + it is also correct for a synchronous `EntityPageLoader` (a `Stream` would + only deliver in a later microtask, after a sync read already finished). + To consume it as a stream, forward it: `onEvent: myEventStream.add`. + - `onEvent` is not `final`, so it can also be attached to an already built + `EntityPagination`. Only events emitted afterwards are seen. + - An exception thrown by the listener is reported to the current `Zone` and + does not break the fetch. + - Nothing is allocated (not even the fetch timer) while `onEvent` is `null`. + +- New `EntityPaginationEvent`, a `sealed` hierarchy so a `switch` over it is + exhaustive, with `EntityPaginationListener` as the callback type: + - `EntityPaginationPageLoading`: a fetch is about to start. Emitted once per + *actual* fetch. + - `EntityPaginationPageLoaded`: a fetch finished, with the `entries`, the + `entriesLength`, the `elapsedTime` of the `pageLoader` and `isFinalPage`. + - `EntityPaginationPageError`: a fetch failed, with the `error`, the + `stackTrace` and the `elapsedTime`. The error is rethrown to the caller + right after the event. + - `EntityPaginationPageSkipped`: a page was served *without* a fetch, with an + `EntityPaginationSkipReason`: `alreadyLoaded`, `inFlight` (a concurrent + request shares the fetch) or `knownEmpty` (past the resolved end). Not an + error — it is what makes a repeated, concurrent or past-the-end read free. + - `EntityPaginationEnd`: the end was resolved, with the `finalPage` and the + `totalLength`. Emitted once, immediately after the + `EntityPaginationPageLoaded` that resolved it — which is not necessarily + the final page itself, since an empty page can pin the end at its + predecessor. + - `EntityPaginationReset`: `reset()` or `refresh()` discarded the loaded + pages, with the `discardedPages`, the `discardedEntitiesLength` and + `isRefresh` — `true` while it is the reset of a `refresh()`, which + re-fetches those pages right after, so a consumer can tell an in-progress + refresh from a pagination that was simply emptied. Emitted *after* the + state is cleared, so the pagination already reads as empty and the + discarded state is on the event. + + Every event but `EntityPaginationReset` is about a page, and is an + `EntityPaginationPageEvent` (also `sealed`) carrying the `page`. + + Note that concurrent page loads interleave: `getRange` and `refresh` start + every page at once, so all the fetches are announced before any completes. + +- `paginateByQuery`, `paginate` and `paginateAll` gained the optional `onEvent` + parameter, on `EntitySource`, `EntityRepository` and `APIRepository`, so the + hook is reachable without building an `EntityPagination` by hand. + +- Tests: 15 new cases in `bones_api_entity_pagination_test.dart` (the event + sequence of a full read, of an exact multiple of the page size, of an empty + result and of a failure; the 3 skip reasons; the synchronous delivery; a + listener attached after construction; a throwing listener; and the + `reset`/`refresh` events). + ## 1.12.0 - New `EntityPagination`: a lazily loaded, paginated view over a select, diff --git a/README.md b/README.md index 8c334c7..bb8cde4 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,37 @@ until it reaches the end, so `totalLength` is `null` until the final page is identified (`isFinalPageResolved`). Until then you still know `maxLoadedIndex`, `maxKnownPage` and which pages are loaded. +The optional `onEvent` hook reports what is being fetched, for progress +reporting and logging: + +```dart +var page = accountRepository.paginateByQuery( + ' address.state == ? ', + parameters: ['NY'], + limit: 20, + onEvent: (event) { + switch (event) { + case EntityPaginationPageLoading(:var page): + print('fetching page $page...'); + case EntityPaginationPageLoaded(:var page, :var entriesLength): + print('page $page: $entriesLength entries'); + case EntityPaginationPageError(:var page, :var error): + print('page $page failed: $error'); + case EntityPaginationPageSkipped(:var page, :var reason): + print('page $page not fetched: ${reason.name}'); + case EntityPaginationEnd(:var totalLength): + print('done: $totalLength entries'); + case EntityPaginationReset(:var discardedPages): + print('discarded ${discardedPages.length} pages'); + } + }, +); +``` + +Events are delivered synchronously and in order, so the sequence is meaningful +even for a synchronous page loader. To consume them as a `Stream` instead, +forward them: `onEvent: myEventStream.add`. + The config file used above: File: `api-local.yaml` diff --git a/lib/src/bones_api_base.dart b/lib/src/bones_api_base.dart index eb514fe..6771234 100644 --- a/lib/src/bones_api_base.dart +++ b/lib/src/bones_api_base.dart @@ -48,7 +48,7 @@ typedef APILogger = /// Bones API Library class. class BonesAPI { // ignore: constant_identifier_names - static const String VERSION = '1.12.0'; + static const String VERSION = '1.13.0'; static bool _boot = false; diff --git a/lib/src/bones_api_entity.dart b/lib/src/bones_api_entity.dart index 5eaf6ac..43a73af 100644 --- a/lib/src/bones_api_entity.dart +++ b/lib/src/bones_api_entity.dart @@ -3642,6 +3642,8 @@ abstract class EntitySource extends EntityAccessor { /// over a stable order, so unlike the `select*` methods this is on by /// default instead of being implied by an offset. /// - [orderDirection]: the [OrderDirection] of the ordering. + /// - [onEvent]: an optional hook notified of what is being fetched. + /// See [EntityPaginationEvent]. /// {@endtemplate} EntityPagination paginateByQuery( String query, { @@ -3652,9 +3654,11 @@ abstract class EntitySource extends EntityAccessor { bool? orderByID, OrderDirection? orderDirection, Transaction? transaction, + EntityPaginationListener? onEvent, }) => EntityPagination( limit: limit, query: query, + onEvent: onEvent, pageLoader: (page, limit) => selectByQuery( query, @@ -3679,9 +3683,11 @@ abstract class EntitySource extends EntityAccessor { bool? orderByID, OrderDirection? orderDirection, Transaction? transaction, + EntityPaginationListener? onEvent, }) => EntityPagination( limit: limit, query: '$matcher', + onEvent: onEvent, pageLoader: (page, limit) => select( matcher, @@ -3702,9 +3708,11 @@ abstract class EntitySource extends EntityAccessor { bool? orderByID, OrderDirection? orderDirection, Transaction? transaction, + EntityPaginationListener? onEvent, }) => EntityPagination( limit: limit, query: 'ALL', + onEvent: onEvent, pageLoader: (page, limit) => selectAll( transaction: transaction, @@ -5822,8 +5830,10 @@ abstract class EntityRepository extends EntityAccessor OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, + EntityPaginationListener? onEvent, }) => EntityPagination( limit: limit, + onEvent: onEvent, query: query, pageLoader: (page, limit) => selectByQuery( @@ -5854,8 +5864,10 @@ abstract class EntityRepository extends EntityAccessor OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, + EntityPaginationListener? onEvent, }) => EntityPagination( limit: limit, + onEvent: onEvent, query: '$matcher', pageLoader: (page, limit) => select( @@ -5882,8 +5894,10 @@ abstract class EntityRepository extends EntityAccessor OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, + EntityPaginationListener? onEvent, }) => EntityPagination( limit: limit, + onEvent: onEvent, query: 'ALL', pageLoader: (page, limit) => selectAll( diff --git a/lib/src/bones_api_entity_pagination.dart b/lib/src/bones_api_entity_pagination.dart index e689679..8df4f21 100644 --- a/lib/src/bones_api_entity_pagination.dart +++ b/lib/src/bones_api_entity_pagination.dart @@ -5,6 +5,218 @@ import 'package:async_extension/async_extension.dart'; typedef EntityPageLoader = FutureOr> Function(int page, int limit); +/// Notified of what an [EntityPagination] is fetching. +/// See [EntityPagination.onEvent] and [EntityPaginationEvent]. +typedef EntityPaginationListener = + void Function(EntityPaginationEvent event); + +/// An event of an [EntityPagination]. See [EntityPagination.onEvent]. +/// +/// Delivered **synchronously**, at the point where it happens and in order, so +/// it is also correct for a synchronous [EntityPageLoader]. To consume it as a +/// `Stream` instead, forward it: `onEvent: myEventStream.add`. +/// +/// Every event but [EntityPaginationReset] refers to a page, and is an +/// [EntityPaginationPageEvent]. Switch over it exhaustively: +/// +/// ```dart +/// switch (event) { +/// case EntityPaginationPageLoading(:var page): +/// print('fetching page $page...'); +/// case EntityPaginationPageLoaded(:var page, :var entries): +/// print('page $page: ${entries.length} entries'); +/// case EntityPaginationPageError(:var page, :var error): +/// print('page $page failed: $error'); +/// case EntityPaginationPageSkipped(:var page, :var reason): +/// print('page $page not fetched: ${reason.name}'); +/// case EntityPaginationEnd(:var totalLength): +/// print('done: $totalLength entries'); +/// case EntityPaginationReset(:var discardedPages): +/// print('discarded ${discardedPages.length} pages'); +/// } +/// ``` +sealed class EntityPaginationEvent { + /// The [EntityPagination] that emitted this event. + final EntityPagination pagination; + + EntityPaginationEvent(this.pagination); + + /// The page size. Same as `pagination.limit`. + int get limit => pagination.limit; +} + +/// An [EntityPaginationEvent] about a specific [page]. +/// +/// Everything but [EntityPaginationReset], which is about the whole +/// pagination. +sealed class EntityPaginationPageEvent + extends EntityPaginationEvent { + /// The page (1-based) this event refers to. + final int page; + + EntityPaginationPageEvent(super.pagination, this.page); +} + +/// A page fetch is about to start: [EntityPagination.pageLoader] is called +/// immediately after this event. +/// +/// Emitted once per *actual* fetch. A page that is already loaded, already +/// in-flight, or known to be past the end emits an +/// [EntityPaginationPageSkipped] instead. +final class EntityPaginationPageLoading + extends EntityPaginationPageEvent { + EntityPaginationPageLoading(super.pagination, super.page); + + @override + String toString() => 'EntityPaginationPageLoading{page: $page}'; +} + +/// A page fetch finished. Always preceded by an +/// [EntityPaginationPageLoading] for the same [page]. +final class EntityPaginationPageLoaded + extends EntityPaginationPageEvent { + /// The loaded entries (unmodifiable). + final List entries; + + /// How long [EntityPagination.pageLoader] took. + final Duration elapsedTime; + + EntityPaginationPageLoaded( + super.pagination, + super.page, + this.entries, + this.elapsedTime, + ); + + /// The number of loaded entries. A value below [limit] means this is the + /// last page. + int get entriesLength => entries.length; + + /// Whether this page turned out to be the final one. + bool get isFinalPage => pagination.finalPage == page; + + @override + String toString() => + 'EntityPaginationPageLoaded{page: $page, ' + 'entries: ${entries.length}, elapsedTime: $elapsedTime}'; +} + +/// A page fetch failed. The error is rethrown to the caller right after this +/// event, and the failed page is evicted so a retry actually retries. +final class EntityPaginationPageError + extends EntityPaginationPageEvent { + /// The error thrown by [EntityPagination.pageLoader]. + final Object error; + + /// The stack trace of [error]. + final StackTrace stackTrace; + + /// How long the failed fetch took. + final Duration elapsedTime; + + EntityPaginationPageError( + super.pagination, + super.page, + this.error, + this.stackTrace, + this.elapsedTime, + ); + + @override + String toString() => 'EntityPaginationPageError{page: $page, error: $error}'; +} + +/// Why a page was served without calling [EntityPagination.pageLoader]. +/// See [EntityPaginationPageSkipped]. +enum EntityPaginationSkipReason { + /// The page was already loaded, and was served from [EntityPagination]. + alreadyLoaded, + + /// A fetch of the same page was already in-flight, and is shared with it. + inFlight, + + /// The page is known to be past the end, so it can only be empty. + knownEmpty, +} + +/// A page was served without a fetch. See [EntityPaginationSkipReason]. +/// +/// Not an error: it is what makes a repeated read, a concurrent read and a +/// read past the end free. Ignore it to only observe real fetches. +final class EntityPaginationPageSkipped + extends EntityPaginationPageEvent { + /// Why the page was not fetched. + final EntityPaginationSkipReason reason; + + EntityPaginationPageSkipped(super.pagination, super.page, this.reason); + + @override + String toString() => + 'EntityPaginationPageSkipped{page: $page, reason: ${reason.name}}'; +} + +/// The end of the result was resolved: [EntityPagination.finalPage] and +/// [EntityPagination.totalLength] are now known. +/// +/// Emitted once, immediately after the [EntityPaginationPageLoaded] that +/// resolved it — which is not necessarily the final page itself, since an +/// empty page can pin the end at its predecessor. +/// See [EntityPagination.isFinalPageResolved]. +final class EntityPaginationEnd + extends EntityPaginationPageEvent { + /// The total number of entries. + final int totalLength; + + /// The final page. Same as [page]. + int get finalPage => page; + + EntityPaginationEnd(super.pagination, super.page, this.totalLength); + + /// Whether the select matched no entry at all. + bool get isEmpty => totalLength == 0; + + @override + String toString() => + 'EntityPaginationEnd{finalPage: $page, totalLength: $totalLength}'; +} + +/// Every loaded page was discarded by [EntityPagination.reset] or +/// [EntityPagination.refresh], and everything known about the end with them. +/// +/// Emitted *after* the state is cleared, so the [pagination] already reads as +/// empty. The discarded state is on the event itself. +/// +/// The only event that is not an [EntityPaginationPageEvent]: it is about the +/// whole pagination. +final class EntityPaginationReset + extends EntityPaginationEvent { + /// The pages that were loaded before the reset, ascending. + final List discardedPages; + + /// The number of entries that were loaded before the reset. + final int discardedEntitiesLength; + + /// Whether this is the reset of an [EntityPagination.refresh], which + /// re-fetches [discardedPages] right after — so a consumer can tell an + /// in-progress refresh from a pagination that was simply emptied. + final bool isRefresh; + + EntityPaginationReset( + super.pagination, + this.discardedPages, + this.discardedEntitiesLength, + this.isRefresh, + ); + + /// Whether there was nothing to discard. + bool get isEmpty => discardedPages.isEmpty; + + @override + String toString() => + 'EntityPaginationReset{discardedPages: ${discardedPages.length}, ' + 'discardedEntities: $discardedEntitiesLength, isRefresh: $isRefresh}'; +} + /// A lazily loaded, paginated view over a select operation. /// /// It keeps the pages it has already loaded and never fetches implicitly: @@ -59,16 +271,42 @@ class EntityPagination { /// An optional description of the paginated query, for [toString]. final String? query; + /// An optional hook notified of what is being fetched, or `null` (the + /// default) to notify nothing. See [EntityPaginationEvent]. + /// + /// Called **synchronously** at the point where the event happens, so the + /// order is meaningful even for a synchronous [pageLoader]. Not `final`, so + /// it can also be attached to an already built [EntityPagination] — only + /// events emitted afterwards are seen. + /// + /// An exception thrown by the listener is reported to the current [Zone] and + /// does not break the fetch. + EntityPaginationListener? onEvent; + EntityPagination({ required this.limit, required this.pageLoader, this.query, + this.onEvent, }) { if (limit <= 0) { throw ArgumentError.value(limit, 'limit', 'The page size must be > 0'); } } + /// Notifies [onEvent], isolating it: a broken listener must not break a + /// fetch, but must not be silently swallowed either. + void _notify(EntityPaginationEvent event) { + var onEvent = this.onEvent; + if (onEvent == null) return; + + try { + onEvent(event); + } catch (e, s) { + Zone.current.handleUncaughtError(e, s); + } + } + /// The loaded pages, by page number. final Map> _pages = >{}; @@ -282,29 +520,57 @@ class EntityPagination { } var loaded = _pages[page]; - if (loaded != null) return loaded; + if (loaded != null) { + _notifySkipped(page, EntityPaginationSkipReason.alreadyLoaded); + return loaded; + } // Already known to be past the end, no need to fetch: - if (_isPageKnownEmpty(page)) return []; + if (_isPageKnownEmpty(page)) { + _notifySkipped(page, EntityPaginationSkipReason.knownEmpty); + return []; + } var loading = _loadingPages[page]; - if (loading != null) return loading; + if (loading != null) { + _notifySkipped(page, EntityPaginationSkipReason.inFlight); + return loading; + } + + _notify(EntityPaginationPageLoading(this, page)); + + // Only timed while there is a listener to receive it: + var stopwatch = onEvent != null ? (Stopwatch()..start()) : null; var ret = pageLoader(page, limit); if (ret is! Future>) { - _setPage(page, ret); - return _pages[page]!; + var elapsedTime = stopwatch?.elapsed ?? Duration.zero; + var resolvedEnd = _setPage(page, ret); + var entries = _pages[page]!; + + _notifyLoaded(page, entries, elapsedTime, resolvedEnd); + + return entries; } var future = ret - .then((entries) { + .then((pageEntries) { + var elapsedTime = stopwatch?.elapsed ?? Duration.zero; _loadingPages.remove(page); - _setPage(page, entries); - return _pages[page]!; + var resolvedEnd = _setPage(page, pageEntries); + var entries = _pages[page]!; + + _notifyLoaded(page, entries, elapsedTime, resolvedEnd); + + return entries; }) .onError((e, s) { + var elapsedTime = stopwatch?.elapsed ?? Duration.zero; _loadingPages.remove(page); + + _notify(EntityPaginationPageError(this, page, e, s, elapsedTime)); + throw e; }); @@ -312,6 +578,33 @@ class EntityPagination { return future; } + void _notifySkipped(int page, EntityPaginationSkipReason reason) { + if (onEvent == null) return; + _notify(EntityPaginationPageSkipped(this, page, reason)); + } + + /// Notifies the loaded page, then the end of the result when this page + /// resolved it (in that order). + void _notifyLoaded( + int page, + List entries, + Duration elapsedTime, + bool resolvedEnd, + ) { + if (onEvent == null) return; + + _notify(EntityPaginationPageLoaded(this, page, entries, elapsedTime)); + + if (resolvedEnd) { + var finalPage = _finalPage; + var totalLength = this.totalLength; + + if (finalPage != null && totalLength != null) { + _notify(EntityPaginationEnd(this, finalPage, totalLength)); + } + } + } + bool _isPageKnownEmpty(int page) { var finalPage = _finalPage; if (finalPage != null && page > finalPage) return true; @@ -388,10 +681,17 @@ class EntityPagination { // Control: // --------------------------------------------------------------------- - void _setPage(int page, List entries) { + /// Stores the entries of [page], and returns `true` if this call resolved + /// [finalPage] (so that [EntityPaginationEnd] is emitted exactly once, and + /// after the [EntityPaginationPageLoaded] that caused it). + bool _setPage(int page, List entries) { var list = List.unmodifiable(entries); _pages[page] = list; + + var wasResolved = _finalPage != null; _resolveFinalPage(page, list); + + return !wasResolved && _finalPage != null; } void _resolveFinalPage(int page, List entries) { @@ -426,18 +726,41 @@ class EntityPagination { /// Discards every loaded page and everything known about the end, /// keeping the query. - void reset() { + /// + /// Notifies an [EntityPaginationReset]. + void reset() => _resetImpl(isRefresh: false); + + void _resetImpl({required bool isRefresh}) { + // Captured before clearing: the event reports what was discarded, while + // the pagination itself already reads as empty. + var discardedPages = onEvent != null ? loadedPages : const []; + var discardedEntitiesLength = onEvent != null ? loadedEntitiesLength : 0; + _pages.clear(); _loadingPages.clear(); _finalPage = null; _minEmptyPage = null; + + if (onEvent != null) { + _notify( + EntityPaginationReset( + this, + discardedPages, + discardedEntitiesLength, + isRefresh, + ), + ); + } } /// Re-fetches the currently loaded pages, discarding what was known about /// the end (it may have moved). + /// + /// Notifies an [EntityPaginationReset] with `isRefresh: true`, then the + /// events of the re-fetched pages. FutureOr refresh() { var pages = loadedPages; - reset(); + _resetImpl(isRefresh: true); if (pages.isEmpty) return null; diff --git a/lib/src/bones_api_repository.dart b/lib/src/bones_api_repository.dart index 64ff90e..89a6f04 100644 --- a/lib/src/bones_api_repository.dart +++ b/lib/src/bones_api_repository.dart @@ -286,6 +286,7 @@ abstract class APIRepository with Initializable { OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, + EntityPaginationListener? onEvent, }) => entityRepository.paginateByQuery( query, parameters: parameters, @@ -296,6 +297,7 @@ abstract class APIRepository with Initializable { orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, + onEvent: onEvent, ); /// {@macro bones_api.paginate} @@ -309,6 +311,7 @@ abstract class APIRepository with Initializable { OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, + EntityPaginationListener? onEvent, }) => entityRepository.paginate( matcher, parameters: parameters, @@ -319,6 +322,7 @@ abstract class APIRepository with Initializable { orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, + onEvent: onEvent, ); /// {@macro bones_api.paginate} @@ -328,12 +332,14 @@ abstract class APIRepository with Initializable { OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, + EntityPaginationListener? onEvent, }) => entityRepository.paginateAll( limit: limit, orderByID: orderByID, orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, + onEvent: onEvent, ); FutureOr> deleteByQuery( diff --git a/pubspec.yaml b/pubspec.yaml index 0ad23c7..3e3951b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bones_api description: Bones_API - A powerful API backend framework for Dart. It comes with a built-in HTTP Server, route handler, entity handler, SQL translator, and DB adapters. -version: 1.12.0 +version: 1.13.0 homepage: https://github.com/Colossus-Services/bones_api environment: diff --git a/test/bones_api_entity_pagination_test.dart b/test/bones_api_entity_pagination_test.dart index 8b82bc3..f96c574 100644 --- a/test/bones_api_entity_pagination_test.dart +++ b/test/bones_api_entity_pagination_test.dart @@ -36,12 +36,44 @@ class _FakeSource { return async ? Future.value(entries) : entries; } - EntityPagination<_Item> pagination({int limit = 10}) => - EntityPagination<_Item>(limit: limit, pageLoader: load, query: 'fake'); + EntityPagination<_Item> pagination({ + int limit = 10, + EntityPaginationListener<_Item>? onEvent, + }) => EntityPagination<_Item>( + limit: limit, + pageLoader: load, + query: 'fake', + onEvent: onEvent, + ); } List _ids(Iterable<_Item> items) => items.map((e) => e.id).toList(); +/// Records the [EntityPagination] events as compact `':'` strings, +/// so a whole sequence can be asserted at once. +class _EventLog { + final List> events = []; + + void call(EntityPaginationEvent<_Item> event) => events.add(event); + + List get trace => + events.map((e) { + return switch (e) { + EntityPaginationPageLoading(:var page) => 'loading:$page', + EntityPaginationPageLoaded(:var page, :var entriesLength) => + 'loaded:$page($entriesLength)', + EntityPaginationPageError(:var page) => 'error:$page', + EntityPaginationPageSkipped(:var page, :var reason) => + 'skipped:$page(${reason.name})', + EntityPaginationEnd(:var finalPage, :var totalLength) => + 'end:$finalPage($totalLength)', + EntityPaginationReset(:var discardedPages, :var isRefresh) => + '${isRefresh ? 'refresh' : 'reset'}:' + '(${discardedPages.join(',')})', + }; + }).toList(); +} + void main() { group('EntityPagination: construction', () { test('rejects a non-positive limit', () { @@ -628,4 +660,288 @@ void main() { expect(p.isIndexKnownOutOfRange(25), isTrue); }); }); + + group('EntityPagination: events', () { + test('nothing is notified without a listener', () async { + var p = _FakeSource(25).pagination(limit: 10); + + // Just has to not throw: + await p.loadAll(); + + expect(p.onEvent, isNull); + expect(p.totalLength, equals(25)); + }); + + test('a full read notifies each page, then the end', () async { + var log = _EventLog(); + var p = _FakeSource(25).pagination(limit: 10, onEvent: log.call); + + await p.loadAll(); + + expect( + log.trace, + equals([ + 'loading:1', + 'loaded:1(10)', + 'loading:2', + 'loaded:2(10)', + 'loading:3', + // A short page is the last one, so the end resolves right here: + 'loaded:3(5)', + 'end:3(25)', + ]), + ); + }); + + test('the end is notified once, after the page that resolved it', () async { + var log = _EventLog(); + // An exact multiple of the page size: the end is only pinned by the + // empty page 3, whose predecessor is loaded and full. + var p = _FakeSource(20).pagination(limit: 10, onEvent: log.call); + + await p.loadAll(); + + expect( + log.trace, + equals([ + 'loading:1', + 'loaded:1(10)', + 'loading:2', + 'loaded:2(10)', + 'loading:3', + 'loaded:3(0)', + 'end:2(20)', + ]), + ); + + // Re-reading resolves nothing new: + log.events.clear(); + await p.loadAll(); + expect(log.trace.where((e) => e.startsWith('end:')), isEmpty); + }); + + test('an empty result notifies the end on page 1', () async { + var log = _EventLog(); + var p = _FakeSource(0).pagination(limit: 10, onEvent: log.call); + + await p.loadAll(); + + expect(log.trace, equals(['loading:1', 'loaded:1(0)', 'end:1(0)'])); + + var end = log.events.whereType>().single; + expect(end.isEmpty, isTrue); + }); + + test('a page served without a fetch notifies why', () async { + var log = _EventLog(); + var source = _FakeSource(25); + var p = source.pagination(limit: 10, onEvent: log.call); + + await p.loadPage(1); + log.events.clear(); + + // Already loaded: + await p.loadPage(1); + + // In-flight: 2 concurrent requests for page 2 share one fetch. + await Future.wait([ + Future.value(p.loadPage(2)), + Future.value(p.loadPage(2)), + ]); + + expect( + log.trace, + equals([ + 'skipped:1(alreadyLoaded)', + 'loading:2', + 'skipped:2(inFlight)', + 'loaded:2(10)', + ]), + ); + + // Past the resolved end: + await p.loadAll(); + log.events.clear(); + await p.loadPage(9); + + expect(log.trace, equals(['skipped:9(knownEmpty)'])); + + expect(source.fetches, equals([1, 2, 3])); + }); + + test('a failed page notifies the error, then rethrows', () async { + var log = _EventLog(); + var attempts = 0; + + var p = EntityPagination<_Item>( + limit: 10, + onEvent: log.call, + pageLoader: (page, limit) async { + ++attempts; + if (attempts == 1) throw StateError('boom'); + return [_Item(0)]; + }, + ); + + await expectLater(p.loadPage(1), throwsA(isA())); + + var error = + log.events.whereType>().single; + expect(error.page, equals(1)); + expect(error.error, isA()); + expect(error.stackTrace, isNotNull); + + // The retry is a normal load, with no leftover state: + log.events.clear(); + await p.loadPage(1); + + expect(log.trace, equals(['loading:1', 'loaded:1(1)', 'end:1(1)'])); + }); + + test('a synchronous pageLoader notifies in order, synchronously', () { + var log = _EventLog(); + var p = _FakeSource( + 25, + async: false, + ).pagination(limit: 10, onEvent: log.call); + + // Not awaited: a sync loader must have notified everything already. + p.loadPage(1); + + expect(log.trace, equals(['loading:1', 'loaded:1(10)'])); + }); + + test('the loaded entries and the elapsed time are reported', () async { + var log = _EventLog(); + var p = _FakeSource(25).pagination(limit: 10, onEvent: log.call); + + await p.loadPage(3); + + var loaded = + log.events.whereType>().single; + + expect(_ids(loaded.entries), equals([20, 21, 22, 23, 24])); + expect(loaded.entriesLength, equals(5)); + expect(loaded.isFinalPage, isTrue); + expect(loaded.limit, equals(10)); + expect(loaded.pagination, same(p)); + expect(loaded.elapsedTime, isA()); + expect(loaded.elapsedTime.isNegative, isFalse); + + // The entries are the stored, unmodifiable list: + expect(() => loaded.entries.add(_Item(99)), throwsUnsupportedError); + }); + + test('a listener can be attached after construction', () async { + var log = _EventLog(); + var p = _FakeSource(25).pagination(limit: 10); + + await p.loadPage(1); + expect(log.events, isEmpty); + + p.onEvent = log.call; + await p.loadPage(2); + + expect(log.trace, equals(['loading:2', 'loaded:2(10)'])); + }); + + test('a listener that throws does not break the fetch', () async { + var uncaught = []; + + var entries = await runZonedGuarded(() async { + var p = _FakeSource(25).pagination( + limit: 10, + onEvent: (_) => throw StateError('broken listener'), + ); + + return await p.loadPage(1); + }, (e, s) => uncaught.add(e)); + + expect(_ids(entries!), equals([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); + expect(uncaught, isNotEmpty); + expect(uncaught.first, isA()); + }); + + test('reset notifies what it discarded', () async { + var log = _EventLog(); + var p = _FakeSource(25).pagination(limit: 10, onEvent: log.call); + + await p.loadAll(); + log.events.clear(); + + p.reset(); + + expect(log.trace, equals(['reset:(1,2,3)'])); + + var reset = log.events.whereType>().single; + expect(reset.discardedPages, equals([1, 2, 3])); + expect(reset.discardedEntitiesLength, equals(25)); + expect(reset.isRefresh, isFalse); + expect(reset.isEmpty, isFalse); + expect(reset.pagination, same(p)); + + // Emitted *after* the state is cleared: + expect(p.loadedPages, isEmpty); + expect(p.totalLength, isNull); + }); + + test('resetting an empty pagination still notifies', () { + var log = _EventLog(); + var p = _FakeSource(25).pagination(limit: 10, onEvent: log.call); + + p.reset(); + + expect(log.trace, equals(['reset:()'])); + + var reset = log.events.whereType>().single; + expect(reset.isEmpty, isTrue); + expect(reset.discardedEntitiesLength, equals(0)); + }); + + test('refresh notifies a reset, then re-fetches the pages', () async { + var log = _EventLog(); + var source = _FakeSource(25); + var p = source.pagination(limit: 10, onEvent: log.call); + + await p.loadPage(1); + await p.loadPage(3); + log.events.clear(); + + await p.refresh(); + + expect( + log.trace, + equals([ + // Marked as a refresh, so a consumer knows the pages come back: + 'refresh:(1,3)', + 'loading:1', + 'loading:3', + 'loaded:1(10)', + 'loaded:3(5)', + 'end:3(25)', + ]), + ); + + expect(source.fetches, equals([1, 3, 1, 3])); + }); + + test('stream and getRange notify their pages', () async { + var log = _EventLog(); + var p = _FakeSource(25).pagination(limit: 10, onEvent: log.call); + + await p.getRange(5, 15); + + // `getRange` starts every spanned page at once, so both fetches are + // announced before either completes: + expect( + log.trace, + equals(['loading:1', 'loading:2', 'loaded:1(10)', 'loaded:2(10)']), + ); + + log.events.clear(); + await p.stream(fromPage: 3).toList(); + + expect(log.trace, equals(['loading:3', 'loaded:3(5)', 'end:3(25)'])); + }); + }); }