feat: offset / page / orderByID / orderDirection on selectByQuery and siblings (v1.11.0) - #145
Merged
Merged
Conversation
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) <noreply@anthropic.com>
Adds the 3 new optional parameters across the select surface, threaded along the exact route the existing `limit` already travels. All defaults preserve today's behavior: with `offset`, `orderByID` and `orderDirection` all unset the generated SQL is character-identical. Semantics: - Effective ordering is `orderByID ?? (offset != null)`, so a paginated select is stable by default; `orderByID: false` opts out. - `orderDirection` is ignored while the ordering is not active. SQL generation (`bones_api_entity_db_sql.dart`): `_generateSelectTailSQL` builds the ` ORDER BY ... LIMIT ... OFFSET ...` tail shared by `generateSelectSQL` and `generateSelectIDsSQL`, delegating the dialect-specific syntax to `SQLDialect`. The `ORDER BY` column is the main table's ID, reusing the resolution that was already in `_generateSQLFrom` (`TableScheme.idFieldName` -> `EncodingContext.tableFieldID`) -- no new machinery. Threaded through: `EntitySource` / `EntityRepository` / `IterableEntityRepository` (`bones_api_entity.dart`), `APIRepository`, `DBEntityRepository`, `DBRelationalAdapter` / `DBRelationalRepositoryAdapter` / `DBRelationalEntityRepository`, `DBSQLAdapter.doSelect` / `doSelectIDsBy` and `DBSQLRepositoryAdapter.generateSelectSQL`. Executed by `DBSQLMemoryAdapter._selectEntries` and by `IterableEntityRepository.matches`/`all`, both via the shared `applySelectOrderAndPagination` (order -> offset -> limit, matching SQL semantics). `IterableEntityRepository` was updated here rather than in a follow-up because Dart requires an override to accept every named parameter of the supertype. `DBEntityRepository.select`'s ConditionID/ConditionIdIN/ConditionANY fast paths still drop them, exactly as they already drop `limit`; that is the next commit. Tests: - `bones_api_entity_db_sql_select_test.dart`: exact generated SQL per case (incl. `copy()` round-trip and the PostgreSQL/MySQL dialect shapes) plus end-to-end paging over `DBSQLEntityRepository[memory]`. - `bones_api_entity_test.dart`: `SetEntityRepository` ordering/pagination. - `bones_api_entity_db_tests_base.dart`: two tests in the shared adapter template, so memory, PostgreSQL and MySQL all assert the generated SQL and real page-by-page reads. Verified against real PostgreSQL and MySQL containers, which covers the MySQL `LIMIT 18446744073709551615 OFFSET n` path (MySQL can't parse a standalone `OFFSET`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the dead ends where the select options were accepted and silently discarded, so `offset`/`orderByID`/`orderDirection` now work on every adapter -- not just the SQL ones. BEHAVIOR CHANGE: `limit` starts applying where it was previously ignored. `selectAll(limit: 2)` on an object adapter returned *every* row before this commit; it now returns 2. - `DBAdapter.doSelectByIDs`/`doSelectAll` and the `DBRepositoryAdapter` pass-throughs gained the 4 options. - `DBEntityRepository.select` stops dropping them: `_selectByIDs` and `_selectAll` forward them, and the single-row `ConditionID` / `KeyConditionEQ`-on-id paths honor a positive `offset` (which skips the only row there is) via `_singleResult`. - `DBEntityRepository.selectIDsBy`'s `ConditionIdIN` branch applies them in Dart, since `existIDs` has no pagination hook. - `DBSQLAdapter.doSelectByIDs`/`doSelectAll` forward into `generateSelectSQL` -- they previously passed no `limit` at all. - `DBObjectMemoryAdapter`, `DBObjectDirectoryAdapter` and `DBObjectGCSAdapter` apply them in their `_doSelect*Impl`, each through a local `_applyOrderAndPagination` built on the shared `applySelectOrderAndPagination` and the `_getTableIDFieldName` helper each adapter already had. Tests: a `Pagination [objectAdapter]` test in the shared adapter template, driving `photoAPIRepository`. `Photo` has a `String` ID, so it also covers the non-numeric branch of `compareEntityIDs`. It includes an explicit regression assertion for the `limit` change above, and covers the `ConditionIdIN` and single-row `ConditionID` fast paths. Runs against `DBObjectMemoryAdapter` (memory/PostgreSQL/MySQL suites) and `DBObjectDirectoryAdapter` (directory suite) -- all verified, with PostgreSQL and MySQL on real containers. `DBObjectGCSAdapter` has no test suite in this repo (it had none before either), so its change is compile-checked and review-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `/db/select/<table>` endpoint could only return the whole table: it selected *every* row and then sorted it by ID in Dart. It now pushes the ordering and the pagination down to the DB. - The ad-hoc `EAGER=true` string surgery is replaced by `_extractQueryDirective`, which pulls a `KEY=VALUE` token out of the `&`-joined query `String` and returns the rest as the entity condition query. `EAGER=true` keeps behaving exactly as before, at any position. - New directives (see `APIDBModule.selectQueryDirectives`): `LIMIT=<n>`, `OFFSET=<n>` and `ORDER=asc|desc`. An unparsable value is ignored. `select` also takes them as arguments, which take precedence over the query `String`. - The manual Dart-side sort is gone, replaced by `orderByID: true`. That default is deliberate -- this endpoint has always returned ID-sorted entities, so it defaults to `true` rather than to the `offset != null` rule used everywhere else, keeping the output of existing calls unchanged. Tests: `bones_api_db_module_test.dart` is new -- `APIDBModule` had no test coverage at all. It asserts the default ID ordering (a regression guard for the removed sort), each directive, paging through the full set, `EAGER=true` combined with the new directives, an entity condition query preserved alongside them, and unparsable values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents the new `offset`/`orderByID`/`orderDirection` parameters, the `OrderDirection` enum, the `SQLDialect` clause builders and capabilities, and the `APIDBModule.select` query directives. Called out explicitly in the CHANGELOG: - the behavior change where `limit` starts applying on the paths that previously ignored it (object adapters, `doSelectAll`/`doSelectByIDs`, the `DBEntityRepository.select` fast paths); - that adding named parameters to abstract members is source-breaking for third-party `EntityRepository`/`DBAdapter` subclasses, since Dart requires an override to accept every named parameter of the supertype -- hence a minor bump rather than a patch; - the pre-existing missing `DISTINCT` on to-many `JOIN`s, which makes paginating such a query best-effort. The version is bumped in both `pubspec.yaml` and the `VERSION` constant of `bones_api_base.dart`, which `bones_api_version_test.dart` keeps in sync. `bump.sh`/`dart_bump` is not used here: it drives the publish flow and needs an API key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ergonomic alternative to `offset` that computes it from the page size: `offset = (page - 1) * limit`. `page: 3, limit: 20` is `offset: 40`. `page` resolves to an `offset` at the repository layer via the new shared `resolveSelectOffset`, so it is a public convenience only: the adapter contract, `SQL`, `SQLDialect`, the SQL generation and every DB adapter still take `offset` alone. No second source-breaking widening of the abstract adapter methods. Validation (all `ArgumentError`, so mistakes fail at the call site rather than silently returning the wrong rows): - `page` with an `offset` -> they are two spellings of one thing, so passing both is a bug, not a precedence question. - `page` without a positive `limit` -> a page has no meaning without a page size, and a `limit` of 0 already means "no limit" here. Includes `page: 1`, so the rule stays predictable. - `page < 1` -> pages are numbered from 1; a 0-based caller silently getting page 1 would be an off-by-one that never surfaces. `page: 1` resolves to `offset: 0`, which still activates `orderByID` under the existing `orderByID ?? (offset != null)` rule, so even the first page is stable. `selectFirstByQuery` forces `limit: 1`, so `page: n` there is the Nth entity. `APIDBModule.select` gains a `PAGE=<n>` directive. It resolves the page itself so an invalid `PAGE` becomes an error response instead of an uncaught `ArgumentError`. Tests: `resolveSelectOffset` unit tests (all three error paths, the 1-based arithmetic, and that page 1 activates the ordering); end-to-end `page` paging plus the error paths over the in-memory SQL adapter and via the shared adapter template, so PostgreSQL and MySQL cover it too (verified on real containers); `PAGE` directive and its error responses in the module suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not related to this branch's feature: `reflection_factory` 2.8.0 was
published after the last `master` build, and `^2.7.5` let CI's
`dart pub upgrade` pick it up.
2.8.0 bundles `dart_style` 3.1.12, which "hugs" block-like arguments in
the generated `*.reflection.g.dart`:
var ret = onCall(this, 'mapKeys', <String, dynamic>{
'map': map,
}, const __TR<...>(...));
while the `dart format` of the Dart SDK 3.12.2 (what CI runs) splits
them:
var ret = onCall(
this,
'mapKeys',
<String, dynamic>{'map': map},
const __TR<...>(...),
);
That makes the two CI jobs mutually exclusive: committing the generator
output fails `dart format --set-exit-if-changed` in `build`, and
committing the formatted output fails `ensure_build_test` in `test_vm`
(build_runner rewrites the file back). Confirmed both directions
locally.
Held at `>=2.7.5 <2.8.0` so CI resolves 2.7.5 again and the committed
generated code is byte-identical to what build_runner produces. The
range (rather than an exact pin) still allows a 2.7.x patch.
Worth revisiting once `reflection_factory` emits code that the SDK's
`dart format` leaves untouched, or once the SDK's bundled `dart_style`
catches up to 3.1.12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onflict)" This reverts commit 493f801.
…rmat check Keeps the package on the latest `reflection_factory`. 2.8.0 bundles `dart_style` 3.1.12, which "hugs" block-like arguments in the generated `*.reflection.g.dart`, while the `dart format` of the Dart SDK 3.12.2 splits them. That made two CI jobs mutually exclusive: committing the generator output failed `dart format --set-exit-if-changed` in `build`, and committing the formatted output failed `ensure_build_test` in `test_vm` (build_runner rewrites the file back). Resolved by not format-gating generated code: the `build` job now formats only the hand-written sources (`git ls-files '*.dart'` minus `*.g.dart`). Generated files are still analyzed with `--fatal-infos --fatal-warnings`, and `ensure_build_test` still guarantees they match the generator output byte for byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m the format check" This reverts commit 68954fd.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #145 +/- ##
==========================================
+ Coverage 66.47% 67.49% +1.02%
==========================================
Files 63 63
Lines 21222 21347 +125
==========================================
+ Hits 14107 14409 +302
+ Misses 7115 6938 -177
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- actions/checkout: v4 -> v7 - dart-lang/setup-dart: v1 -> v1.7.2 - codecov/codecov-action: v3 -> v7 `codecov-action` v4+ takes the upload token as a `token` input instead of the `CODECOV_TOKEN` env var, so that is moved accordingly. The remaining inputs (`directory`, `flags`, `env_vars`, `fail_ci_if_error`, `verbose`) are unchanged and still valid in v7. No workflow logic or commands changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`^2.7.5` let CI's `dart pub upgrade` resolve 2.8.0, whose bundled `dart_style` 3.1.12 formats the generated code differently from the `dart format` of the Dart SDK (3.1.6), breaking `ensure_build_test`. reflection_factory 2.8.1 caps `dart_style` below 3.1.10 (gmpassos/reflection_factory#51), so the generated code is byte-identical to what the SDK's `dart format` wants again. Requiring `^2.8.1` -- and not `^2.7.5` -- also keeps consumers of the released bones_api off the broken 2.8.0. The regenerated files only change the builder version stamp (2.7.5 -> 2.8.1); there is no formatting change. Verified: `dart format --set-exit-if-changed` 0 changed, `dart analyze --fatal-infos --fatal-warnings` clean, `ensure_build_test` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes coverage holes where the existing assertions would have passed even if the implementation were wrong. - **Non-`id` ID column.** Every entity in `bones_api_test_entities.dart` uses `id`, so every `orderByID` assertion so far would pass even if the ORDER BY column were hardcoded instead of resolved from the `TableScheme`. Adds a `legacy_item` table with `idFieldName: 'item_code'` and asserts the emitted SQL orders by `item_code` (and that it contains no `.id` at all), for both `generateSelectSQL` and `generateSelectIDsSQL`. - **ORDER BY across a JOIN.** A condition over a referenced entity (`config.open == ?`) generates a JOIN; the ORDER BY must target the MAIN table's alias, not the joined one. Here all 5 campaigns share one config row, so ordering by the joined table's ID would produce arbitrary paging -- the test would fail. - **Ordering survives eager resolution.** Paging with `EntityResolutionRules(allEager: true)`, which re-reads the referenced entities after the select. - **Stability.** The same page requested repeatedly returns the same rows -- the actual point of `offset` implying `orderByID`. - **Empty results.** A query matching nothing stays empty under `offset`/`limit`, including `offset: 0`. The JOIN/eager/stability test lives in the shared adapter template, so it runs against the in-memory, PostgreSQL, MySQL and object-directory adapters. Verified on real PostgreSQL and MySQL containers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
selectByQueryand its siblings already acceptedlimit, but there was no way to paginate: noOFFSET, and no ordering support of any kind anywhere in the query machinery (the onlyORDER BYinlib/was inside a Postgrespg_constraintintrospection query). Without a stable order aLIMITalone returns an arbitrary subset, so page-by-page reads were impossible to write correctly. The one place that did order results —APIDBModule.select— worked around it by selecting every row and sorting by ID in Dart.What this adds
Four optional parameters across the whole select surface:
offset— the return offset.page— 1-based page, the ergonomic form ofoffset: resolves to(page - 1) * limit.orderByID— orders by the table's ID column, resolved automatically from the machinery that already existed (TableScheme.idFieldName→EncodingContext.tableFieldID, orEntityHandler.idFieldName). No new resolution code.orderDirection— newOrderDirectionenum:ascending(default) /descending.Semantics
orderByID ?? (offset != null)— anoffsetimplies the ordering, so pagination is stable by defaultorderByID: falsewith anoffsetemits a bareOFFSET, noORDER BYorderDirectionalonepage→offset(page - 1) * limit.page: 1→offset: 0, which still activates the ordering, so even the first page is stablepagevalidationpageis a public convenience resolved to anoffsetat the repository layer by the sharedresolveSelectOffset. The adapter contract,SQL,SQLDialect, the SQL generation and every DB adapter still takeoffsetalone — so no second source-breaking widening of the abstract adapter methods.It throws an
ArgumentErrorrather than silently returning the wrong rows when:page+offsetpagewithout a positivelimitlimit: 0already means "no limit" here. Includespage: 1, so the rule stays predictablepage < 1selectFirstByQueryforceslimit: 1, sopage: nthere is the Nth entity.Dialects
All dialect-specific
SELECTtail syntax now lives inSQLDialect.orderBySQL/limitOffsetSQL, with two new capabilities:offsetRequiresLimitandoffsetMaxLimitValue.MySQL can't parse an
OFFSETthat is not preceded by aLIMIT, so an offset-only select there emitsLIMIT 18446744073709551615 OFFSET n. PostgreSQL and thegeneric(in-memory) dialect emit a bareOFFSET n. This is asserted per-dialect and exercised against real containers.limitstarts working where it was ignoredlimitwas accepted and silently discarded byDBEntityRepository.select'sConditionID/ConditionIdIN/ConditionANY/KeyConditionEQfast paths, byDBAdapter.doSelectAll/doSelectByIDs, and by everyDBObject*adapter. Those are all fixed here, so e.g.selectAll(limit: 2)on an object adapter returned every row before this PR and now returns 2. There is an explicit regression test pinning this.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-partyEntityRepository/DBAdapterimplementations must widen their overrides. Hence the minor bump (1.10.0 → 1.11.0), not a patch.APIDBModule.select/db/select/<table>gainsLIMIT=<n>,OFFSET=<n>,PAGE=<n>andORDER=asc|descquery directives, parsed alongside the pre-existingEAGER=trueand stripped before the remainder is parsed as the entity condition query. The ad-hocEAGER=truestring surgery is replaced by a general directive extractor. The manual Dart-side sort is gone — the ordering is resolved by the DB. Output order for existing calls is unchanged (this endpoint defaults toorderByID: true, not to theoffset != nullrule). An invalidPAGEreturns an error response rather than an uncaughtArgumentError.Commits (each independently compilable and tested)
ac67146OrderDirection+ the shared ordering/pagination primitives +SQLDialectclause builders238663aoffset/orderByID/orderDirectionthreaded through the SQL path and the public API602bb6cDBAdapterobject path — closes thelimitdead ends1241b3aAPIDBModule.selectquery directivesb9fe0b047eb8c7pageparameter +PAGEdirectivea5819221256462reflection_factory^2.8.1+ regenerate93ed7fc(Commits
493f801..edafbc9are a pin/CI experiment that nets to zero —git diff 47eb8c7 edafbc9is empty. Squash-merge, or say the word and I'll drop them.)Testing
719 VM tests + 447 Chrome tests pass, with the Docker daemon running so the PostgreSQL and MySQL suites actually executed rather than self-skipping.
bones_api_entity_select_order_test.dart(enum, comparator, shared applier,resolveSelectOffsetincl. all three error paths, dialect clause builders),bones_api_entity_db_sql_select_test.dart(exact generated SQL per case + end-to-end paging byoffsetand bypage), andbones_api_db_module_test.dart— the first test coverageAPIDBModulehas ever had.runAdapterTeststemplate, so the generated SQL and real page-by-page reads are asserted for the in-memory, PostgreSQL, MySQL, object-memory and object-directory adapters at once.Photo(String IDs) is used for the object-adapter test, covering the non-numeric branch ofcompareEntityIDs.dart format,dart analyze --fatal-infos --fatal-warnings,dart pub publish --dry-runandensure_build_testare all clean.Toolchain fixes pulled in along the way
CI went red on
ensure_build_testfor a reason unrelated to this feature:reflection_factory2.8.0 was published after the lastmasterbuild, and^2.7.5let CI'sdart pub upgradetake it. 2.8.0 requiresdart_style: ^3.1.9→ resolves 3.1.12, whose output differs from thedart_style3.1.6 vendored in Dart SDK 3.12.2 — makingdart format --set-exit-if-changedandensure_build_testmutually exclusive.Resolved upstream rather than worked around here:
dart_stylebelow 3.1.10, with a regression test that comparesDartFormatteragainst the SDK'sdart formaton the generated-proxy shape.reflection_factory: ^2.8.1— deliberately not^2.7.5, which would let consumers of the released 1.11.0 resolve the broken 2.8.0. The regenerated*.g.dartchange only the builder version stamp; there is no formatting change.checkoutv4→v7,setup-dartv1→v1.7.2,codecov-actionv3→v7 (token moved to an input, as v4+ requires). No workflow logic changed.Test coverage added after review
Gaps where the original assertions would have passed even with a broken implementation:
idID column — every test entity usesid, soorderByIDwould have passed even if the column were hardcoded. Alegacy_itemtable withidFieldName: 'item_code'now asserts the emitted SQL orders byitem_codeand contains no.idat all.ORDER BYacross a JOIN — must target the main table's alias. All 5 campaigns share one config row, so ordering by the joined table's ID would give arbitrary paging and fail.EntityResolutionRules(allEager: true).offset ⇒ orderByID.offset/limit.Not covered
DBObjectGCSAdapterhas no test suite in this repo (it had none before either), so its symmetrical change is compile-checked and review-only.Known limitation (pre-existing, documented not fixed)
A query over a to-many relationship generates a
JOINwithout aDISTINCT, so it can already return the same entity more than once. LayeringLIMIT/OFFSETon top means such a page can contain duplicates — paginating those queries is best-effort. Noted in the dartdoc and the CHANGELOG.🤖 Generated with Claude Code