Typed Query Predicates¶
Model.where, Query.where, and Relation.where accept a single predicate
shape: a lambda that receives a QueryProxy and returns a QueryNode.
rows = await User.where(lambda user: user.archived == False).all()
rows = await User.where(
lambda user: (user.role == "admin") & (user.active == True)
).all()
How It Works¶
The lambda receives a fresh QueryProxy for the model being queried.
Attribute access on the proxy is validated against the model's declared
columns (plus shadow {fk}_id columns) and returns a FieldProxy, so
user.archived == False builds a QueryNode. A misspelled column —
user.archievd — raises AttributeError naming the closest valid match and
listing every queryable column, at build time, before any query reaches the
database.
Name the lambda parameter after the model in lowercase singular (user for
User, post for Post) so predicates read like English. The full operator
surface is available: ==, !=, <, <=, >, >=, .like(), .in_(),
&, |, == None, and shadow FK columns (user.author_id).
That gives you two concrete guarantees today:
- A valid predicate type-checks as
QueryNode.lambda user: user.age >= 18passesty check/ Pyright because>=on aFieldProxyis typed to returnQueryNode, which is exactly whatwhere()expects. - A junk predicate fails the checker.
lambda user: True— a callable that doesn't return aQueryNodeat all — is a type error, not a silent no-op query, becausewhere()'s parameter type isPredicate = Callable[[QueryProxy[TModel]], QueryNode].
What isn't checked yet is the right-hand side of a comparison: the proxy attribute type is FieldProxy[Any], so user.age >= "eighteen" type-checks even though it would fail at runtime. Closing that gap needs per-field static types on the proxy — TypeScript-style mapped types, proposed for Python in PEP 827 ("Type Manipulation", draft status, targeting Python 3.16). When type checkers support it, QueryProxy attribute typing upgrades from FieldProxy[Any] to each field's real declared type — with zero runtime change — and user.age >= "eighteen" starts failing the checker too.
Relation.where (used on BackRef collections) accepts the same shape:
Traversing Relations¶
Reaching through a foreign key inside a predicate — lambda transaction: transaction.account.ledger_id == 1 — type-checks as a predicate for the same reason a plain comparison does: attribute access on the proxy keeps returning a chainable proxy, and the terminal comparison returns QueryNode. The checker sees a valid Predicate[Transaction]; the Queries guide covers what the traversal means (one INNER join per relation path).
The static gate checks the shape of the predicate, not the names in it:
- A traversal that ends in a comparison type-checks.
transaction.account.ledger_id == 1is aQueryNode, so it satisfieswhere(). - A bare relation as a predicate fails the checker.
lambda transaction: transaction.accountreturns a proxy, not aQueryNode, so it is a type error — the same failure mode aslambda user: True. - Name typos are caught at build time, not by the checker. A misspelled hop (
transaction.accont,account.emial) isFieldProxy[Any]to the type checker, so it type-checks; at build time the proxy validates every hop against the real model and raisesAttributeErrorwith a did-you-mean naming that hop's model. The static gate guarantees shape; the runtime proxy guarantees names.
Projected Queries¶
Partial selects extend the same shape-not-names promise through projection.
select(...) with a selector flips the query's static type from
Query[Model] to ProjectedQuery[Model], so the terminals change shape:
.all() checks as Rows[Row] and .first() as Row | None — never
list[Model]:
rows = await Transaction.select(lambda t: (t.id, t.amount)).all() # Rows[Row]
row = await Transaction.select(lambda t: t.amount).first() # Row | None
full = await Transaction.select().all() # list[Transaction] — bare form unchanged
Every selector form keeps that shape: the tuple, the single field, traversal
at any depth (t.account.owner.email chains as FieldProxy[Any]), and the
dict form, whose values may also be aggregate expressions:
row = await Transaction.select(
lambda t: {"acct": t.account_id, "total": t.amount.sum()}
).first() # Row | None
An aggregate expression types opaquely: t.amount.sum() is an
AggregateExpr — deliberately not a value, not a QueryNode, not a column.
That is what makes the misuse gates work: an aggregate compared inside
where() raises at build time (post-aggregation filtering is having()),
and an AggregateExpr is only ever valid as a dict-selector value or an
order_by() lambda result.
The gate divides the work exactly like predicates do:
- Shape is checked statically. Passing a
Rowwhere a model instance is expected fails the checker (list[Transaction] = await Transaction.select(lambda t: (t.id,)).all()is aninvalid-assignment), and mutating a projected query (.update()/.delete()) is a static error at the call site as well as a build-timeValueError. - Names are checked at build time. A selected column is
FieldProxy[Any]to the checker; a misspelled column raisesAttributeErrorwith a did-you-mean when the query is built, before any round-trip — the same runtime validation aswhere()andorder_by().
The promise is shape, not names: the checker knows a projected query
yields Rows[Row], but not that this particular Row has a .total of type
int | None — record fields type as Any on access. Inferring per-field
record types from the selector (so row.total checks as what the aggregate
contract says it is) is the record-field inference lane, tracked in
#290 — another place
typing can sharpen later with zero runtime change.
Included Queries¶
include() deliberately does not flip the query's type the way select()
does. An included query stays Query[Model]-shaped: .all() checks as
list[Transaction], .first() as Transaction | None, and populated access
(txns[0].account.label) type-checks through the field's ordinary declared
annotation. There is no Loaded[Transaction].
That is a decision, not a gap, for two reasons:
- A per-query "loaded" type is unsound under the identity map. In a
session, the instance an included query returns is the same object a
plain query returns — populations attach to shared instances and accumulate
across queries. A static brand that says "this object's
accountis populated" asserts something the runtime cannot pin to a type: the next refresh may drop the population (see the refresh rule), and a plain query can hand you the already-populated object. The honest static type of every instance is the model itself. - The declared annotation already claims the instance. The field says
account: Account; a distinct loaded type would need to transform per-field types on the unloaded side instead (account: Awaitable[Account]→Account), which requires TypeScript-style mapped types — PEP 827 (draft, targeting Python 3.16). Population is what makes the declared annotation true at runtime, exactly where the user opted in.
When PEP 827 lands, typing can sharpen with zero runtime change — precisely
because the runtime type stayed single. Meanwhile the statically catchable
misuses do fail the gate: include() on a projected query is an error at the
call site (the self: Never pin), and a string selector fails the callable
parameter type.
What This Doesn't Change¶
- Your model annotations.
archived: bool = Falsestays exactly as it is. - Pydantic schema generation, JSON schema output, or model validation.
- The Rust FFI bridge architecture (predicates serialize through QueryIR envelopes).
Reference¶
ferro.query.QueryProxy— validating attribute proxy passed to lambda predicates.ferro.query.Predicate—Callable[[QueryProxy[TModel]], QueryNode], the type of any lambda predicate.ferro.query.FieldProxy— generic over the column's Python type (FieldProxy[T]); the mechanism aQueryProxyattribute access returns.ferro.query.RowSelector— the type of aselect()lambda selector: a callable returning one field, a tuple of fields, or a dict of output names to fields/aggregates.ferro.query.AggregateExpr— the opaque type oft.amount.sum()and friends; valid only as a dict-selector value or anorder_by()lambda result.ferro.query.Row/ferro.query.Rows— projected records and their list-like container, the result shape of a projected query.