diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index ca11dcc..0422896 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -10,8 +10,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 + - uses: actions/checkout@v7 + - uses: dart-lang/setup-dart@v1.7.2 - name: Dart version run: | dart --version @@ -34,8 +34,8 @@ jobs: test_vm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 + - uses: actions/checkout@v7 + - uses: dart-lang/setup-dart@v1.7.2 - name: Dart version run: | dart --version @@ -51,10 +51,11 @@ jobs: dart pub global activate coverage dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --report-on=lib --lcov -o ./coverage/lcov.info -i ./coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v7 with: + # `codecov-action` v4+ takes the token as an input + # (it was an env var in v3): + token: ${{ secrets.CODECOV_TOKEN }} directory: ./coverage/ flags: unittests env_vars: OS,DART @@ -65,8 +66,8 @@ jobs: test_chrome: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 + - uses: actions/checkout@v7 + - uses: dart-lang/setup-dart@v1.7.2 - name: Dart version run: | dart --version diff --git a/CHANGELOG.md b/CHANGELOG.md index f81dfa5..ddf9adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,102 @@ +## 1.11.0 + +- `selectByQuery` and its siblings gained 4 optional parameters, for pagination + and ordering: + - `offset`: the return offset. + - `page`: the 1-based page to return, an ergonomic alternative to `offset` + that computes it from the page size: `(page - 1) * limit`. + - `orderByID`: orders the result by the table's ID column, resolved + automatically from the existing scheme machinery (`TableScheme.idFieldName` + → `EncodingContext.tableFieldID`, or `EntityHandler.idFieldName`). + - `orderDirection`: the new `OrderDirection` enum, `ascending` (default) or + `descending`. + + Semantics: + - The effective ordering is `orderByID ?? (offset != null)`: a non-null + `offset` turns the ordering **on** by default, since an offset-based + pagination needs a stable order to be correct. Pass `orderByID: false` to + opt out and get a bare `OFFSET`. + - `orderDirection` is **ignored** while the ordering is not active. + - `page` is a public convenience resolved to an `offset` at the repository + layer (see `resolveSelectOffset`); the adapter contract keeps taking only + `offset`. It throws an `ArgumentError` when combined with an `offset` (two + spellings of one thing), when there is no positive `limit` to use as the + page size, or when it is `< 1`. `page: 1` resolves to `offset: 0`, which + still activates the ordering, so even the first page is stable. + - All 4 are optional and default to the previous behavior: with them unset the + generated SQL is character-identical to 1.10.0. + + Added to `EntitySource`/`EntityRepository` (`selectByQuery`, + `selectFirstByQuery`, `select`, `selectIDsByQuery`, `selectIDsBy`, + `selectAll`), `APIRepository`, `IterableEntityRepository` + (`matches`/`all` included), `DBEntityRepository`, `DBRelationalAdapter`/ + `DBRelationalRepositoryAdapter`/`DBRelationalEntityRepository`, + `DBAdapter.doSelectAll`/`doSelectByIDs`, `DBSQLAdapter.doSelect`/ + `doSelectIDsBy`/`generateSelectSQL`/`generateSelectIDsSQL` and + `DBSQLRepositoryAdapter.generateSelectSQL`. + +- New `OrderDirection` enum (`bones_api_types.dart`), with `sqlKeyword`, + `parse` and the resolvers `resolve` and `resolveOrderByID` that state the + semantics above exactly once. + +- New `compareEntityIDs` and `applySelectOrderAndPagination` + (`bones_api_entity.dart`): the shared Dart-side "order by ID → skip → take" + used by every adapter that can't delegate the ordering to a DB engine. + +- New `resolveSelectOffset` (`bones_api_entity.dart`): resolves `page` to an + `offset`, and states the `page`/`offset`/`limit` validation rules once. + +- `SQLDialect`: + - New `orderBySQL` and `limitOffsetSQL` clause builders, so all the + dialect-specific `SELECT` tail syntax lives in one place. + - New `offsetRequiresLimit` and `offsetMaxLimitValue` capabilities. MySQL sets + `offsetRequiresLimit: true` since it can't parse an `OFFSET` that is not + preceded by a `LIMIT`; an offset-only select there emits + `LIMIT 18446744073709551615 OFFSET n`. PostgreSQL and the `generic` + (in-memory) dialect emit a bare `OFFSET n`. + +- `SQL`: new `offset`, `orderByID` and `orderDirection` fields (carried by + `copy()`), read by `DBSQLMemoryAdapter` to apply the same semantics in Dart. + +- `APIDBModule.select` (`/db/select/`): new `LIMIT=`, `OFFSET=`, + `PAGE=` and `ORDER=asc|desc` query directives + (see `APIDBModule.selectQueryDirectives`), + parsed from the query `String` alongside the pre-existing `EAGER=true` and + stripped before the remainder is parsed as the entity condition query. The + endpoint no longer selects the whole table and sorts it in Dart — the ordering + is now resolved by the DB. Its output order is unchanged. An invalid `PAGE` + becomes an error response rather than an uncaught `ArgumentError`. + +- **Behavior change**: `limit` is now honored on the paths that previously + accepted and silently ignored it — `DBEntityRepository.select`'s + `ConditionID`/`ConditionIdIN`/`ConditionANY`/`KeyConditionEQ` fast paths, + `DBAdapter.doSelectAll`/`doSelectByIDs`, and the `DBObjectMemoryAdapter`, + `DBObjectDirectoryAdapter` and `DBObjectGCSAdapter` adapters. For example, + `selectAll(limit: 2)` on an object adapter returned *every* row before this + release; it now returns 2. + +- **Source-breaking for external subclasses**: new named parameters were added + to abstract members (`EntitySource.select`/`selectIDsBy`/`selectAll`, + `DBAdapter.doSelectAll`/`doSelectByIDs`, + `DBRelationalAdapter.doSelect`/`doSelectIDsBy`). Dart requires an override to + accept every named parameter of the supertype, so third-party + `EntityRepository`/`DBAdapter` implementations must widen their overrides. + +- Known limitation: a query over a to-many relationship generates a `JOIN` + without a `DISTINCT`, so it can return the same entity more than once + (pre-existing). Paginating such a query is therefore best-effort. + +- Tests: + - New `bones_api_entity_select_order_test.dart` (`OrderDirection`, + `compareEntityIDs`, `applySelectOrderAndPagination`, `SQLDialect` clause + builders), `bones_api_entity_db_sql_select_test.dart` (exact generated SQL + per case + end-to-end paging over the in-memory SQL adapter) and + `bones_api_db_module_test.dart` (first coverage of `APIDBModule`). + - `bones_api_entity_db_tests_base.dart`: 3 new tests in the shared adapter + template, so the generated SQL and real page-by-page reads are asserted for + the in-memory, PostgreSQL, MySQL, object-memory and object-directory + adapters. Verified against real PostgreSQL and MySQL containers. + ## 1.10.0 - `docker_commander`: `^2.1.8` → `^3.0.0`. diff --git a/README.md b/README.md index 11330b6..c960e79 100644 --- a/README.md +++ b/README.md @@ -416,10 +416,49 @@ class AccountAPIRepository extends APIRepository { // This condition will be translated to a SQL with INNER JOIN (when using an SQLAdapter): return selectByQuery(' address.state == ? ', parameters: [state]); } + + /// Selects a page of [Account]s by field `state` (`page` starts at 1): + FutureOr> selectAccountsPage(String state, int page) { + // `page` computes the offset from the page size, and implies `orderByID`, + // so the pagination is stable + // (translated to: ORDER BY ASC LIMIT 20 OFFSET <(page - 1) * 20>). + return selectByQuery( + ' address.state == ? ', + parameters: [state], + limit: 20, + page: page, + ); + } + + /// Selects the 10 newest [Account]s (highest IDs first): + FutureOr> selectNewestAccounts() { + return selectAll( + limit: 10, + orderByID: true, + orderDirection: OrderDirection.descending, + ); + } } ``` +### Ordering and pagination + +The `select*` methods accept `limit`, `offset`, `page`, `orderByID` and +`orderDirection`: + +- `orderByID` orders by the table's ID column, resolved automatically from the + table scheme — no column name to spell out. +- A non-null `offset` turns `orderByID` on by default, since an offset without + a stable order can return overlapping or missing rows across pages. Pass + `orderByID: false` to opt out. +- `orderDirection` is `OrderDirection.ascending` by default, and is ignored + while the ordering is not active. +- `page` is the 1-based ergonomic form of `offset`, using `limit` as the page + size: `page: 3, limit: 20` is `offset: 40`. It throws an `ArgumentError` if + combined with an `offset`, if there is no positive `limit` to page by, or if + it is below 1. + 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 9456c2b..9953b6c 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.10.0'; + static const String VERSION = '1.11.0'; static bool _boot = false; diff --git a/lib/src/bones_api_db_module.dart b/lib/src/bones_api_db_module.dart index b091f85..758b59e 100644 --- a/lib/src/bones_api_db_module.dart +++ b/lib/src/bones_api_db_module.dart @@ -15,6 +15,7 @@ import 'bones_api_entity_rules.dart'; import 'bones_api_extension.dart'; import 'bones_api_html_document.dart'; import 'bones_api_module.dart'; +import 'bones_api_types.dart'; import 'bones_api_utils_json.dart'; final _log = logging.Logger('APIDBModule'); @@ -263,11 +264,68 @@ class APIDBModule extends APIModule { return APIResponse.ok(html, mimeType: 'html'); } + /// The directives accepted by the query `String` of [select], as + /// `&`-joined `KEY=VALUE` tokens. Everything else in the query `String` is + /// the entity condition query. + /// + /// - `EAGER=true`: resolves the referenced entities. + /// - `LIMIT=`: the maximum number of returned entities, and the page size + /// of `PAGE`. + /// - `OFFSET=`: the return offset, for pagination. + /// - `PAGE=`: the 1-based page to return; requires `LIMIT` and can't be + /// combined with `OFFSET`. See [resolveSelectOffset]. + /// - `ORDER=asc|desc`: the [OrderDirection] of the ordering. + /// + /// Example: `/db/select/user/json?email == "a@b.c"&LIMIT=10&PAGE=3` + static const List selectQueryDirectives = [ + 'EAGER', + 'LIMIT', + 'OFFSET', + 'PAGE', + 'ORDER', + ]; + + /// Extracts the `KEY=VALUE` directive [key] from the raw [query] `String`, + /// returning the value and the [query] without it. + /// + /// Follows the pre-existing `EAGER=true` convention: the directive can be + /// the whole [query] or one of its `&`-joined tokens. + static ({String query, String? value}) _extractQueryDirective( + String query, + String key, + ) { + if (query.isEmpty) return (query: query, value: null); + + var prefix = '$key='; + + var parts = query.split('&'); + + String? value; + var rest = []; + + for (var part in parts) { + if (value == null && part.startsWith(prefix)) { + value = part.substring(prefix.length); + } else { + rest.add(part); + } + } + + if (value == null) return (query: query, value: null); + + return (query: rest.join('&'), value: value); + } + Future> select( String table, APIRequest apiRequest, { bool? eager, bool json = false, + int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) async { if (onlyOnDevelopment && !development) { return APIResponse.error(error: "Unsupported request!"); @@ -287,20 +345,62 @@ class APIDBModule extends APIModule { var query = Uri.decodeQueryComponent(requestedUri.query); - if (query == 'EAGER=true') { - query = ''; - eager = true; - } else if (query.endsWith('&EAGER=true')) { - query = query.substring(0, query.length - 11); - eager = true; + // See [selectQueryDirectives]. The arguments passed to this method take + // precedence over the query `String` directives. + { + var extracted = _extractQueryDirective(query, 'EAGER'); + query = extracted.query; + if (extracted.value == 'true') eager = true; + } + + { + var extracted = _extractQueryDirective(query, 'LIMIT'); + query = extracted.query; + limit ??= int.tryParse(extracted.value?.trim() ?? ''); + } + + { + var extracted = _extractQueryDirective(query, 'OFFSET'); + query = extracted.query; + offset ??= int.tryParse(extracted.value?.trim() ?? ''); + } + + { + var extracted = _extractQueryDirective(query, 'PAGE'); + query = extracted.query; + page ??= int.tryParse(extracted.value?.trim() ?? ''); + } + + { + var extracted = _extractQueryDirective(query, 'ORDER'); + query = extracted.query; + orderDirection ??= OrderDirection.parse(extracted.value); + } + + // Resolved here (and not by the repository) so an invalid `PAGE` becomes an + // error response instead of an uncaught `ArgumentError`: + try { + offset = resolveSelectOffset(page: page, offset: offset, limit: limit); + } on ArgumentError catch (e) { + return APIResponse.error(error: "Invalid pagination: ${e.message}"); } eager ??= false; + // This endpoint has always returned ID-sorted entities, so the ordering + // defaults to `true` here (and not to the `offset != null` rule). + // It is now resolved by the DB instead of being sorted in Dart. + orderByID ??= true; + _log.info( "APIDBModule[REQUEST]> select> " "table: `$table` ; " - "eager: $eager" + "eager: $eager ; " + "orderByID: $orderByID ; " + "orderDirection: ${OrderDirection.resolve(orderDirection).name}" + "${limit != null ? ' ; limit: $limit' : ''}" + "${offset != null ? ' ; offset: $offset' : ''}" + "${page != null ? ' ; page: $page' : ''}" "${query.isNotEmpty ? ' ; QUERY> $query' : ''}", ); @@ -311,35 +411,24 @@ class APIDBModule extends APIModule { if (query.isEmpty) { var selectAll = await entityRepository.selectAll( resolutionRules: resolutionRules, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); list = selectAll.toList(); } else { var selectByQuery = await entityRepository.selectByQuery( query, resolutionRules: resolutionRules, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); list = selectByQuery.toList(); } - list.sort((a, b) { - var id1 = entityRepository.getEntityID(a); - var id2 = entityRepository.getEntityID(b); - - if (id1 == null && id2 == null) { - return 0; - } else if (id1 == null) { - return 1; - } else if (id2 == null) { - return -1; - } else if (id1 is num && id2 is num) { - return id1.compareTo(id2); - } else if (id1 is String && id2 is String) { - return id1.compareTo(id2); - } else { - return 0; - } - }); - if (json) { var entitiesJson = _entitiesToJsonMap(list); return APIResponse.ok(entitiesJson, mimeType: 'json'); diff --git a/lib/src/bones_api_entity.dart b/lib/src/bones_api_entity.dart index 2799a8d..72a9d6c 100644 --- a/lib/src/bones_api_entity.dart +++ b/lib/src/bones_api_entity.dart @@ -3307,6 +3307,117 @@ mixin EntityFieldAccessor { class EntityFieldAccessorGeneric with EntityFieldAccessor {} +/// Compares 2 entity IDs, implementing the `orderByID` ordering of the +/// `select*` methods. +/// +/// - `null` IDs are ordered last (in [OrderDirection.ascending] order). +/// - [num]s are compared numerically and [String]s lexicographically. +/// - Any other pair of [Comparable]s of the same type is compared +/// with [Comparable.compareTo]; otherwise returns `0` (undefined order). +int compareEntityIDs(Object? id1, Object? id2) { + if (identical(id1, id2)) return 0; + + if (id1 == null) return id2 == null ? 0 : 1; + if (id2 == null) return -1; + + if (id1 is num && id2 is num) return id1.compareTo(id2); + if (id1 is String && id2 is String) return id1.compareTo(id2); + + if (id1 is Comparable && id2 is Comparable) { + if (id1.runtimeType == id2.runtimeType) { + return id1.compareTo(id2); + } + } + + return 0; +} + +/// Resolves the effective `offset` of a `select*` operation from a 1-based +/// [page], where [limit] is the page size. +/// +/// Returns [offset] unchanged when [page] is `null`, otherwise +/// `(page - 1) * limit`. A [page] of `1` resolves to an `offset` of `0`, which +/// still activates the ordering (see [OrderDirection.resolveOrderByID]), so a +/// paginated select is stable by default. +/// +/// Throws an [ArgumentError] when: +/// - [page] and [offset] are both defined: they are two spellings of the same +/// thing, so passing both is a bug rather than a precedence question; +/// - [page] is defined without a positive [limit]: a page has no meaning +/// without a page size, and a `limit` of `0` means "no limit"; +/// - [page] is `< 1`: pages are numbered from `1`. +int? resolveSelectOffset({int? page, int? offset, int? limit}) { + if (page == null) return offset; + + if (offset != null) { + throw ArgumentError.value( + page, + 'page', + "`page` and `offset` are mutually exclusive (offset: $offset)", + ); + } + + if (limit == null || limit <= 0) { + throw ArgumentError.value( + page, + 'page', + '`page` requires a positive `limit` (the page size), got: $limit', + ); + } + + if (page < 1) { + throw ArgumentError.value(page, 'page', '`page` is 1-based, must be >= 1'); + } + + return (page - 1) * limit; +} + +/// Applies the ordering and the pagination of a `select*` operation to [itr]. +/// +/// Orders the elements by ID (resolved through [idGetter]) when +/// [OrderDirection.resolveOrderByID] resolves to `true`, then skips [offset] +/// elements and takes [limit] of them — in that order, matching SQL semantics +/// (`ORDER BY` → `OFFSET` → `LIMIT`). +/// +/// Used by the [DBAdapter]s that can't delegate the ordering and the +/// pagination to a DB engine, and by [IterableEntityRepository]. +/// +/// [zeroLimitIsUnlimited] selects how a `limit` of `0` is interpreted, since +/// the pre-existing call sites disagree: the generated SQL treats it as "no +/// `LIMIT` clause" (`true`), while the in-memory selects treat it as an empty +/// result (`false`, the default). Only relevant for a [limit] of exactly `0`. +Iterable applySelectOrderAndPagination( + Iterable itr, + Object? Function(T o) idGetter, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + bool zeroLimitIsUnlimited = false, +}) { + if (OrderDirection.resolveOrderByID(orderByID, offset)) { + var descending = OrderDirection.resolve(orderDirection).isDescending; + + var sorted = itr.toList(); + sorted.sort( + descending + ? (a, b) => compareEntityIDs(idGetter(b), idGetter(a)) + : (a, b) => compareEntityIDs(idGetter(a), idGetter(b)), + ); + itr = sorted; + } + + if (offset != null && offset > 0) { + itr = itr.skip(offset); + } + + if (limit != null && (zeroLimitIsUnlimited ? limit > 0 : limit >= 0)) { + itr = itr.take(limit); + } + + return itr; +} + abstract class EntityAccessor { static String simplifiedName(String name) { name = name.trim().toLowerCase().replaceAll(RegExp(r'[\W_]+'), '').trim(); @@ -3381,20 +3492,51 @@ abstract class EntitySource extends EntityAccessor { final ConditionParseCache _parseCache = ConditionParseCache.get(); + /// {@template bones_api.select_pagination} + /// Ordering and pagination: + /// - [limit]: the maximum number of returned entities. Also the page size + /// of [page]. + /// - [offset]: the return offset, for pagination. + /// - [page]: the 1-based page to return, an ergonomic alternative to + /// [offset]: it resolves to `(page - 1) * limit`. Requires a positive + /// [limit], and can't be combined with [offset]. + /// See [resolveSelectOffset]. + /// - [orderByID]: if `true` the result is ordered by the table ID column, + /// automatically resolved from the table scheme + /// ([TableScheme.idFieldName]) or from [EntityHandler.idFieldName]. + /// Defaults to `true` when [offset] is defined, since a paginated select + /// needs a stable order to be correct. Pass `false` to opt out. + /// - [orderDirection]: the [OrderDirection] of the ordering, + /// [OrderDirection.ascending] by default. + /// **Ignored while the ordering is not active** (see [orderByID]). + /// + /// Note that a query over a to-many relationship generates a `JOIN` without + /// a `DISTINCT`, so it can return the same entity more than once. Paginating + /// such a query is best-effort. + /// {@endtemplate} FutureOr selectFirstByQuery( String query, { Object? parameters, List? positionalParameters, Map? namedParameters, Transaction? transaction, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) => selectByQuery( query, parameters: parameters, namedParameters: namedParameters, transaction: transaction, limit: 1, + // The page size of a "first" select is 1, so `page: n` is the Nth entity: + offset: resolveSelectOffset(page: page, offset: offset, limit: 1), + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((result) => result.firstOrNull); + /// {@macro bones_api.select_pagination} FutureOr> selectByQuery( String query, { Object? parameters, @@ -3402,6 +3544,10 @@ abstract class EntitySource extends EntityAccessor { Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) { var condition = _parseCache.parseQuery(query); @@ -3412,9 +3558,13 @@ abstract class EntitySource extends EntityAccessor { namedParameters: namedParameters, transaction: transaction, limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, ); } + /// {@macro bones_api.select_pagination} FutureOr> select( EntityMatcher matcher, { Object? parameters, @@ -3422,9 +3572,14 @@ abstract class EntitySource extends EntityAccessor { Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }); + /// {@macro bones_api.select_pagination} FutureOr> selectIDsByQuery( String query, { Object? parameters, @@ -3432,6 +3587,10 @@ abstract class EntitySource extends EntityAccessor { Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) { var condition = _parseCache.parseQuery(query); @@ -3442,9 +3601,13 @@ abstract class EntitySource extends EntityAccessor { namedParameters: namedParameters, transaction: transaction, limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, ); } + /// {@macro bones_api.select_pagination} FutureOr> selectIDsBy( EntityMatcher matcher, { Object? parameters, @@ -3452,11 +3615,20 @@ abstract class EntitySource extends EntityAccessor { Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }); + /// {@macro bones_api.select_pagination} FutureOr> selectAll({ Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }); @@ -5463,6 +5635,7 @@ abstract class EntityRepository extends EntityAccessor ); } + /// {@macro bones_api.select_pagination} @override FutureOr selectFirstByQuery( String query, { @@ -5470,6 +5643,10 @@ abstract class EntityRepository extends EntityAccessor List? positionalParameters, Map? namedParameters, Transaction? transaction, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }) => selectByQuery( query, @@ -5477,9 +5654,14 @@ abstract class EntityRepository extends EntityAccessor namedParameters: namedParameters, transaction: transaction, limit: 1, + // The page size of a "first" select is 1, so `page: n` is the Nth entity: + offset: resolveSelectOffset(page: page, offset: offset, limit: 1), + orderByID: orderByID, + orderDirection: orderDirection, resolutionRules: resolutionRules, ).resolveMapped((result) => result.firstOrNull); + /// {@macro bones_api.select_pagination} @override FutureOr> selectByQuery( String query, { @@ -5488,6 +5670,10 @@ abstract class EntityRepository extends EntityAccessor Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }) { checkNotClosed(); @@ -5501,10 +5687,14 @@ abstract class EntityRepository extends EntityAccessor namedParameters: namedParameters, transaction: transaction, limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, resolutionRules: resolutionRules, ); } + /// {@macro bones_api.select_pagination} @override FutureOr> selectIDsByQuery( String query, { @@ -5513,6 +5703,10 @@ abstract class EntityRepository extends EntityAccessor Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) { checkNotClosed(); @@ -5525,6 +5719,9 @@ abstract class EntityRepository extends EntityAccessor namedParameters: namedParameters, transaction: transaction, limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, ); } @@ -7427,6 +7624,10 @@ abstract class IterableEntityRepository Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }) { checkNotClosed(); @@ -7437,6 +7638,9 @@ abstract class IterableEntityRepository positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, ); return trackEntities(os); @@ -7450,6 +7654,10 @@ abstract class IterableEntityRepository Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) { checkNotClosed(); @@ -7459,6 +7667,9 @@ abstract class IterableEntityRepository positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, ); var ids = entityHandler.getIDs(os); @@ -7470,11 +7681,20 @@ abstract class IterableEntityRepository FutureOr> selectAll({ Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }) { checkNotClosed(); - var os = all(limit: limit); + var os = all( + limit: limit, + offset: resolveSelectOffset(page: page, offset: offset, limit: limit), + orderByID: orderByID, + orderDirection: orderDirection, + ); return trackEntities(os); } @@ -7871,12 +8091,16 @@ abstract class IterableEntityRepository return del; } + /// {@macro bones_api.select_pagination} List matches( EntityMatcher matcher, { Object? parameters, List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }) { var itr = iterable().where((o) { return matcher.matchesEntity( @@ -7888,22 +8112,47 @@ abstract class IterableEntityRepository ); }); - if (limit != null && limit > 0) { - itr = itr.take(limit); - } - - return itr.toList(); + return _applyOrderAndPagination( + itr, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); } - List all({int? limit}) { - var itr = iterable(); - - if (limit != null && limit > 0) { - itr = itr.take(limit); - } + /// {@macro bones_api.select_pagination} + List all({ + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) => _applyOrderAndPagination( + iterable(), + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); - return itr.toList(); - } + List _applyOrderAndPagination( + Iterable itr, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) => + applySelectOrderAndPagination( + itr, + (o) => getID(o, entityHandler: entityHandler), + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + // Preserves the pre-existing behavior of this repository, + // where a `limit` of 0 means "no limit": + zeroLimitIsUnlimited: true, + ).toList(); @override Map information({bool extended = false}) { diff --git a/lib/src/bones_api_entity_db.dart b/lib/src/bones_api_entity_db.dart index 3a06214..0b7beaa 100644 --- a/lib/src/bones_api_entity_db.dart +++ b/lib/src/bones_api_entity_db.dart @@ -603,18 +603,28 @@ abstract class DBAdapter extends SchemeProvider PreFinishDBOperation?, R?>? preFinish, }); + /// {@macro bones_api.select_pagination} FutureOr> doSelectByIDs( TransactionOperation op, String entityName, String table, List ids, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }); + /// {@macro bones_api.select_pagination} FutureOr> doSelectAll( TransactionOperation op, String entityName, String table, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }); @@ -1356,23 +1366,45 @@ class DBRepositoryAdapter with Initializable { preFinish: preFinish, ); + /// {@macro bones_api.select_pagination} FutureOr> doSelectByIDs( TransactionOperation op, List ids, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => databaseAdapter.doSelectByIDs( op, name, tableName, ids, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, preFinish: preFinish, ); + /// {@macro bones_api.select_pagination} FutureOr> doSelectAll( TransactionOperation op, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, - }) => - databaseAdapter.doSelectAll(op, name, tableName, preFinish: preFinish); + }) => databaseAdapter.doSelectAll( + op, + name, + tableName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + preFinish: preFinish, + ); FutureOr doInsert( TransactionOperation op, @@ -1639,6 +1671,12 @@ class DBEntityRepository extends EntityRepository } } + /// Resolves a single-row select against a requested [offset]: any positive + /// offset skips the only row there is. + List _singleResult(O? res, int? offset) => + res != null && (offset == null || offset <= 0) ? [res] : []; + + /// {@macro bones_api.select_pagination} @override FutureOr> select( EntityMatcher matcher, { @@ -1647,15 +1685,22 @@ class DBEntityRepository extends EntityRepository Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }) { + offset = resolveSelectOffset(page: page, offset: offset, limit: limit); + if (matcher is ConditionID) { + // A single row: only a positive `offset` can change the result. return _selectByID( transaction, matcher, parameters ?? namedParameters, resolutionRules, - ).resolveMapped((res) => res != null ? [res] : []); + ).resolveMapped((res) => _singleResult(res, offset)); } if (matcher is ConditionIdIN) { @@ -1664,11 +1709,23 @@ class DBEntityRepository extends EntityRepository matcher, parameters ?? namedParameters, resolutionRules, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); } if (matcher is ConditionANY) { - return _selectAll(transaction, matcher, resolutionRules); + return _selectAll( + transaction, + matcher, + resolutionRules, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); } if (matcher is KeyConditionEQ) { @@ -1689,7 +1746,7 @@ class DBEntityRepository extends EntityRepository matcherID, parameters ?? namedParameters, resolutionRules, - ).resolveMapped((res) => res != null ? [res] : []); + ).resolveMapped((res) => _singleResult(res, offset)); } } } @@ -1708,20 +1765,37 @@ class DBEntityRepository extends EntityRepository Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) { + offset = resolveSelectOffset(page: page, offset: offset, limit: limit); + if (matcher is ConditionID) { var id = matcher.resolveIDValue( parameters: parameters ?? namedParameters, ); - return existsID( - id, - transaction: transaction, - ).resolveMapped((exists) => exists ? [id] : []); + // A single ID: only a positive `offset` can change the result. + return existsID(transaction: transaction, id).resolveMapped( + (exists) => exists && (offset == null || offset <= 0) ? [id] : [], + ); } if (matcher is ConditionIdIN) { var ids = matcher.idsValues.whereType().toList(); - return existIDs(ids, transaction: transaction); + // `existIDs` has no ordering/pagination hook, so it is applied here: + return existIDs(ids, transaction: transaction).resolveMapped( + (existing) => + applySelectOrderAndPagination( + existing, + (id) => id, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ).toList(), + ); } throw UnsupportedError( @@ -1784,8 +1858,12 @@ class DBEntityRepository extends EntityRepository Transaction? transaction, ConditionIdIN matcher, Object? parameters, - EntityResolutionRules? resolutionRules, - ) { + EntityResolutionRules? resolutionRules, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { var ids = matcher.idsValues.nonNulls.toList(); if (ids.isEmpty) return []; @@ -1807,6 +1885,10 @@ class DBEntityRepository extends EntityRepository return repositoryAdapter.doSelectByIDs( op, ids, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, preFinish: (results) { return resolveEntities( op.transaction, @@ -1829,8 +1911,12 @@ class DBEntityRepository extends EntityRepository FutureOr> _selectAll( Transaction? transaction, ConditionANY matcher, - EntityResolutionRules? resolutionRules, - ) { + EntityResolutionRules? resolutionRules, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { final resolutionRulesResolved = resolveEntityResolutionRules( resolutionRules, ); @@ -1848,6 +1934,10 @@ class DBEntityRepository extends EntityRepository try { return repositoryAdapter.doSelectAll( op, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, preFinish: (results) { return resolveEntities( op.transaction, @@ -1870,8 +1960,22 @@ class DBEntityRepository extends EntityRepository FutureOr> selectAll({ Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, - }) => select(ConditionANY(), limit: limit, resolutionRules: resolutionRules); + // NOTE: `transaction` is not forwarded, preserving the pre-existing + // behavior of this method. + }) => select( + ConditionANY(), + limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, + resolutionRules: resolutionRules, + ); @override bool hasReferencedEntities([EntityResolutionRulesResolved? resolutionRules]) { diff --git a/lib/src/bones_api_entity_db_memory.dart b/lib/src/bones_api_entity_db_memory.dart index c807544..b7b462c 100644 --- a/lib/src/bones_api_entity_db_memory.dart +++ b/lib/src/bones_api_entity_db_memory.dart @@ -877,10 +877,17 @@ class DBSQLMemoryAdapter extends DBSQLAdapter }); } - final limit = sql.limit; - + // The `ORDER BY` must be applied before the `OFFSET`/`LIMIT`, + // which is what makes an offset-based pagination stable: var sel = - limit != null && limit >= 0 ? itr.take(limit).toList() : itr.toList(); + applySelectOrderAndPagination( + itr, + (e) => e[tableScheme?.idFieldName ?? 'id'], + limit: sql.limit, + offset: sql.offset, + orderByID: sql.orderByID, + orderDirection: sql.orderDirection, + ).toList(); return sel; } diff --git a/lib/src/bones_api_entity_db_mysql.dart b/lib/src/bones_api_entity_db_mysql.dart index 37f48ad..f08b113 100644 --- a/lib/src/bones_api_entity_db_mysql.dart +++ b/lib/src/bones_api_entity_db_mysql.dart @@ -109,6 +109,8 @@ class DBMySQLAdapter extends DBSQLAdapter acceptsTemporaryTableForReturning: true, acceptsInsertIgnore: true, createIndexIfNotExists: false, + // MySQL can't parse an `OFFSET` without a preceding `LIMIT`: + offsetRequiresLimit: true, ), transactions: true, transactionAbort: true, diff --git a/lib/src/bones_api_entity_db_object_directory.dart b/lib/src/bones_api_entity_db_object_directory.dart index b9bdd87..86283ea 100644 --- a/lib/src/bones_api_entity_db_object_directory.dart +++ b/lib/src/bones_api_entity_db_object_directory.dart @@ -17,6 +17,7 @@ import 'bones_api_entity_db_object.dart'; import 'bones_api_entity_reference.dart'; import 'bones_api_extension.dart'; import 'bones_api_logging.dart'; +import 'bones_api_types.dart'; final _log = logging.Logger('DBObjectDirectoryAdapter')..registerAsDbLogger(); @@ -485,6 +486,10 @@ class DBObjectDirectoryAdapter String entityName, String table, List ids, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => executeTransactionOperation>( op, @@ -492,21 +497,36 @@ class DBObjectDirectoryAdapter table, ids, entityName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((res) => _finishOperation(op, res, preFinish)), ); FutureOr>> _doSelectByIDsImpl( String table, List ids, - String entityName, - ) async { + String entityName, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) async { var tableDir = _resolveTableDirectory(table); if (!tableDir.existsSync()) return []; var entries = await ids.map((id) => _readObject(table, id)).resolveAllNotNull(); - return entries; + return _applyOrderAndPagination( + table, + entries, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); } @override @@ -514,19 +534,31 @@ class DBObjectDirectoryAdapter TransactionOperation op, String entityName, String table, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => executeTransactionOperation>( op, (conn) => _doSelectAllImpl( table, entityName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((res) => _finishOperation(op, res, preFinish)), ); FutureOr>> _doSelectAllImpl( String table, - String entityName, - ) { + String entityName, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { var tableDir = _resolveTableDirectory(table); if (!tableDir.existsSync()) return []; @@ -539,7 +571,36 @@ class DBObjectDirectoryAdapter return _readObject(table, id); }).resolveAllNotNull(); - return entries; + return entries.resolveMapped( + (entries) => _applyOrderAndPagination( + table, + entries, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ), + ); + } + + List> _applyOrderAndPagination( + String table, + Iterable> entries, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { + var idField = _getTableIDFieldName(table); + + return applySelectOrderAndPagination( + entries, + (e) => e[idField], + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ).toList(); } @override diff --git a/lib/src/bones_api_entity_db_object_gcs.dart b/lib/src/bones_api_entity_db_object_gcs.dart index e5f2c91..881f2a9 100644 --- a/lib/src/bones_api_entity_db_object_gcs.dart +++ b/lib/src/bones_api_entity_db_object_gcs.dart @@ -26,6 +26,7 @@ import 'bones_api_entity_reference.dart'; import 'bones_api_extension.dart'; import 'bones_api_extension_io.dart'; import 'bones_api_logging.dart'; +import 'bones_api_types.dart'; final _log = logging.Logger('DBObjectGCSAdapter')..registerAsDbLogger(); @@ -579,6 +580,10 @@ class DBObjectGCSAdapter extends DBObjectAdapter { String entityName, String table, List ids, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => executeTransactionOperation>( op, @@ -586,18 +591,33 @@ class DBObjectGCSAdapter extends DBObjectAdapter { table, ids, entityName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((res) => _finishOperation(op, res, preFinish)), ); FutureOr>> _doSelectByIDsImpl( String table, List ids, - String entityName, - ) async { + String entityName, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) async { var entries = await ids.map((id) => _readObject(table, id)).resolveAllNotNull(); - return entries; + return _applyOrderAndPagination( + table, + entries, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); } @override @@ -605,29 +625,68 @@ class DBObjectGCSAdapter extends DBObjectAdapter { TransactionOperation op, String entityName, String table, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => executeTransactionOperation>( op, (conn) => _doSelectAllImpl( table, entityName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((res) => _finishOperation(op, res, preFinish)), ); Future>> _doSelectAllImpl( String table, - String entityName, - ) async { + String entityName, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) async { var files = await _listTableFiles(table); var entries = - files.map((f) { + await files.map((f) { var fileName = f.split('/').last; var id = pack_path.withoutExtension(fileName); return _readObject(table, id); }).resolveAllNotNull(); - return entries; + return _applyOrderAndPagination( + table, + entries, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); + } + + List> _applyOrderAndPagination( + String table, + Iterable> entries, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { + var idField = _getTableIDFieldName(table); + + return applySelectOrderAndPagination( + entries, + (e) => e[idField], + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ).toList(); } @override diff --git a/lib/src/bones_api_entity_db_object_memory.dart b/lib/src/bones_api_entity_db_object_memory.dart index e9a79d8..0aaf238 100644 --- a/lib/src/bones_api_entity_db_object_memory.dart +++ b/lib/src/bones_api_entity_db_object_memory.dart @@ -14,6 +14,7 @@ import 'bones_api_entity_db_object.dart'; import 'bones_api_entity_reference.dart'; import 'bones_api_extension.dart'; import 'bones_api_logging.dart'; +import 'bones_api_types.dart'; final _log = logging.Logger('DBObjectMemoryAdapter')..registerAsDbLogger(); @@ -533,6 +534,10 @@ class DBObjectMemoryAdapter String entityName, String table, List ids, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => executeTransactionOperation>( op, @@ -540,14 +545,22 @@ class DBObjectMemoryAdapter table, ids, entityName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((res) => _finishOperation(op, res, preFinish)), ); FutureOr>> _doSelectByIDsImpl( String table, List ids, - String entityName, - ) { + String entityName, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { var map = _getTableMap(table, false); if (map == null) return []; @@ -565,7 +578,14 @@ class DBObjectMemoryAdapter .nonNulls .toList(); - return entries; + return _applyOrderAndPagination( + table, + entries, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); } @override @@ -573,24 +593,62 @@ class DBObjectMemoryAdapter TransactionOperation op, String entityName, String table, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) => executeTransactionOperation>( op, (conn) => _doSelectAllImpl( table, entityName, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((res) => _finishOperation(op, res, preFinish)), ); FutureOr>> _doSelectAllImpl( String table, - String entityName, - ) { + String entityName, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { var map = _getTableMap(table, false); if (map == null) return []; - var entries = map.values.toList(); - return entries; + return _applyOrderAndPagination( + table, + map.values, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); + } + + List> _applyOrderAndPagination( + String table, + Iterable> entries, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { + var idField = _getTableIDFieldName(table); + + return applySelectOrderAndPagination( + entries, + (e) => e[idField], + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ).toList(); } @override diff --git a/lib/src/bones_api_entity_db_relational.dart b/lib/src/bones_api_entity_db_relational.dart index 790b93e..aa4f634 100644 --- a/lib/src/bones_api_entity_db_relational.dart +++ b/lib/src/bones_api_entity_db_relational.dart @@ -13,6 +13,7 @@ import 'bones_api_entity_reference.dart'; import 'bones_api_entity_rules.dart'; import 'bones_api_extension.dart'; import 'bones_api_logging.dart'; +import 'bones_api_types.dart'; import 'bones_api_utils.dart'; final _log = logging.Logger('DBRelationalAdapter')..registerAsDbLogger(); @@ -135,6 +136,9 @@ abstract class DBRelationalAdapter extends DBAdapter { List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, R>? preFinish, }); @@ -147,6 +151,9 @@ abstract class DBRelationalAdapter extends DBAdapter { List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }); FutureOr doInsertRelationship( @@ -201,6 +208,9 @@ class DBRelationalRepositoryAdapter extends DBRepositoryAdapter { List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, R>? preFinish, }) => databaseAdapter.doSelect( op, @@ -211,6 +221,9 @@ class DBRelationalRepositoryAdapter extends DBRepositoryAdapter { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, preFinish: preFinish, ); @@ -221,6 +234,9 @@ class DBRelationalRepositoryAdapter extends DBRepositoryAdapter { List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }) => databaseAdapter.doSelectIDsBy( op, name, @@ -230,6 +246,9 @@ class DBRelationalRepositoryAdapter extends DBRepositoryAdapter { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); FutureOr doInsertRelationship( @@ -482,9 +501,15 @@ class DBRelationalEntityRepository Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, }) { checkNotClosed(); + offset = resolveSelectOffset(page: page, offset: offset, limit: limit); + var op = TransactionOperationSelect( name, false, @@ -501,6 +526,9 @@ class DBRelationalEntityRepository positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); } catch (e, s) { var message = @@ -523,10 +551,16 @@ class DBRelationalEntityRepository Map? namedParameters, Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, }) { checkNotClosed(); + offset = resolveSelectOffset(page: page, offset: offset, limit: limit); + final resolutionRulesResolved = resolveEntityResolutionRules( resolutionRules, ); @@ -549,6 +583,9 @@ class DBRelationalEntityRepository positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, preFinish: (results) { return resolveEntities( op.transaction, @@ -574,8 +611,22 @@ class DBRelationalEntityRepository FutureOr> selectAll({ Transaction? transaction, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, EntityResolutionRules? resolutionRules, - }) => select(ConditionANY(), limit: limit, resolutionRules: resolutionRules); + // NOTE: `transaction` is not forwarded, preserving the pre-existing + // behavior of this method. + }) => select( + ConditionANY(), + limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, + resolutionRules: resolutionRules, + ); @override bool isStored(O o, {Transaction? transaction}) { diff --git a/lib/src/bones_api_entity_db_sql.dart b/lib/src/bones_api_entity_db_sql.dart index 55ce120..8aadaba 100644 --- a/lib/src/bones_api_entity_db_sql.dart +++ b/lib/src/bones_api_entity_db_sql.dart @@ -167,6 +167,17 @@ class SQL implements SQLWrapper { final int? limit; + /// The return offset of this select (`OFFSET`), or `null` for no offset. + final int? offset; + + /// If this select is ordered by the table ID column. `null` means unset, + /// resolved by [OrderDirection.resolveOrderByID] as `offset != null`. + final bool? orderByID; + + /// The [OrderDirection] of the ordering. + /// Only applies while the ordering is active. See [orderByID]. + final OrderDirection? orderDirection; + final Map? returnColumnsAliases; final String? mainTable; @@ -216,6 +227,9 @@ class SQL implements SQLWrapper { this.returnColumns, this.returnColumnsAliases, this.limit, + this.offset, + this.orderByID, + this.orderDirection, required this.mainTable, this.relationship, this.tablesAliases, @@ -240,6 +254,9 @@ class SQL implements SQLWrapper { returnColumns: returnColumns?.toSet(), returnColumnsAliases: returnColumnsAliases?.map((k, v) => MapEntry(k, v)), limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, mainTable: mainTable, relationship: relationship, tablesAliases: tablesAliases?.map((k, v) => MapEntry(k, v)), @@ -2585,6 +2602,36 @@ abstract class DBSQLAdapter extends DBRelationalAdapter }); } + /// Builds the ` ORDER BY ... LIMIT ... OFFSET ...` tail of a `SELECT`, + /// shared by [generateSelectSQL] and [generateSelectIDsSQL]. + /// + /// The `ORDER BY` column is the ID column of the main table, already + /// resolved by [_generateSQLFrom] (through [TableScheme.idFieldName]) and + /// exposed as [EncodingContext.tableFieldID]. + String _generateSelectTailSQL( + EncodingContext context, + String tableAlias, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, + }) { + var tail = ''; + + if (OrderDirection.resolveOrderByID(orderByID, offset)) { + var tableFieldID = context.tableFieldID ?? 'id'; + tail += dialect.orderBySQL( + tableAlias, + tableFieldID, + direction: orderDirection, + ); + } + + tail += dialect.limitOffsetSQL(limit: limit, offset: offset); + + return tail; + } + FutureOr generateSelectSQL( Transaction transaction, String entityName, @@ -2594,6 +2641,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }) { return _generateSQLFrom( transaction, @@ -2604,11 +2654,21 @@ abstract class DBSQLAdapter extends DBRelationalAdapter positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, sqlBuilder: (String from, EncodingContext context) { var tableAlias = context.resolveEntityAlias(table); var q = dialect.elementQuote; - var limitStr = limit != null && limit > 0 ? ' LIMIT $limit' : ''; - var sql = 'SELECT $q$tableAlias$q.* $from$limitStr'; + var tail = _generateSelectTailSQL( + context, + tableAlias, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); + var sql = 'SELECT $q$tableAlias$q.* $from$tail'; return sql; }, ); @@ -2623,6 +2683,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }) { return _generateSQLFrom( transaction, @@ -2633,13 +2696,23 @@ abstract class DBSQLAdapter extends DBRelationalAdapter positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, sqlBuilder: (String from, EncodingContext context) { var tableAlias = context.resolveEntityAlias(table); var tableFieldID = context.tableFieldID ?? 'id'; var q = dialect.elementQuote; - var limitStr = limit != null && limit > 0 ? ' LIMIT $limit' : ''; + var tail = _generateSelectTailSQL( + context, + tableAlias, + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, + ); var sql = - 'SELECT $q$tableAlias$q.$q$tableFieldID$q as ${q}id$q $from$limitStr'; + 'SELECT $q$tableAlias$q.$q$tableFieldID$q as ${q}id$q $from$tail'; return sql; }, ); @@ -2654,6 +2727,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, required String Function(String from, EncodingContext context) sqlBuilder, }) { if (matcher is! Condition) { @@ -2707,6 +2783,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter mainTable: table, tablesAliases: encodedSQL.tableAliases, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); } else { var joins = <_JoinEntry>[]; @@ -2905,6 +2984,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter mainTable: table, tablesAliases: encodedSQL.tableAliases, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); } }); @@ -3232,6 +3314,10 @@ abstract class DBSQLAdapter extends DBRelationalAdapter String entityName, String table, List ids, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) { return generateSelectSQL( @@ -3239,6 +3325,10 @@ abstract class DBSQLAdapter extends DBRelationalAdapter entityName, table, ConditionIdIN(ids), + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((sql) { return selectSQL( op, @@ -3254,6 +3344,10 @@ abstract class DBSQLAdapter extends DBRelationalAdapter TransactionOperation op, String entityName, String table, { + int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, List>? preFinish, }) { return generateSelectSQL( @@ -3261,6 +3355,10 @@ abstract class DBSQLAdapter extends DBRelationalAdapter entityName, table, ConditionANY(), + limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((sql) { return selectSQL( op, @@ -3281,6 +3379,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, PreFinishDBOperation>, R>? preFinish, }) { return generateSelectSQL( @@ -3292,6 +3393,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((sql) { return selectSQL( op, @@ -3312,6 +3416,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }) { return generateSelectIDsSQL( op.transaction, @@ -3322,6 +3429,9 @@ abstract class DBSQLAdapter extends DBRelationalAdapter positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ).resolveMapped((sql) { return selectSQL(op, entityName, table, sql).resolveMapped( (r) => _finishSQLOperation(sql, op, r, (results) { @@ -3768,6 +3878,9 @@ class DBSQLRepositoryAdapter extends DBRelationalRepositoryAdapter { List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + bool? orderByID, + OrderDirection? orderDirection, }) => databaseAdapter.generateSelectSQL( transaction, name, @@ -3777,6 +3890,9 @@ class DBSQLRepositoryAdapter extends DBRelationalRepositoryAdapter { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + orderByID: orderByID, + orderDirection: orderDirection, ); FutureOr>> selectSQL( diff --git a/lib/src/bones_api_repository.dart b/lib/src/bones_api_repository.dart index a9ec800..4e45ef6 100644 --- a/lib/src/bones_api_repository.dart +++ b/lib/src/bones_api_repository.dart @@ -5,6 +5,7 @@ import 'bones_api_condition.dart'; import 'bones_api_entity.dart'; import 'bones_api_entity_rules.dart'; import 'bones_api_initializable.dart'; +import 'bones_api_types.dart'; /// A entity repository API. abstract class APIRepository with Initializable { @@ -123,23 +124,37 @@ abstract class APIRepository with Initializable { resolutionRules: resolutionRules, ); + /// {@macro bones_api.select_pagination} FutureOr> selectAll({ int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, }) => entityRepository.selectAll( limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, ); FutureOr length() => entityRepository.length(); + /// {@macro bones_api.select_pagination} FutureOr selectFirstByQuery( String query, { Object? parameters, List? positionalParameters, Map? namedParameters, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, }) => entityRepository.selectFirstByQuery( @@ -147,16 +162,25 @@ abstract class APIRepository with Initializable { parameters: parameters, positionalParameters: positionalParameters, namedParameters: namedParameters, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, ); + /// {@macro bones_api.select_pagination} FutureOr> selectByQuery( String query, { Object? parameters, List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, }) => entityRepository.selectByQuery( @@ -165,16 +189,25 @@ abstract class APIRepository with Initializable { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, ); + /// {@macro bones_api.select_pagination} FutureOr> selectIDsByQuery( String query, { Object? parameters, List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, Transaction? transaction, }) => entityRepository.selectIDsByQuery( query, @@ -182,15 +215,24 @@ abstract class APIRepository with Initializable { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, transaction: transaction, ); + /// {@macro bones_api.select_pagination} FutureOr> selectIDsBy( EntityMatcher matcher, { Object? parameters, List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, Transaction? transaction, }) => entityRepository.selectIDsBy( matcher, @@ -198,15 +240,24 @@ abstract class APIRepository with Initializable { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, transaction: transaction, ); + /// {@macro bones_api.select_pagination} FutureOr> select( EntityMatcher matcher, { Object? parameters, List? positionalParameters, Map? namedParameters, int? limit, + int? offset, + int? page, + bool? orderByID, + OrderDirection? orderDirection, Transaction? transaction, EntityResolutionRules? resolutionRules, }) => entityRepository.select( @@ -215,6 +266,10 @@ abstract class APIRepository with Initializable { positionalParameters: positionalParameters, namedParameters: namedParameters, limit: limit, + offset: offset, + page: page, + orderByID: orderByID, + orderDirection: orderDirection, transaction: transaction, resolutionRules: resolutionRules, ); diff --git a/lib/src/bones_api_sql_builder.dart b/lib/src/bones_api_sql_builder.dart index 137a620..0b40bc9 100644 --- a/lib/src/bones_api_sql_builder.dart +++ b/lib/src/bones_api_sql_builder.dart @@ -111,6 +111,18 @@ class SQLDialect extends DBDialect { /// Whether the SQL dialect supports `IF NOT EXISTS` on `CREATE INDEX`. final bool createIndexIfNotExists; + /// If `true` this dialect can't parse an `OFFSET` clause that is not preceded + /// by a `LIMIT` clause (MySQL). See [offsetMaxLimitValue]. + final bool offsetRequiresLimit; + + /// The maximum row count accepted by a `LIMIT` clause of this dialect, + /// used as the `LIMIT` value when only an `offset` was requested and + /// [offsetRequiresLimit] is `true`. + /// + /// Declared as a [String] and not an [int] because MySQL's documented value + /// (`18446744073709551615`, 2^64-1) is out of range of a Dart [int]. + final String offsetMaxLimitValue; + const SQLDialect( super.name, { this.elementQuote = '', @@ -123,8 +135,48 @@ class SQLDialect extends DBDialect { this.acceptsVarcharWithoutMaximumSize = false, this.foreignKeyCreatesImplicitIndex = true, this.createIndexIfNotExists = true, + this.offsetRequiresLimit = false, + this.offsetMaxLimitValue = '18446744073709551615', }); + /// Builds the ` ORDER BY . ASC|DESC` clause + /// of this dialect. + String orderBySQL( + String tableAlias, + String column, { + OrderDirection? direction, + }) { + var q = elementQuote; + var keyword = OrderDirection.resolve(direction).sqlKeyword; + return ' ORDER BY $q$tableAlias$q.$q$column$q $keyword'; + } + + /// Builds the ` LIMIT OFFSET ` clause of this dialect. + /// Returns an empty `String` when neither [limit] nor [offset] applies. + /// + /// Only positive values are emitted (`limit > 0` and `offset > 0`), + /// preserving the pre-existing `LIMIT` behavior. + /// + /// When only an [offset] applies and [offsetRequiresLimit] is `true`, emits + /// `LIMIT OFFSET `, since such a dialect can't + /// parse a standalone `OFFSET`. + String limitOffsetSQL({int? limit, int? offset}) { + var hasLimit = limit != null && limit > 0; + var hasOffset = offset != null && offset > 0; + + if (!hasOffset) { + return hasLimit ? ' LIMIT $limit' : ''; + } + + if (hasLimit) { + return ' LIMIT $limit OFFSET $offset'; + } + + return offsetRequiresLimit + ? ' LIMIT $offsetMaxLimitValue OFFSET $offset' + : ' OFFSET $offset'; + } + @override bool operator ==(Object other) => identical(this, other) || @@ -147,7 +199,9 @@ class SQLDialect extends DBDialect { 'acceptsInsertIgnore: $acceptsInsertIgnore, ' 'acceptsInsertOnConflict: $acceptsInsertOnConflict, ' 'foreignKeyCreatesImplicitIndex: $foreignKeyCreatesImplicitIndex, ' - 'createIndexIfNotExists: $createIndexIfNotExists' + 'createIndexIfNotExists: $createIndexIfNotExists, ' + 'offsetRequiresLimit: $offsetRequiresLimit, ' + 'offsetMaxLimitValue: $offsetMaxLimitValue' '}'; } } diff --git a/lib/src/bones_api_types.dart b/lib/src/bones_api_types.dart index a8809bf..fac65c0 100644 --- a/lib/src/bones_api_types.dart +++ b/lib/src/bones_api_types.dart @@ -6,6 +6,71 @@ import 'package:statistics/statistics.dart'; import 'bones_api_utils.dart'; +/// The direction of the "order by" clause of a select operation. +/// +/// Used by the `orderDirection` parameter of the `select*` methods. +/// Only has effect while the ordering is active (see the `orderByID` +/// parameter and [resolveOrderByID]). +enum OrderDirection { + /// Ascending order (SQL `ASC`). The [defaultDirection]. + ascending, + + /// Descending order (SQL `DESC`). + descending; + + /// The direction used when none is defined: [ascending]. + static const OrderDirection defaultDirection = ascending; + + /// Returns `true` if this is [ascending]. + bool get isAscending => this == ascending; + + /// Returns `true` if this is [descending]. + bool get isDescending => this == descending; + + /// The SQL keyword of this direction: `ASC` or `DESC`. + String get sqlKeyword => isDescending ? 'DESC' : 'ASC'; + + /// Resolves a nullable [direction] to a non-null one, + /// defaulting to [defaultDirection]. + static OrderDirection resolve(OrderDirection? direction) => + direction ?? defaultDirection; + + /// Resolves if a select operation should order by the table ID column. + /// + /// Returns [orderByID] when defined. Otherwise returns `true` if an [offset] + /// is defined, since a paginated select needs a stable order to be correct. + static bool resolveOrderByID(bool? orderByID, int? offset) => + orderByID ?? (offset != null); + + /// Parses [o] to an [OrderDirection], or returns `null` if it can't + /// be resolved. + /// + /// Accepts (case-insensitive and trimmed): `asc`, `ascending`, `up`, `+`, + /// `desc`, `descending`, `down`, `-`. Also accepts an [OrderDirection]. + static OrderDirection? parse(Object? o) { + if (o == null) return null; + if (o is OrderDirection) return o; + + var s = o.toString().trim().toLowerCase(); + if (s.isEmpty) return null; + + switch (s) { + case 'asc': + case 'ascending': + case 'up': + case '+': + return ascending; + case 'desc': + case 'descending': + case 'down': + case '-': + return descending; + default: + return null; + } + } +} + /// A [Time] represents the time of the day, /// independently of the day of the year, timezone or [DateTime]. class Time implements Comparable