Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ _Avoid_: Select list, projection spec, hydration mode flag
The single typed wire artifact a query ships to the Rust runtime — model identity, predicates, ordering, paging, joins, and exactly one materialization plan, inside a versioned envelope. Compiled only by `compile_query`; no other code assembles query wire shape.
_Avoid_: Query dict, query def, payload dict

**Paging**:
The QueryIR window over matching rows: a size (`limit`) and a start. A start is either an offset or one position bound (`after` or `before`, never both). Both bounds are exclusive of the position. A limited `before` is the adjacent previous page; an unbounded `before` is every earlier row in declared order. Paging is not a predicate — it does not change which rows match — and `count()` drops it.
_Avoid_: pagination, cursor, page filter

**Position**:
The ordered tuple of a query's order-key values that marks one row's place in that order. Two rows never share a position: the order keys include the model's primary key. A non-PK slot may be empty (`None`); the PK slot may not. `after`/`before` start the page from a position; `position_of` reads one off a model instance, or off a projected record that carries every order key. Traversed order keys require those relations populated. Not a cursor — encoding is the caller's.
_Avoid_: cursor, bookmark, page token, keyset

**Order key**:
One term in a query's `order_by`: the column (root or traversed), its direction, and its null placement. A position holds one value per order key, in declaration order.
_Avoid_: sort field, sort column, order term

**Null placement**:
Where NULL sort keys land for one order key: `last`, `first`, or `native` (that backend's own default — Postgres and SQLite are opposites). Omitted means `last`, so the same order on every backend. `native` is never implied.
_Avoid_: dialect default, omitted nulls

**Compiled query**:
The single artifact `compile_query` returns: the QueryIR payload, its wire JSON, and the plan-scoped hop-class map, all views of one compile. The map is collected from the hop facts the payload itself carries, so wire and hop classes can never disagree; it is `None` unless the materialization plan decodes or hydrates through a hop model's class (mirroring the Rust `needs_hop_classes` guard — a both-sides double-check). No other code assembles hop classes for the FFI.
_Avoid_: payload + kwargs, hop-class side-channel, wire tuple
Expand Down
208 changes: 155 additions & 53 deletions crates/ferro-schema-ir/src/lib.rs

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions docs/adr/0017-omitted-nulls-means-last.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Omitted `nulls=` means last, not the dialect default

`order_by(..., nulls=)` accepts `"last"` | `"first"` | `"native"`. Omitted
means **`last`** — the same NULL placement on every backend. `"native"` is the
escape hatch for a backend's own default (Postgres and SQLite are opposites).
`native` is never implied.

#363 shipped omitted as dialect-native and deliberately did not cross-assert
omitted DESC. That split is the opposite of portable paging: the same
`after((3pm, 2))` would start in a different bucket per backend, and a
Postgres `DESC` with no `nulls=` puts NULLs *first* (unpinned conversations
leading). Defaulting to `last` is the typical "empties at the bottom" list
and matches the Pinch pinned-first shape.

This is a breaking change for every `order_by` that omitted `nulls=`, not
only for `after`/`before`:

- Postgres `DESC` — NULLs move first → last
- SQLite `ASC` — NULLs move first → last

Rejected: requiring `nulls=` only on nullable keys when paging (the kwarg
is required sometimes and not others); requiring it on every key only when
`after`/`before` is set (same smell); leaving omitted as `native` (pages
are not portable).

See `CONTEXT.md`: Null placement. Grilled with #372.
32 changes: 32 additions & 0 deletions docs/adr/0018-position-paging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# `after`/`before` are position paging, not a derived predicate

`Query.after(position)` / `Query.before(position)` are a **paging start** —
a sibling of `limit`/`offset` on the QueryIR payload — not a `where()`
predicate the query writes for itself. A position is the ordered tuple of
the query's order-key values; `position_of(row)` reads it; `after(row)` is
sugar. Cursor encoding is the caller's.

The bound is exclusive. `after` + `offset`, or `after` + `before`, is a
build-time error (`count()` already drops paging). Order keys are root or
traversed columns (not aggregates); they must include the model's primary
key so two rows never share a position. `None` is legal in every non-PK
slot — column nullability is the wrong question, because a `left_join`'d
NOT NULL related column is still NULL when the relation is missing.

`before(position).limit(n)` is the **adjacent previous page**, yielded in
the declared order (flip comparisons and order, fetch n, reverse).
Unbounded `before()` is every earlier row in declared order — a prefix,
not a page. Limit is optional on both sides; on unbounded `before()`,
`first()` (limit 1 → adjacent) and `all()[0]` (prefix head) disagree.
That is accepted and documented.

Same chainers on `ProjectedQuery`. `position_of(Row)` requires every order
key to be in the projection; otherwise pass a tuple. Grouped aggregates
fail the PK-in-order-keys rule.

Rejected: injecting the keyset tree into `where` (makes predicates depend
on `order_by`); an opaque Position type (Pinch must rebuild from a decoded
cursor); silently appending the PK (hidden extra sort); requiring `nulls=`
only when paging (ADR-0017).

See `CONTEXT.md`: Paging, Position, Order key. Grilled with #372.
26 changes: 20 additions & 6 deletions docs/examples/partial_selects.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ class Transaction(Model):
id: int | None = Field(default=None, primary_key=True)
amount: int
memo: str
account: Annotated[
Account | None, ForeignKey(related_name="transactions")
] = None
account: Annotated[Account | None, ForeignKey(related_name="transactions")] = None


# --8<-- [end:schema]


Expand Down Expand Up @@ -90,9 +90,7 @@ async def main() -> None:
# A selected field may reach across a relation, at any depth.
# Unaliased, the field takes the bare leaf column name.
rows = await (
Transaction.select(lambda t: (t.memo, t.account.label))
.order_by("id")
.all()
Transaction.select(lambda t: (t.memo, t.account.label)).order_by("id").all()
)
assert rows[0].model_dump() == {"memo": "coffee", "label": "a1"}
# --8<-- [end:traversed]
Expand Down Expand Up @@ -165,6 +163,22 @@ async def main() -> None:
assert row is not None and row.memo == "coffee"
# --8<-- [end:compose]

# --8<-- [start:projected-paging]
# Projected records page the same way instances do. position_of(Row)
# requires every order key in the projection; otherwise pass a tuple.
projected = (
Transaction.select(lambda t: {"label": t.account.label, "id": t.id})
.order_by(lambda t: t.account.label)
.order_by(lambda t: t.id)
)
records = await projected.all()
next_records = (
await projected.after(projected.position_of(records[1])).limit(2).all()
)
# --8<-- [end:projected-paging]
assert [r.id for r in records] == [1, 2, 3]
assert [r.id for r in next_records] == [3]

# --8<-- [start:count]
# count()/exists() are unaffected by projection: they measure the
# same matching rows a full query would.
Expand Down
72 changes: 72 additions & 0 deletions docs/examples/predicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,56 @@ async def main() -> None:
assert oldest_first[0].name == "carol"
assert len(second_page) == 2

# --8<-- [start:after-paging]
page = (
await User.select()
.order_by(lambda user: user.age)
.order_by(lambda user: user.id)
.limit(2)
.all()
)
next_page = (
await User.select()
.order_by(lambda user: user.age)
.order_by(lambda user: user.id)
.after(page[-1])
.limit(2)
.all()
)
# --8<-- [end:after-paging]
assert [user.name for user in page] == ["dave", "bob"]
assert [user.name for user in next_page] == ["alice", "carol"]

# --8<-- [start:before-paging]
previous_page = (
await User.select()
.order_by(lambda user: user.age)
.order_by(lambda user: user.id)
.before(next_page[0])
.limit(2)
.all()
)
earlier = (
await User.select()
.order_by(lambda user: user.age)
.order_by(lambda user: user.id)
.before(next_page[0])
.all()
)
adjacent = (
await User.select()
.order_by(lambda user: user.age)
.order_by(lambda user: user.id)
.before(next_page[0])
.first()
)
# --8<-- [end:before-paging]
assert [user.name for user in previous_page] == ["dave", "bob"]
assert [user.name for user in earlier] == ["dave", "bob"]
assert adjacent is not None and adjacent.name == "bob"
assert earlier[0].name == "dave"
assert adjacent.name != earlier[0].name

t0 = datetime(2026, 1, 1, tzinfo=UTC)
t1 = datetime(2026, 2, 1, tzinfo=UTC)
t2 = datetime(2026, 3, 1, tzinfo=UTC)
Expand Down Expand Up @@ -146,6 +196,28 @@ async def main() -> None:
"unpinned-old",
]

# --8<-- [start:after-null-paging]
last_pinned = cards[1]
unpinned_page = (
await Card.select()
.order_by(lambda card: card.pinned_at, "desc")
.order_by(lambda card: card.updated_at, "desc")
.order_by(lambda card: card.id, "desc")
.after(last_pinned)
.all()
)
remaining_unpinned = (
await Card.select()
.order_by(lambda card: card.pinned_at, "desc")
.order_by(lambda card: card.updated_at, "desc")
.order_by(lambda card: card.id, "desc")
.after((None, cards[2].updated_at, cards[2].id))
.all()
)
# --8<-- [end:after-null-paging]
assert [card.title for card in unpinned_page] == ["unpinned-new", "unpinned-old"]
assert [card.title for card in remaining_unpinned] == ["unpinned-old"]

# --8<-- [start:terminals]
everyone = await User.all()
first_admin = await User.where(lambda user: user.role == "admin").first()
Expand Down
46 changes: 40 additions & 6 deletions docs/examples/traversal.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
import asyncio
from typing import Annotated

from ferro import BackRef, Field, ForeignKey, ManyToMany, Model, Relation, connect, engines
from ferro import (
BackRef,
Field,
ForeignKey,
ManyToMany,
Model,
Relation,
connect,
engines,
)


# --8<-- [start:schema]
Expand Down Expand Up @@ -39,6 +48,8 @@ class Transaction(Model):
id: int | None = Field(default=None, primary_key=True)
amount: int
account: Annotated[Account, ForeignKey(related_name="transactions")]


# --8<-- [end:schema]


Expand All @@ -47,6 +58,8 @@ class Note(Model):
id: int | None = Field(default=None, primary_key=True)
body: str
account: Annotated[Account | None, ForeignKey(related_name="notes")] = None


# --8<-- [end:note-model]


Expand All @@ -62,15 +75,21 @@ class Flight(Model):
id: int | None = Field(default=None, primary_key=True)
origin: Annotated[Airport, ForeignKey(related_name="departures")]
destination: Annotated[Airport, ForeignKey(related_name="arrivals")]


# --8<-- [end:two-fk-model]


# --8<-- [start:self-fk-model]
class Employee(Model):
id: int | None = Field(default=None, primary_key=True)
name: str
manager: Annotated["Employee", ForeignKey(related_name="reports", nullable=True)] = None
manager: Annotated[
"Employee", ForeignKey(related_name="reports", nullable=True)
] = None
reports: Relation[list["Employee"]] = BackRef()


# --8<-- [end:self-fk-model]


Expand All @@ -92,6 +111,8 @@ class Post(Model):
id: int | None = Field(default=None, primary_key=True)
title: str
tags: Relation[list["Tag"]] = ManyToMany(related_name="posts")


# --8<-- [end:m2m-model]


Expand Down Expand Up @@ -147,7 +168,9 @@ async def main() -> None:

# --8<-- [start:pinch]
top = await (
Transaction.where(lambda transaction: transaction.account.ledger_id == ledger_a.id)
Transaction.where(
lambda transaction: transaction.account.ledger_id == ledger_a.id
)
.where(lambda transaction: transaction.amount >= 20)
.order_by(lambda transaction: transaction.amount, "desc")
.limit(2)
Expand Down Expand Up @@ -188,6 +211,19 @@ async def main() -> None:
# --8<-- [end:order-by]
assert [r.id for r in ordered] == [1, 2, 3, 4, 5, 6]

# --8<-- [start:traversed-paging]
# after/before take a decoded tuple — include() is not required to page.
next_page = await (
Transaction.select()
.order_by(lambda transaction: transaction.account.label)
.order_by(lambda transaction: transaction.id)
.after(("a1", 2))
.limit(2)
.all()
)
# --8<-- [end:traversed-paging]
assert [r.id for r in next_page] == [3, 4]

# --8<-- [start:instance-eq]
# `== instance` filters by the shadow FK column, with no join.
on_a1 = await Transaction.where(
Expand Down Expand Up @@ -291,9 +327,7 @@ async def _run_m2m() -> None:
# --8<-- [start:m2m-query]
# The association context (post.tags) and forward-FK traversal on the tag
# compose in one statement.
admin_tags = await post.tags.where(
lambda tag: tag.created_by.role == "admin"
).all()
admin_tags = await post.tags.where(lambda tag: tag.created_by.role == "admin").all()
# --8<-- [end:m2m-query]
assert {t.id for t in admin_tags} == {1}

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/api/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Prefix `~` negates **any** predicate — leaf comparison or `&`/`|` compound — rendering as SQL `NOT (...)` over the condition it wraps (ADR-0008). It is the universal negation rule: there are no per-operator negative forms (`~t.role.in_([...])` is NOT IN, `~t.email.like(p)` is NOT LIKE), and double negation nests. Like SQL `NOT` and the `!=` operator, a negated comparison excludes rows where the compared column is `NULL` — see [Negation and NULL values](../guide/queries.md#negation-and-null-values).

`where()` and `order_by()` lambdas may **traverse** a forward-FK relation (`lambda t: t.account.ledger_id == 1`): each hop renders one INNER join, deduplicated by relation path (ADR-0006). `join()` forces a join on a relation path (a bare `join()` is an existence filter on a nullable relation), and `left_join()` marks the whole path LEFT to keep relation-less rows. `order_by(..., nulls="first" | "last")` pins `NULL` placement when a sort key is nullable (dialect defaults otherwise — see [Ordering, Limit & Offset](../guide/queries.md#ordering-limit--offset)). See the [Querying Across Relationships](../guide/queries.md#querying-across-relationships) guide for worked examples.
`where()` and `order_by()` lambdas may **traverse** a forward-FK relation (`lambda t: t.account.ledger_id == 1`): each hop renders one INNER join, deduplicated by relation path (ADR-0006). `join()` forces a join on a relation path (a bare `join()` is an existence filter on a nullable relation), and `left_join()` marks the whole path LEFT to keep relation-less rows. Omitted `nulls=` on `order_by` means `NULLS LAST` on every backend; pass `nulls="first"`, `nulls="last"`, or `nulls="native"` (dialect default) to override — see [Ordering, Limit & Offset](../guide/queries.md#ordering-limit--offset). See the [Querying Across Relationships](../guide/queries.md#querying-across-relationships) guide for worked examples.

A reverse (`BackRef`) or many-to-many relation in a predicate supports exactly one verb — the **existence test** `t.rel.exists(inner_lambda=None)` (ADR-0007). It renders as a correlated `EXISTS` at every cardinality (never a join, so the result stays root-shaped and each matching root returns once), negates with `~`, and the optional inner lambda is a full ferro predicate over the related model (operators, `&`/`|`/`~`, forward traversal rendered inside the subquery, nested tests). Everything else on a reverse edge — column access, comparisons (including `!= None`), `in_` (including a query RHS), `join()`/`left_join()` — raises at build time naming `.exists()`; an inner lambda referencing any scope but its own parameter is likewise rejected ([#309](https://github.com/syn54x/ferro-orm/issues/309)). See [Existence Tests](../guide/queries.md#existence-tests-on-reverse-many-to-many-relations) for worked examples.

Expand Down
Loading
Loading