Skip to content

feat(query): position paging after()/before() from order_by - #404

Merged
0x054 merged 6 commits into
mainfrom
feat/position-paging
Aug 30, 2026
Merged

feat(query): position paging after()/before() from order_by#404
0x054 merged 6 commits into
mainfrom
feat/position-paging

Conversation

@0x054

@0x054 0x054 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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

Closes #372

Made with Cursor

0x054 and others added 6 commits August 30, 2026 10:49
Pin Paging, Position, Order key, and Null placement, plus ADR-0017/0018,
ahead of the #372 implementation slices.

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary

- Omitted `nulls=` on `order_by` now compiles to **`last`** on every
backend (breaking change: Postgres DESC and SQLite ASC no longer use
dialect defaults).
- New explicit escape: `nulls="native"` restores plain ASC/DESC per
dialect.
- QueryIR **11 → 12**: every `order_by` term carries required `nulls`
(`first` | `last` | `native`); missing key fails at decode.

## Breaking change

| Before (omitted `nulls=`) | After |
|---|---|
| Postgres DESC → NULLs first | NULLs last |
| SQLite ASC → NULLs first | NULLs last |

Use `nulls="native"` when you want the old dialect-default behavior.

## Test plan

- [x] `pytest tests/test_order_by_nulls.py` — omitted `nulls=`
cross-asserted as last on SQLite
- [x] `pytest tests/test_order_by_nulls_wire.py` — v12 wire, every term
has `nulls`, `native` token
- [x] `pytest tests/test_query_wire_vectors.py` — golden vectors at
ir_version 12
- [x] `pytest tests/test_ir_vectors_contract.py` — contract requires
`nulls` on every order_by term
- [x] `cargo test -p ferro-schema-ir query_order_by query_card_nulls` —
required nulls decode/roundtrip
- [x] `pytest tests/test_query_column_validation.py` — builder accepts
`native`, omitted → `last`

Closes #392

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary

- `after(position)` pages a query whose order keys are **root columns**,
include the **primary key**, and are all **non-nullable**. The bound is
exclusive. `position_of(row)` reads that tuple; `after(row)` is sugar
for `after(position_of(row))`.
- QueryIR **v13**: fetch payloads carry one optional `after` bound as
typed `kind`/`value` nodes (omitted when unset). `count()` drops paging
the same way it drops limit/offset; `update`/`delete` reject `after`.
`after` + `offset` is a build-time error (two starts).
- Rust expands one exclusive stepwise compare — `(a > :a) OR (a = :a AND
b > :b)`, DESC flipping the inequality — so #394 can extend that
function. Datetime query binds use pydantic JSON (`…Z` for UTC) so
SQLite TEXT equality matches INSERT.

## Test plan

- [x] Build-time: no PK in order keys; PK-less model; nullable order
key; wrong arity; `None` in a slot; `after`+`offset`; `update`/`delete`
reject `after`; `count()` omits `after`
- [x] e2e sqlite: `order_by(updated_at).order_by(id).after((ts,
id)).limit(n)` returns the next n exclusive, in declared order; empty
page past the end; `after(row)` equals `after(position_of(row))`
- [x] Golden vector `query_user_after_v13` plus all query vectors bumped
to `ir_version` 13
- [x] Rust: exclusive stepwise compare ASC and DESC on two NOT NULL
keys; QueryIR accepts v13
- [ ] Postgres e2e locally (pytest-postgresql could not start; no
`FERRO_POSTGRES_URL`)

Closes #393

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary

- `after()` now accepts `None` in every non-PK position slot (PK `None`
is still a build-time error) and no longer consults
`ColumnSpec.nullable`.
- `exclusive_stepwise_compare` is still the one expander: each key's
direction, `nulls=` placement, and whether the bound is NULL fold into
one `IS NULL` / `IS NOT NULL` / inequality tree so a cursor can cross
the NULL bucket in one query.
- `"native"` resolves at render from `Dialect` (Postgres: NULL is
larger; SQLite: NULL is smaller). QueryIR stays at v13; a new golden
pins `kind: "null"` in `after`.

Closes #394

## Test plan

- [x] `uv run pytest tests/test_after_position.py` — build-time contract
+ SQLite backend-matrix e2e (Pinch `nulls="last"` DESC, `after((None,
id))`, non-NULL cursor into the NULL bucket)
- [x] `cargo test --no-default-features --features testing query::` —
expander combinatorics (ASC/DESC × first/last × bound-NULL/non-NULL,
native on both dialects, two keys)
- [x] Wire/IR: `tests/test_query_wire_vectors.py`,
`tests/test_ir_vectors_contract.py`, `cargo test -p ferro-schema-ir
query_after`
- [x] `uv run ty check src/ferro/query`
- [ ] Postgres backend-matrix — `pytest-postgresql` could not start
locally (`pg_ctl initdb` failed). CI will run it.


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

Co-authored-by: Cursor <cursoragent@cursor.com>
## Description

`before(position)` is now the other paging start. `before(pos).limit(n)`
is the adjacent previous page in declared order; unbounded
`before(pos).all()` is every earlier row (a prefix). The bound is
exclusive.

On unbounded `before()`, `first()` (limit 1 → adjacent previous row) and
`all()[0]` (prefix head) disagree. That is accepted and documented.

## Changes

- Invert each order key (`asc`↔`desc`, `first`↔`last`, `native` stays
`native`), reuse `#394`'s `exclusive_stepwise_compare`, fetch, then
reverse hydrated rows.
- QueryIR v13 → v14 (`before` omitted when unset). `after` + `before`
and `before` + `offset` are build-time errors; mutations reject `before`
the same way they reject `after`.
- Same order-key rules as `after()`: root columns, PK included, `None`
legal in every non-PK slot. Traversed keys stay #396.

## Test plan

- [x] `tests/test_before_position.py`: adjacent page, unbounded prefix,
`first()` vs `all()[0]`, after+before / offset+before errors,
NULL-bucket Pinch, `before(row)` sugar
- [x] Rust `query::` pins invert + same expander (no second tree);
`native` unchanged
- [x] Wire/goldens at v14 (`query_user_before_v14.json`); `rejects_v13`;
future-reject is 15
- [x] sqlite e2e (backend matrix)
- [ ] Postgres e2e — skipped locally (`pytest-postgresql` could not
start `initdb`); CI will run it
- [x] `uv run ty check src/ferro/query`

## Bridge and Schema Impact

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

## Migration / Breaking Changes

- [x] No breaking changes
- [ ] Breaking changes included (details below)

QueryIR 13 → 14 is an in-wheel wire bump (Python and Rust ship
together).

## Documentation and Changelog

- [ ] No docs update needed
- [x] Docs updated (README/docs/inline docs)
- [ ] Changelog entry needed

I-10: changelog is release-managed; do not edit `CHANGELOG.md`.

## Related Issues

Closes #395

## Exit Steps

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
## 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
- [x] Python model/schema changed
- [x] Rust core or SQL generation changed
- [ ] `src/ferro/_core.pyi` updated (if needed)
- [x] 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

- [x] 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
- [x] 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

- Closes #396

## Test plan

- [x] sqlite e2e: traversed `after`/`before` from a tuple (no
`include()`), `left_join` + `None` related slot, projected-record
paging, `position_of` populated vs unpopulated
- [x] 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()`
- [x] `cargo test --no-default-features --features testing query::`
(includes `before_condition_qualifies_path_carrying_order_key`)
- [x] `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

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


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

Co-authored-by: Cursor <cursoragent@cursor.com>
@0x054
0x054 merged commit de92b42 into main Aug 30, 2026
7 checks passed
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.

feat PRD: keyset after()/before() derived from order_by, NULL-placement aware

1 participant