Skip to content
Closed
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
195 changes: 195 additions & 0 deletions docs/examples/existence_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Runnable companion to the Existence Tests guide (docs/pages/guide/queries.md).

Seeds the transfer/split-line domain the guide walks through (one-to-one and
to-many BackRefs plus a category both layers point at) and a tagged-user M2M
pair, then runs every existence test the guide shows and asserts the exact
rows that come back.
"""

import asyncio
from typing import Annotated

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


# --8<-- [start:schema]
class Category(Model):
id: int | None = Field(default=None, primary_key=True)
name: str
transactions: Relation[list["Transaction"]] = BackRef()
lines: Relation[list["SplitLine"]] = BackRef()


class Transaction(Model):
id: int | None = Field(default=None, primary_key=True)
amount: int
category: Annotated[
Category | None, ForeignKey(related_name="transactions", on_delete="SET NULL")
] = None
# One-to-one BackRefs: a transfer references a transaction via a unique FK
transfer_out: "Transfer" = BackRef()
transfer_in: "Transfer" = BackRef()
# To-many BackRef: a split transaction carries its lines
lines: Relation[list["SplitLine"]] = BackRef()


class Transfer(Model):
id: int | None = Field(default=None, primary_key=True)
outflow_transaction: Annotated[
Transaction | None, ForeignKey(related_name="transfer_out", unique=True)
] = None
inflow_transaction: Annotated[
Transaction | None, ForeignKey(related_name="transfer_in", unique=True)
] = None


class SplitLine(Model):
id: int | None = Field(default=None, primary_key=True)
txn: Annotated[Transaction, ForeignKey(related_name="lines", on_delete="CASCADE")]
category: Annotated[
Category | None, ForeignKey(related_name="lines", on_delete="SET NULL")
] = None
amount: int = 0
# --8<-- [end:schema]


# --8<-- [start:m2m-schema]
class Tag(Model):
id: int | None = Field(default=None, primary_key=True)
name: str
users: Relation[list["User"]] = ManyToMany(related_name="tags")


class User(Model):
id: int | None = Field(default=None, primary_key=True)
username: str
tags: Relation[list["Tag"]] = BackRef()
# --8<-- [end:m2m-schema]


async def main() -> None:
await connect("sqlite::memory:", auto_migrate=True)

async with engines.session():
outflow = await Transaction.create(amount=-100)
inflow = await Transaction.create(amount=100)
plain = await Transaction.create(amount=-20)
assert plain.amount == -20
await Transfer.create(outflow_transaction=outflow)
await Transfer.create(inflow_transaction=inflow)

# --8<-- [start:bare]
# "Is this transaction part of any transfer?" — membership via either
# FK column, one EXISTS per side, each matching row exactly once.
in_transfer = await Transaction.where(
lambda t: t.transfer_out.exists() | t.transfer_in.exists()
).all()

# The negated branch: ~ renders NOT EXISTS
not_in_transfer = await Transaction.where(
lambda t: ~t.transfer_out.exists() & ~t.transfer_in.exists()
).all()
# --8<-- [end:bare]
assert {t.amount for t in in_transfer} == {-100, 100}
assert {t.amount for t in not_in_transfer} == {-20}

groceries = await Category.create(name="Groceries")
split = await Transaction.create(amount=-70) # category vacated while split
await SplitLine.create(txn=split, category=groceries, amount=-30)
await SplitLine.create(txn=split, category=groceries, amount=-40)
await Transaction.create(amount=-10, category=groceries)
ids = [groceries.id]

# --8<-- [start:scoped]
# The inner lambda is a full ferro predicate over the related model:
# "transactions carrying the category at the root OR on any line".
# A line-less transaction survives through the OR's root branch, and
# a transaction with several matching lines comes back exactly once.
matching = await Transaction.where(
lambda t: t.category_id.in_(ids)
| t.lines.exists(lambda line: line.category_id.in_(ids))
).all()
# --8<-- [end:scoped]
assert {t.amount for t in matching} == {-70, -10}

# --8<-- [start:composes]
# Root-shaped results: existence tests compose with every other
# predicate, ordering, and paging — nothing about the query changes.
page = (
await Transaction.where(
lambda t: t.category_id.in_(ids)
| t.lines.exists(lambda line: line.category_id.in_(ids))
)
.order_by("amount", "desc")
.limit(1)
.all()
)
# --8<-- [end:composes]
assert [t.amount for t in page] == [-10]

fuel_run = await Transaction.create(amount=-55)
await SplitLine.create(txn=fuel_run, category=groceries, amount=-55)
spread = await Transaction.create(amount=-60)
await SplitLine.create(txn=spread, category=groceries, amount=-5)
await SplitLine.create(txn=spread, amount=-55)

# --8<-- [start:grouping]
# Grouping is YOUR choice, spelled explicitly — the two shapes below
# are different questions with different answers.

# One line matches BOTH conditions:
one_line_both = await Transaction.where(
lambda t: t.lines.exists(
lambda line: line.category_id.in_(ids) & (line.amount <= -50)
)
).all()

# SOME line matches each condition (possibly different lines):
some_line_each = await Transaction.where(
lambda t: t.lines.exists(lambda line: line.category_id.in_(ids))
& t.lines.exists(lambda line: line.amount <= -50)
).all()
# --8<-- [end:grouping]
assert {t.amount for t in one_line_both} == {-55}
assert {t.amount for t in some_line_each} == {-55, -60}

# --8<-- [start:traversal-inside]
# Forward traversal works inside the test (joins render INSIDE the
# EXISTS subquery, ADR-0006 semantics unchanged), and tests nest.
by_name = await Transaction.where(
lambda t: t.lines.exists(lambda line: line.category.name == "Groceries")
).all()

active_categories = await Category.where(
lambda c: c.lines.exists(lambda line: line.txn.amount < -50)
).all()
# --8<-- [end:traversal-inside]
assert {t.amount for t in by_name} == {-70, -55, -60}
assert {c.name for c in active_categories} == {"Groceries"}

admin = await Tag.create(name="admin")
beta = await Tag.create(name="beta")
alice = await User.create(username="alice")
bob = await User.create(username="bob")
await User.create(username="carol")
await admin.users.add(alice)
await beta.users.add(alice, bob)

# --8<-- [start:m2m]
# Many-to-many spells identically — the test correlates through the
# join table, and the inner lambda scopes over the target model.
admins = await User.where(
lambda u: u.tags.exists(lambda tag: tag.name == "admin")
).all()
tagged = await User.where(lambda u: u.tags.exists()).all()
untagged = await User.where(lambda u: ~u.tags.exists()).all()
# --8<-- [end:m2m]
assert {u.username for u in admins} == {"alice"}
assert {u.username for u in tagged} == {"alice", "bob"}
assert {u.username for u in untagged} == {"carol"}

print("existence_tests example ran successfully")


if __name__ == "__main__":
asyncio.run(main())
101 changes: 101 additions & 0 deletions docs/examples/existence_tests_annotated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Annotated-style companion to existence_tests.py (AGENTS.md I-7)."""

import asyncio
from typing import Annotated

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


# --8<-- [start:schema]
class Category(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
name: str
transactions: Relation[list["Transaction"]] = BackRef()
lines: Relation[list["SplitLine"]] = BackRef()


class Transaction(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
amount: int
category: Annotated[
Category | None, ForeignKey(related_name="transactions", on_delete="SET NULL")
] = None
# One-to-one BackRefs: a transfer references a transaction via a unique FK
transfer_out: "Transfer" = BackRef()
transfer_in: "Transfer" = BackRef()
# To-many BackRef: a split transaction carries its lines
lines: Relation[list["SplitLine"]] = BackRef()


class Transfer(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
outflow_transaction: Annotated[
Transaction | None, ForeignKey(related_name="transfer_out", unique=True)
] = None
inflow_transaction: Annotated[
Transaction | None, ForeignKey(related_name="transfer_in", unique=True)
] = None


class SplitLine(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
txn: Annotated[Transaction, ForeignKey(related_name="lines", on_delete="CASCADE")]
category: Annotated[
Category | None, ForeignKey(related_name="lines", on_delete="SET NULL")
] = None
amount: int = 0
# --8<-- [end:schema]


# --8<-- [start:m2m-schema]
class Tag(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
name: str
users: Relation[list["User"]] = ManyToMany(related_name="tags")


class User(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
username: str
tags: Relation[list["Tag"]] = BackRef()
# --8<-- [end:m2m-schema]


async def main() -> None:
await connect("sqlite::memory:", auto_migrate=True)

async with engines.session():
outflow = await Transaction.create(amount=-100)
await Transaction.create(amount=-20)
await Transfer.create(outflow_transaction=outflow)

in_transfer = await Transaction.where(
lambda t: t.transfer_out.exists() | t.transfer_in.exists()
).all()
assert {t.amount for t in in_transfer} == {-100}

groceries = await Category.create(name="Groceries")
split = await Transaction.create(amount=-70)
await SplitLine.create(txn=split, category=groceries, amount=-70)

line_aware = await Transaction.where(
lambda t: t.category_id.in_([groceries.id])
| t.lines.exists(lambda line: line.category_id.in_([groceries.id]))
).all()
assert {t.amount for t in line_aware} == {-70}

admin = await Tag.create(name="admin")
alice = await User.create(username="alice")
await User.create(username="bob")
await admin.users.add(alice)

admins = await User.where(
lambda u: u.tags.exists(lambda tag: tag.name == "admin")
).all()
assert {u.username for u in admins} == {"alice"}

print("existence_tests_annotated example ran successfully")


if __name__ == "__main__":
asyncio.run(main())
2 changes: 2 additions & 0 deletions docs/pages/api/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Prefix `~` negates **any** predicate — leaf comparison or `&`/`|` compound —

`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. 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.

## `include()` and populated relations

`include(lambda t: t.account)` delivers each result with the relation **populated** (ADR-0008): access becomes a plain attribute holding the complete related instance — no await, no query — while unpopulated relations keep the awaitable contract. Include is the third orthogonal query axis (joins decide membership, projection decides shape, include decides attached data): it never changes which rows come back, `.all()` still returns `list[Model]`, and `count()`/`exists()` are unaffected. Paths populate whole (`include(lambda t: t.account.owner)` populates both hops); includes are cumulative, order-free, and idempotent; populated instances run the full session identity-map protocol, and a refresh keeps a population only while the row's FK still points at it.
Expand Down
Loading
Loading