You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Requested by: Pinch • Motivated by: pinch-finance/pinch-backend#415 and #428 (pinned-first conversation list; PRD pinch-backend#413). Follow-on to #358 / v0.20.0 order_by(nulls=).
Grilled 2026-08-30. ADRs:0017 (omitted nulls= means last), 0018 (position paging). Glossary: Paging, Position, Order key, Null placement.
Problem Statement
As a ferro user I can sort a list so empty optional stamps trail (order_by(lambda c: c.pinned_at, "desc", nulls="last")), but I cannot ask for the next page of that list as one query. A SQL tuple compare (pinned_at, updated_at, id) < (:ts, :ts2, :id) has no meaning when the cursor row's leading key is NULL, so I have to tag my cursor with "which bucket" and write two where() branches. The ORDER BY is one line; the keyset predicate is still two.
Offset paging does not solve this: it is not stable under inserts, and it is a different QueryIR start. I already keep my own opaque cursor; I need Ferro to turn the last row's order-key values into the correct bound, including the non-NULL → NULL transition.
Solution
after(position) / before(position) are a paging start — a sibling of limit/offset, not a where() predicate. A position is the ordered tuple of the query's order-key values. position_of(row) reads it; passing the row itself is sugar. Ferro derives the NULL-aware bound from each order key's direction and null placement. Cursor encoding stays mine.
Omitted nulls= means last on every order_by (same order on both backends). "native" is the explicit dialect-default escape.
User Stories
As an application developer, I want after(position).limit(n) to return the next n rows after a position in the query's declared order, so that I can page a list without an offset.
As an application developer, I want before(position).limit(n) to return the n rows immediately before that position, still in the declared order, so that a "Previous" button works.
As an application developer, I want both bounds exclusive of the position, so that the cursor row is not repeated on the next or previous page.
As an application developer, I want position_of(row) to return the ordered tuple of order-key values, so that I can store a cursor without knowing which columns the query sorts by.
As an application developer, I want after(row) / before(row) to mean after(position_of(row)), so that I do not have to call position_of when the row is still in hand.
As an application developer, I want to rebuild a position from a decoded cursor as a plain tuple and pass it to after(), so that Ferro does not own cursor encoding.
As a Pinch backend developer, I want a pinned-first conversation list (pinned_at DESC NULLS LAST, updated_at DESC, id DESC) to page as one query, so that I can drop the pinned/active bucket tag and the two WHERE branches.
As a Pinch backend developer, I want after((None, updated_at, id)) to continue through the unpinned bucket, so that crossing from a pinned row into unpinned rows is not a special case in my handler.
As an application developer, I want two rows to never share a position, so that a page boundary cannot skip or repeat a row. The order keys must include the model's primary key.
As an application developer with a uuid4 primary key, I want that PK as a last-key tiebreaker only, so that random UUIDs do not set the list order — they only make "after this row" unambiguous.
As an application developer, I want after() without a PK among the order keys to fail at build time, so that I find the skip/duplicate bug before production.
As an application developer of a PK-less model, I want after() / before() to fail at build time, so that I am not offered a paging API the model cannot support.
As an application developer, I want a wrong-arity tuple to fail at build time, so that a stale cursor from a different order_by cannot silently page the wrong rows.
As an application developer, I want None in a non-PK slot to be legal, so that a nullable leading key (or a missing left_join relation) can sit in the position.
As an application developer, I want None in the PK slot to fail at build time, so that I cannot invent a position no row can have.
As an application developer, I want Ferro not to consult column nullability when accepting None, so that left_join + a NOT NULL related column still pages when the relation is missing.
As an application developer, I want a position that matches no remaining rows to return an empty page, so that "past the end" is not an error.
As an application developer, I want after() + offset() on the same query to fail at build time, so that I cannot stack two starts.
As an application developer, I want after() + before() on the same query to fail at build time, so that a range is not mistaken for a page start.
As an application developer, I want count() to ignore after / before the same way it ignores limit / offset, so that I can count the matching set from the same branched query.
As an application developer, I want update() / delete() to reject after / before the same way they reject limit / offset, so that I cannot write a non-portable mutation.
As an application developer, I want after(position).all() (no limit) to return every remaining row after the position, so that "the rest of the list" is expressible.
As an application developer, I want unbounded before(position).all() to return every earlier row in declared order (a prefix), so that after and before are symmetric about whether a limit is required.
As an application developer, I want to be told in the docs that on unbounded before(), first() (adjacent, via limit 1) and all()[0] (prefix head) disagree, so that I do not treat them as interchangeable.
As an application developer, I want before(position).first() to return the single adjacent previous row, so that "the row just before this cursor" is one call.
As an application developer, I want paging to compose with where(), traversal, exists(), include(), and order_by(), so that a filtered, joined list pages the same way a simple one does.
As an application developer, I want after / before on a ProjectedQuery, so that a column-subset page uses the same paging API.
As an application developer, I want position_of on a projected record to work when every order key is in the projection, so that I can cursor a record page without keeping the full model.
As an application developer, I want position_of on a projected record that omitted an order key to fail at build time, so that I pass a tuple instead of getting a silent wrong position.
As an application developer, I want after / before on a grouped aggregate to fail (the PK is not an order key of a GROUP BY), so that I am not offered position paging the projection cannot uniquely key.
As an application developer, I want aggregate / expression order keys to be a build-time error on after / before, so that v1 stays on columns Ferro can read off a row.
As an application developer ordering by a related column, I want after((label, id)) to work from a decoded tuple without include(), so that my cursor round-trip does not require populated relations.
As an application developer, I want position_of on a model instance to fail when a traversed order key is not populated, so that I do not read a proxy or trigger a fetch inside paging.
As an application developer, I want omitted nulls= to mean last on every order_by, so that the same sort (and the same pages) run on Postgres and SQLite.
As an application developer, I want nulls="first" and nulls="last" to pin placement explicitly, so that I can put empties at the top when I mean to.
As an application developer, I want nulls="native" when I deliberately want that backend's default, so that the dialect split is visible and never implied.
As an application developer upgrading from v0.20.0, I want the changelog / PR to say that omitted nulls= on Postgres DESC and SQLite ASC flips (NULLs move to last), so that I can audit existing sorts.
As a docs reader, I want a worked pinned-first list with after / before and a stored tuple cursor, so that I copy the official pattern (lambda order_by, both field-declaration styles elsewhere as usual).
As a docs reader, I want the all()[0] vs first() note on unbounded before() in the paging guide, so that the accepted disagreement is not a surprise.
As a maintainer, I want the QueryIR payload to carry at most one position bound plus typed values, so that paging is not smuggled through the predicate tree.
As a maintainer, I want every order_by term on the wire to carry an explicit nulls (last | first | native), so that Rust never treats a missing key as dialect-native.
As a maintainer, I want one expansion function to render the keyset SQL from the declared order keys, so that after and before cannot drift and both dialects share the decision table.
As a maintainer, I want a hand-authored golden vector for the paging-bound wire shape, so that the Python emitter and Rust decoder cannot silently disagree.
As a maintainer, I want the QueryIR version bumped, so that a mixed-version payload cannot decode a new bound as "no start."
As a test author, I want result-set assertions at the backend-matrix seam for the NULL-bucket crossing and adjacent before, so that I pin user-visible order without asserting SQL strings in pytest.
As a test author, I want build-time tests at compile_query / the chainer for the loud errors, so that illegal queries never reach the database.
Implementation Decisions
Paging, not a predicate (ADR-0018).after / before are a QueryIR paging start, compiled only by compile_query. They do not add a QueryNode to where. count drops the bound (paging keys become null, same as today's limit/offset). Mutating verbs reject the bound the same way they reject limit/offset and omit paging keys on the wire.
Position is an ordered tuple of order-key values, declaration order. position_of(row) produces it. after(row) / before(row) desugar to position_of. No Position type. No cursor codec.
Order keys in v1 are root or traversed columns. Aggregates and other expressions are a build-time error on after / before. The model's primary-key fact must be among the order keys; do not append it silently. PK-less models fail at build time.
Position validation happens at the chainer / compile: arity equals the order-key count; None is legal in every non-PK slot; None in the PK slot is an error; column nullability is not consulted. A bound that matches zero rows is an empty page.
position_of on a model instance reads root keys via attributes and traversed keys only from populated relations; an unpopulated traversal is a build-time error. A caller-supplied tuple does not require include().
position_of on a projected record requires every order key to appear in the projection (match the order key's source, including output aliases). Otherwise error; pass a tuple. Grouped aggregates fail the PK-in-order-keys rule — no extra gate.
before + limit is adjacent (ADR-0018). Fetch path: invert each order key's direction, swap first↔last (native stays native — each dialect's ASC native is already the reverse of its DESC native), apply the inverted bound, LIMIT n, reverse the fetched rows so the result is declared order. Unbounded before is the before-bound plus declared order (prefix). Limit is optional on both sides.
At most one start.after + offset, before + offset, and after + before are build-time errors. Chaining after twice last-wins, like limit.
Null placement (ADR-0017).nulls= accepts last | first | native. Omitted at the chainer means last, on all order_by, not only when paging. Breaking: Postgres DESC and SQLite ASC with omitted nulls= flip. native is never implied.
Wire (QueryIR version bump). Every order_by term carries explicit nulls (last | first | native) — a missing key is invalid at decode. Fetch payloads carry at most one position bound (after or before) whose values are the existing typed query-value nodes (so None and typed scalars survive). Mutating payloads still omit paging keys. Hand-authored golden vector pins the new bound; existing query vectors update for explicit nulls and the version bump.
One expansion function in the Rust query renderer turns (order keys × direction × null placement × after/before × position values) into the IS NULL / compare tree. Both dialects consume it. native is resolved to that dialect's default at render, not in Python.
Docs use lambda order_by (I-8). Paging examples do not declare fields; ADR-0017's order_by change is called out where omitted nulls= is shown.
Testing Decisions
Good tests assert external behavior only: result-set order and membership at the end-to-end query seam, payload shape at the golden-vector wire seam, and build-time error messages at the compile/chainer seam. No assertions on the expansion function's internals, node graphs, or generated SQL in pytest.
Seam 1 — backend-matrix result sets (highest). Same style as order_by nulls and uniform negation: both backends, assert row order / ids, not SQL. Covers: the Pinch pinned-first list; after crossing from a non-NULL leading key into the NULL bucket; before(pos).limit(n) adjacent (not prefix); unbounded after / before; first() on limited before; exclusive bounds; None in a non-PK slot; left_join + None for a NOT NULL related column; traversed order key with a raw tuple; include() + position_of; ProjectedQuery happy path; empty page past the end; omitted nulls= is last on both backends (cross-assert); nulls="native" is not cross-asserted (documented split).
Seam 2 — QueryIR golden vectors. One new hand-authored vector for a fetch payload with an after bound and typed values (including a JSON null in a non-PK slot). Python builder equality and the Rust decoder both assert the same bytes. Existing query vectors bump with the version and explicit nulls: "last" on terms that used to omit the key. Verb policy (count drops the bound; mutate omits paging keys) stays as inline expected payloads next to the existing count/mutate pins — no fetch-shaped vector for those verbs.
Seam 3 — build-time compile / chainer. Same style as the order_by nulls wire tests and query column validation: stop at compile_query or the chainer, no database. Pins: no PK in order keys; PK-less model; aggregate order key; after + offset; after + before; None in the PK slot; wrong arity; position_of on an unpopulated traversal; position_of(Row) missing an order key; invalid nulls= token.
Rust renderer (cargo). The expansion function's combinatorics (ASC/DESC × last/first/native × after/before × NULL-bucket) are pinned as unit tests on rendered SQL / condition trees — I-4: pure SQL generation stays in cargo test. pytest does not duplicate that matrix.
Prior art: backend-matrix e2e for order_by(..., nulls=) and ~; builder→wire golden vectors; compile_query wire tests that stop before execution.
Out of Scope
Offset pagination (already exists; stacking with a position bound is an error, not a feature).
Cursor encoding (Pinch and other callers keep their own opaque cursors).
Aggregate or expression order keys as position components.
Unique-but-not-PK as the uniqueness stand-in (the PK is the rule).
Inclusive bounds; a range (after + before together).
Requiring a limit on before().
A named/opaque Position type.
Silently appending the PK as a hidden order key.
Optimistic locking / SELECT … FOR UPDATE.
Changing count() / exists() to honor a position bound.
Further Notes
ADR-0017 is a globalorder_by break, not a paging-only rule. Ship it in the same PR as after / before so omitted nulls= and the expansion function never disagree about what "default" means.
native + limited before: invert direction only; do not swap the token. Each dialect's ASC native is the reverse of its DESC native, so the adjacent fetch stays a true reverse.
Shape-preserving query and the complete-instance invariant still hold: paging does not change result type; position_of on a model always sees a complete row's root columns.
I-8: docs and examples use lambda order_by / lambda where. Operator-style order_by is unchanged.
Requested by: Pinch • Motivated by: pinch-finance/pinch-backend#415 and #428 (pinned-first conversation list; PRD pinch-backend#413). Follow-on to #358 / v0.20.0
order_by(nulls=).Grilled 2026-08-30. ADRs: 0017 (omitted
nulls=meanslast), 0018 (position paging). Glossary: Paging, Position, Order key, Null placement.Problem Statement
As a ferro user I can sort a list so empty optional stamps trail (
order_by(lambda c: c.pinned_at, "desc", nulls="last")), but I cannot ask for the next page of that list as one query. A SQL tuple compare(pinned_at, updated_at, id) < (:ts, :ts2, :id)has no meaning when the cursor row's leading key is NULL, so I have to tag my cursor with "which bucket" and write twowhere()branches. The ORDER BY is one line; the keyset predicate is still two.Offset paging does not solve this: it is not stable under inserts, and it is a different QueryIR start. I already keep my own opaque cursor; I need Ferro to turn the last row's order-key values into the correct bound, including the non-NULL → NULL transition.
Solution
after(position)/before(position)are a paging start — a sibling oflimit/offset, not awhere()predicate. A position is the ordered tuple of the query's order-key values.position_of(row)reads it; passing the row itself is sugar. Ferro derives the NULL-aware bound from each order key's direction and null placement. Cursor encoding stays mine.Omitted
nulls=meanslaston everyorder_by(same order on both backends)."native"is the explicit dialect-default escape.User Stories
after(position).limit(n)to return the next n rows after a position in the query's declared order, so that I can page a list without an offset.before(position).limit(n)to return the n rows immediately before that position, still in the declared order, so that a "Previous" button works.position_of(row)to return the ordered tuple of order-key values, so that I can store a cursor without knowing which columns the query sorts by.after(row)/before(row)to meanafter(position_of(row)), so that I do not have to callposition_ofwhen the row is still in hand.after(), so that Ferro does not own cursor encoding.pinned_at DESC NULLS LAST, updated_at DESC, id DESC) to page as one query, so that I can drop the pinned/active bucket tag and the two WHERE branches.after((None, updated_at, id))to continue through the unpinned bucket, so that crossing from a pinned row into unpinned rows is not a special case in my handler.after()without a PK among the order keys to fail at build time, so that I find the skip/duplicate bug before production.after()/before()to fail at build time, so that I am not offered a paging API the model cannot support.order_bycannot silently page the wrong rows.Nonein a non-PK slot to be legal, so that a nullable leading key (or a missingleft_joinrelation) can sit in the position.Nonein the PK slot to fail at build time, so that I cannot invent a position no row can have.None, so thatleft_join+ a NOT NULL related column still pages when the relation is missing.after()+offset()on the same query to fail at build time, so that I cannot stack two starts.after()+before()on the same query to fail at build time, so that a range is not mistaken for a page start.count()to ignoreafter/beforethe same way it ignoreslimit/offset, so that I can count the matching set from the same branched query.update()/delete()to rejectafter/beforethe same way they rejectlimit/offset, so that I cannot write a non-portable mutation.after(position).all()(no limit) to return every remaining row after the position, so that "the rest of the list" is expressible.before(position).all()to return every earlier row in declared order (a prefix), so thatafterandbeforeare symmetric about whether a limit is required.before(),first()(adjacent, via limit 1) andall()[0](prefix head) disagree, so that I do not treat them as interchangeable.before(position).first()to return the single adjacent previous row, so that "the row just before this cursor" is one call.where(), traversal,exists(),include(), andorder_by(), so that a filtered, joined list pages the same way a simple one does.after/beforeon aProjectedQuery, so that a column-subset page uses the same paging API.position_ofon a projected record to work when every order key is in the projection, so that I can cursor a record page without keeping the full model.position_ofon a projected record that omitted an order key to fail at build time, so that I pass a tuple instead of getting a silent wrong position.after/beforeon a grouped aggregate to fail (the PK is not an order key of aGROUP BY), so that I am not offered position paging the projection cannot uniquely key.after/before, so that v1 stays on columns Ferro can read off a row.after((label, id))to work from a decoded tuple withoutinclude(), so that my cursor round-trip does not require populated relations.position_ofon a model instance to fail when a traversed order key is not populated, so that I do not read a proxy or trigger a fetch inside paging.nulls=to meanlaston everyorder_by, so that the same sort (and the same pages) run on Postgres and SQLite.nulls="first"andnulls="last"to pin placement explicitly, so that I can put empties at the top when I mean to.nulls="native"when I deliberately want that backend's default, so that the dialect split is visible and never implied.nulls=on Postgres DESC and SQLite ASC flips (NULLs move to last), so that I can audit existing sorts.after/beforeand a stored tuple cursor, so that I copy the official pattern (lambdaorder_by, both field-declaration styles elsewhere as usual).all()[0]vsfirst()note on unboundedbefore()in the paging guide, so that the accepted disagreement is not a surprise.order_byterm on the wire to carry an explicitnulls(last|first|native), so that Rust never treats a missing key as dialect-native.afterandbeforecannot drift and both dialects share the decision table.before, so that I pin user-visible order without asserting SQL strings in pytest.compile_query/ the chainer for the loud errors, so that illegal queries never reach the database.Implementation Decisions
after/beforeare a QueryIR paging start, compiled only bycompile_query. They do not add aQueryNodetowhere.countdrops the bound (paging keys become null, same as today'slimit/offset). Mutating verbs reject the bound the same way they rejectlimit/offsetand omit paging keys on the wire.position_of(row)produces it.after(row)/before(row)desugar toposition_of. No Position type. No cursor codec.after/before. The model's primary-key fact must be among the order keys; do not append it silently. PK-less models fail at build time.Noneis legal in every non-PK slot;Nonein the PK slot is an error; column nullability is not consulted. A bound that matches zero rows is an empty page.position_ofon a model instance reads root keys via attributes and traversed keys only from populated relations; an unpopulated traversal is a build-time error. A caller-supplied tuple does not requireinclude().position_ofon a projected record requires every order key to appear in the projection (match the order key's source, including output aliases). Otherwise error; pass a tuple. Grouped aggregates fail the PK-in-order-keys rule — no extra gate.before+ limit is adjacent (ADR-0018). Fetch path: invert each order key's direction, swapfirst↔last(nativestaysnative— each dialect's ASC native is already the reverse of its DESC native), apply the inverted bound,LIMIT n, reverse the fetched rows so the result is declared order. Unboundedbeforeis the before-bound plus declared order (prefix). Limit is optional on both sides.after+offset,before+offset, andafter+beforeare build-time errors. Chainingaftertwice last-wins, likelimit.nulls=acceptslast|first|native. Omitted at the chainer meanslast, on allorder_by, not only when paging. Breaking: Postgres DESC and SQLite ASC with omittednulls=flip.nativeis never implied.order_byterm carries explicitnulls(last|first|native) — a missing key is invalid at decode. Fetch payloads carry at most one position bound (afterorbefore) whose values are the existing typed query-value nodes (soNoneand typed scalars survive). Mutating payloads still omit paging keys. Hand-authored golden vector pins the new bound; existing query vectors update for explicitnullsand the version bump.IS NULL/ compare tree. Both dialects consume it.nativeis resolved to that dialect's default at render, not in Python.order_by(I-8). Paging examples do not declare fields; ADR-0017'sorder_bychange is called out where omittednulls=is shown.Testing Decisions
Good tests assert external behavior only: result-set order and membership at the end-to-end query seam, payload shape at the golden-vector wire seam, and build-time error messages at the compile/chainer seam. No assertions on the expansion function's internals, node graphs, or generated SQL in pytest.
Seam 1 — backend-matrix result sets (highest). Same style as
order_bynulls and uniform negation: both backends, assert row order / ids, not SQL. Covers: the Pinch pinned-first list;aftercrossing from a non-NULL leading key into the NULL bucket;before(pos).limit(n)adjacent (not prefix); unboundedafter/before;first()on limitedbefore; exclusive bounds;Nonein a non-PK slot;left_join+Nonefor a NOT NULL related column; traversed order key with a raw tuple;include()+position_of;ProjectedQueryhappy path; empty page past the end; omittednulls=islaston both backends (cross-assert);nulls="native"is not cross-asserted (documented split).Seam 2 — QueryIR golden vectors. One new hand-authored vector for a fetch payload with an
afterbound and typed values (including a JSONnullin a non-PK slot). Python builder equality and the Rust decoder both assert the same bytes. Existing query vectors bump with the version and explicitnulls: "last"on terms that used to omit the key. Verb policy (countdrops the bound; mutate omits paging keys) stays as inline expected payloads next to the existing count/mutate pins — no fetch-shaped vector for those verbs.Seam 3 — build-time compile / chainer. Same style as the
order_bynulls wire tests and query column validation: stop atcompile_queryor the chainer, no database. Pins: no PK in order keys; PK-less model; aggregate order key;after+offset;after+before;Nonein the PK slot; wrong arity;position_ofon an unpopulated traversal;position_of(Row)missing an order key; invalidnulls=token.Rust renderer (cargo). The expansion function's combinatorics (ASC/DESC × last/first/native × after/before × NULL-bucket) are pinned as unit tests on rendered SQL / condition trees — I-4: pure SQL generation stays in
cargo test. pytest does not duplicate that matrix.Prior art: backend-matrix e2e for
order_by(..., nulls=)and~; builder→wire golden vectors;compile_querywire tests that stop before execution.Out of Scope
after+beforetogether).before().SELECT … FOR UPDATE.count()/exists()to honor a position bound.Further Notes
order_bybreak, not a paging-only rule. Ship it in the same PR asafter/beforeso omittednulls=and the expansion function never disagree about what "default" means.native+ limitedbefore: invert direction only; do not swap the token. Each dialect's ASC native is the reverse of its DESC native, so the adjacent fetch stays a true reverse.position_ofon a model always sees a complete row's root columns.order_by/ lambdawhere. Operator-styleorder_byis unchanged.featPRD: keysetafter()/before()derived from order_by, NULL-placement aware #372 explicitly.