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
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
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
14 changes: 13 additions & 1 deletion docs/pages/guide/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ To page backward, `.before(position)` is the other start. With a limit it is the

On unbounded `before()`, `first()` and `all()[0]` disagree: `first()` is `limit(1)` (the adjacent previous row) and `all()[0]` is the head of the prefix (the earliest earlier row). That is accepted.

The same chainers work when an order key is a related column (`order_by(lambda t: t.account.label)`) or the query is a projected record: pass a tuple of the order-key values. `position_of` on a model instance requires those relations populated; `position_of` on a `Row` requires every order key to be in the projection.

For robust pagination patterns, see [Pagination](../howto/pagination.md).

## Executing Queries
Expand Down Expand Up @@ -330,6 +332,12 @@ The narrowing is query-wide, not per-clause: the join is rendered once for the w

Because the sort join is INNER too, ordering by a related column drops relation-less rows — the same narrowing as `where()`. (Use `left_join` to keep them; see below.)

`after()` / `before()` take a decoded **tuple** of those order-key values — `include()` is not required to page. `position_of` on a model instance does require the relation populated.

```python
--8<-- "docs/examples/traversal.py:traversed-paging"
```

### One join per relation path

The relation path is the join's identity. Reference the same path in two `where()` calls, in an `&`/`|` tree, or across `where()` and `order_by()`, and it renders as **one** join. Distinct paths — even to the same table — render as distinct joins. There is no alias to name and none to manage; the path does that job.
Expand Down Expand Up @@ -804,12 +812,16 @@ Projection traversal is ordinary traversal (ADR-0006): it renders an INNER join

### Projections compose like any other query

`where()` (relation traversal included), `order_by()` (even by columns the projection does not select), `limit()`/`offset()`, and `first()` all work unchanged; on a plain projection `count()` and `exists()` are unaffected — they measure the same matching rows a full query would. (On an *aggregate* projection they raise with guidance instead — see [Aggregations & Grouped Queries](aggregations.md#the-loud-limits).)
`where()` (relation traversal included), `order_by()` (even by columns the projection does not select), `limit()`/`offset()`, `after()`/`before()`, and `first()` all work unchanged; on a plain projection `count()` and `exists()` are unaffected — they measure the same matching rows a full query would. (On an *aggregate* projection they raise with guidance instead — see [Aggregations & Grouped Queries](aggregations.md#the-loud-limits).) `position_of` on a `Row` requires every order key in the projection; otherwise pass a tuple.

```python
--8<-- "docs/examples/partial_selects.py:compose"
```

```python
--8<-- "docs/examples/partial_selects.py:projected-paging"
```

```python
--8<-- "docs/examples/partial_selects.py:count"
```
Expand Down
4 changes: 2 additions & 2 deletions docs/solutions/patterns/position-paging-after.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ related_files:
- src/query.rs
- src/ferro/query/builder.py
- src/ferro/query/wire.py
related_issues: [393, 394, 395]
related_issues: [393, 394, 395, 396]
captured: 2026-08-30
---

Expand All @@ -18,6 +18,6 @@ captured: 2026-08-30

One function owns the compare tree: `exclusive_stepwise_compare` in `src/query.rs`. The SELECT walker qualifies columns, binds typed values, and ANDs the result onto WHERE. Do not sprinkle inequalities in `operations.rs`. #394 extends this function; it does not add a second expander. #395 does not add a second expander either — `before_condition` inverts keys then calls the same function. `"native"` resolves inside that function from `Dialect` (Postgres: NULL is larger; SQLite: NULL is smaller).

Python validates the wedge at `after()` / `before()` / `position_of()` (root columns, PK included; `None` legal in every non-PK slot) and `compile_query` is the only assembler that puts `after` / `before` on the fetch payload as typed `kind`/`value` nodes, including `kind: "null"`. Count omits the keys; mutations reject them. Column nullability is not consulted — a `left_join`'d NOT NULL related column may still be NULL when the relation is missing.
Python validates the wedge at `after()` / `before()` / `position_of()` (root or traversed columns, PK included; `None` legal in every non-PK slot) and `compile_query` is the only assembler that puts `after` / `before` on the fetch payload as typed `kind`/`value` nodes, including `kind: "null"`. Count omits the keys; mutations reject them. Column nullability is not consulted — a `left_join`'d NOT NULL related column may still be NULL when the relation is missing. Do not invent a second expander: path-carrying `order_by` terms already go through `qualify_column_with_joins`.

Datetime slots go through `_serialize_query_value` → pydantic JSON mode (`…Z` for UTC), the same bytes `save()` writes. `datetime.isoformat()` emits `…+00:00`; on SQLite that is a different TEXT value, so the prefix-equality arm of the stepwise compare never matches.
Loading
Loading