Skip to content

feat(query): after/before on traversed keys and projections - #403

Merged
0x054 merged 1 commit into
feat/position-pagingfrom
feat/396-traversed-projected-paging
Aug 30, 2026
Merged

feat(query): after/before on traversed keys and projections#403
0x054 merged 1 commit into
feat/position-pagingfrom
feat/396-traversed-projected-paging

Conversation

@0x054

@0x054 0x054 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Description

after / before now page when an order key is a related column (order_by(lambda t: t.account.label)) or the query is a projected record. A decoded tuple is enough — include() is not required to page.

position_of on a model instance requires those relations populated (same contract as reading row.account.label). position_of on a projected Row requires every order key in the projection; otherwise pass a tuple.

Changes

  • Lift the Python path refusal in _assert_position_order_keys. Root PK must still appear among the order keys (a related id does not count). None stays legal in every non-PK slot, including a left_join'd missing relation.
  • position_of(instance) walks populated __dict__ relation hops and errors by path when a hop is unpopulated.
  • ProjectedQuery.position_of(Row) matches order keys by source or output name; missing key tells the caller to pass a tuple.
  • Aggregate order keys error even when the PK is a group key. Grouped aggregates without the PK still fail the existing PK-in-order-keys rule.
  • Rust pin: before on a path-carrying order key still qualifies through qualify_column_with_joins (no second expander).

Bridge and Schema Impact

  • No Rust/Python bridge changes
  • Python model/schema changed
  • Rust core or SQL generation changed
  • src/ferro/_core.pyi updated (if needed)
  • Integration test added first for new behavior

Python builder/validation only plus a Rust unit pin. QueryIR is unchanged (ir_version stays 14). Path-carrying order_by already qualified in the walker.

Migration / Breaking Changes

  • No breaking changes
  • Breaking changes included (details below)

Queries that previously raised "traversed order key is not supported yet" now page.

Documentation and Changelog

  • No docs update needed
  • Docs updated (README/docs/inline docs)
  • Changelog entry needed

User-visible feat — release tooling records the changelog (I-10). Do not hand-edit CHANGELOG.md.

Related Issues

Test plan

  • sqlite e2e: traversed after/before from a tuple (no include()), left_join + None related slot, projected-record paging, position_of populated vs unpopulated
  • build-time: unpopulated path, missing projection key, grouped aggregate (PK rule), aggregate order key, related id is not the model PK, expression order keys still fail at order_by()
  • cargo test --no-default-features --features testing query:: (includes before_condition_qualifies_path_carrying_order_key)
  • uv run ty check src/ferro/query
  • Postgres backend matrix — pytest-postgresql cannot start locally (pg_ctl initdb failed). CI will run it.

Exit Steps

  • Related completed issues are linked above with Closes/Fixes/Resolves
  • Issue statuses are updated/auto-close on merge

Made with Cursor

Position paging works for related-column order keys and projected
records from a decoded tuple. position_of still requires populated
relations on instances and selected order keys on Row.

Co-authored-by: Cursor <cursoragent@cursor.com>
@0x054
0x054 merged commit 562ea92 into feat/position-paging Aug 30, 2026
7 checks passed
0x054 added a commit that referenced this pull request Aug 30, 2026
## Summary

Keyset `after()` / `before()` derived from a query's `order_by`,
NULL-placement aware (ADR-0018). A position is the ordered tuple of
order-key values; `position_of(row)` reads it; `after(row)` /
`before(row)` are sugar. Cursor encoding stays the caller's.

- Omitted `nulls=` is `"last"` on every dialect (ADR-0017 — **breaking**
vs native PG DESC / SQLite ASC). `"native"` is the explicit escape.
- `after(position)` pages forward (exclusive). `None` is legal in every
non-PK slot so a pinned-first list can cross into unpinned rows in one
query.
- `before(position).limit(n)` is the adjacent previous page in declared
order; unbounded `before()` is the earlier-row prefix (`first()` and
`all()[0]` disagree — documented).
- Traversed order keys and projected records page from a tuple
(`include()` not required). `position_of` still requires populated
relations / selected keys.

QueryIR is v14. One Rust expander (`exclusive_stepwise_compare`) owns
the compare tree; `before` inverts keys then reuses it.

## Test plan

- [x] Slice CI on `feat/position-paging`: #397#403 (rust, python PR,
sqlite+postgres matrix)
- [ ] Confirm CI on this PR against `main`
- [ ] Smoke: `order_by(pinned_at, "desc").order_by(id).after((None,
id))` and `before(pos).limit(n)`

Closes #372


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant