From ac67146afc768930f3fd1d4f2f2e9a474fb02a11 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sat, 1 Aug 2026 14:30:26 -0300 Subject: [PATCH 01/13] feat: OrderDirection + select ordering/pagination primitives Foundations for the upcoming `offset`/`orderByID`/`orderDirection` parameters of `selectByQuery` and its siblings. Purely additive: nothing reads these yet, so the generated SQL and every existing behavior are unchanged. - `OrderDirection` (`bones_api_types.dart`): `ascending` (default) / `descending`, with `sqlKeyword`, `parse` and the two resolvers that state the semantics exactly once: - `resolve(direction)` -> defaults to `ascending`. - `resolveOrderByID(orderByID, offset)` -> `orderByID ?? (offset != null)`, so a paginated select is ordered (and therefore stable) by default, and `orderByID: false` opts out. - `compareEntityIDs` and `applySelectOrderAndPagination` (`bones_api_entity.dart`): the shared Dart-side "order by ID, then skip, then take" used by the adapters that can't delegate to a DB engine. `zeroLimitIsUnlimited` exists because the pre-existing call sites disagree on a `limit` of 0 (SQL: no clause; in-memory: empty result); both behaviors are preserved. - `SQLDialect` (`bones_api_sql_builder.dart`): `orderBySQL` and `limitOffsetSQL` clause builders, plus `offsetRequiresLimit` / `offsetMaxLimitValue`. MySQL sets `offsetRequiresLimit: true` since it can't parse an `OFFSET` without a preceding `LIMIT`; it gets `LIMIT 18446744073709551615 OFFSET n` instead. - `SQL`: `offset`, `orderByID` and `orderDirection` fields (+ `copy()`). Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/bones_api_entity.dart | 71 ++++ lib/src/bones_api_entity_db_mysql.dart | 2 + lib/src/bones_api_entity_db_sql.dart | 17 + lib/src/bones_api_sql_builder.dart | 56 ++- lib/src/bones_api_types.dart | 65 ++++ test/bones_api_entity_select_order_test.dart | 337 +++++++++++++++++++ 6 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 test/bones_api_entity_select_order_test.dart diff --git a/lib/src/bones_api_entity.dart b/lib/src/bones_api_entity.dart index 2799a8d..8b250c5 100644 --- a/lib/src/bones_api_entity.dart +++ b/lib/src/bones_api_entity.dart @@ -3307,6 +3307,77 @@ 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; +} + +/// 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(); 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_sql.dart b/lib/src/bones_api_entity_db_sql.dart index 55ce120..79af97c 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)), 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