Query Clauses#
A query clause is a named, reusable SQLAlchemy predicate: a
WhereClause. Declare it
once, compose it with others, and apply it to any statement.
For example, you could reuse the same rules to list and count a user’s non-deleted tasks:
import fastapi_restly as fr
from sqlalchemy import func, select
class Current(fr.ContextNamespace):
user_id: fr.ContextParam[int]
is_admin: fr.ContextParam[bool]
class TaskClauses(fr.ClauseNamespace):
model = Task
is_deleted = fr.where_clause(Task.deleted_at.is_not(None))
owned_by_user = fr.where_clause(Task.user_id == Current.user_id)
visible = fr.all_of(owned_by_user, fr.none_of(is_deleted))
default_scope = visible
with Current.bind(user_id=42):
tasks = fr.apply_clauses(select(Task), TaskClauses.visible)
count = fr.apply_clauses(select(func.count(Task.id)), TaskClauses.visible)
Both statements select non-deleted tasks belonging to user 42.
fr.where_clause creates a
clause from a SQLAlchemy condition, or from a function that returns one.
See Declaring clauses.
Current.user_id is a member of a
ContextNamespace. A
clause takes no arguments: a value that changes per request or per call is
a member, bound around the code that applies the clause. See
Supplying values.
ClauseNamespace groups
the model’s clauses. default_scope = visible makes visible the default
scope for RestView reads and
reference checks on Task. See Scopes.
Compose clauses, then
apply them to statements with
fr.apply_clauses.
The remaining examples share three models:
from datetime import datetime, timedelta
from sqlalchemy import ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Collection(Base):
__tablename__ = "collection"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int]
archived_at: Mapped[datetime | None]
class Item(Base):
__tablename__ = "item"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int]
collection_id: Mapped[int] = mapped_column(ForeignKey("collection.id"))
name: Mapped[str]
created_at: Mapped[datetime]
deleted_at: Mapped[datetime | None]
subscriptions: Mapped[list["Subscription"]] = relationship()
class Subscription(Base):
__tablename__ = "subscription"
id: Mapped[int] = mapped_column(primary_key=True)
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"))
status: Mapped[str]
Declaring clauses#
fr.where_clause builds a
clause. A ready-made SQLAlchemy condition needs no function:
import fastapi_restly as fr
is_deleted = fr.where_clause(Item.deleted_at.is_not(None))
recently_created = fr.where_clause(
Item.created_at > func.now() - timedelta(days=7)
)
A clause is a predicate and never joins. Express a condition on a
related table with the relationship’s any() or has(), which generate
EXISTS. A join used for an existence check multiplies rows on
one-to-many relationships; EXISTS cannot.
has_active_subscription = fr.where_clause(
Item.subscriptions.any(Subscription.status == "active")
)
Decorate a function when the condition needs a Python branch. The function takes no parameters and runs each time the clause is applied:
from sqlalchemy import ColumnElement, true
@fr.where_clause
def owned_unless_admin() -> ColumnElement[bool]:
if Current.is_admin():
return true()
return Item.user_id == Current.user_id()
A function with parameters raises TypeError at declaration. The values
a condition needs come from context members, described under
Supplying values.
Grouping clauses per model#
A model’s clauses live in one
fr.ClauseNamespace
subclass. Declare model in the class body; on definition the namespace
validates itself and registers as the model’s clause namespace:
class ItemClauses(fr.ClauseNamespace):
model = Item
is_deleted = fr.where_clause(Item.deleted_at.is_not(None))
owned_by_user = fr.where_clause(Item.user_id == Current.user_id)
@fr.where_clause
@staticmethod
def owned_unless_admin() -> ColumnElement[bool]:
if Current.is_admin():
return true()
return Item.user_id == Current.user_id()
visible = fr.all_of(owned_by_user, fr.none_of(is_deleted))
trashed = fr.all_of(owned_by_user, is_deleted)
Earlier names in the class body are in scope for later compositions, as
visible shows. The @staticmethod under the clause decorator is for
the type checker, which reads a bare def in a class body as a method;
where_clause unwraps it. Usage is by class name: ItemClauses.visible,
ItemClauses.trashed, plain attribute access that any type checker
follows. Name the namespace after the model, and define it in the
model’s module: importing the model then guarantees the namespace is
registered. model is optional: a namespace without one is a plain group
of clauses, or a base class for namespaces that declare one, and declaring
model is what registers the namespace for its model.
The namespace validates itself at definition time. A public attribute that is not a clause raises, which catches the bare expression that forgot its wrapper:
class ItemClauses(fr.ClauseNamespace):
model = Item
is_deleted = Item.deleted_at.is_not(None) # TypeError at import
Helpers and constants are allowed with a leading underscore. A second namespace for the same model raises.
A tenant predicate that must survive every replacement scope belongs in the session-level rule documented under tenant row scoping.
Name clauses as predicate phrases that read truthfully after WHERE:
owned_by_user, is_deleted, has_active_subscription. Skip
mechanism suffixes such as _filter or _clause; the namespace and the
type already say what the attribute is.
One attribute name is reserved: a WhereClause declared as
default_scope becomes the scope every view read and every reference
check on the model applies, and undeclared means UNSCOPED; the
namespace enforces the shape at definition. Scopes owns
that topic.
Composing#
fr.all_of,
fr.any_of, and
fr.none_of compose clauses into
a new clause:
live_and_owned = fr.all_of(ItemClauses.owned_by_user, fr.none_of(is_deleted))
active_or_new = fr.any_of(has_active_subscription, recently_created)
none_of(a, b) is NOT over the OR of its operands: true when none
hold.
Composing with UNSCOPED#
Use fr.clauses.UNSCOPED to express no restriction. In boolean
composition it accepts every row, equivalent to SQL TRUE. It remains
a separate sentinel, not a WhereClause.
The composition functions accept this sentinel with the following rules.
Here C is a predicate clause, and the argument order does not affect
the result:
Expression |
Result |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
A |
|
A |
UNSCOPED is a no-op in AND composition. It cannot be dropped from OR
or NOT: every row OR published rows still means every row. NOT over
that result matches no rows. The false clause works as a scope too:
lists and counts are empty, and retrieve and reference checks return 404.
all_of removes UNSCOPED arguments before composing the remaining
clauses. With one clause left it returns that exact object. With none left it returns the sentinel. This
lets a predicate extend a possibly unscoped default without branching:
scope = fr.all_of(ItemClauses.default_scope, has_active_subscription)
The return type is WhereClause when a definite predicate is the first
or second argument and the other arguments are predicates or Unscoped.
Mixed argument lists outside those overloads use a safe union return
type. any_of can return Unscoped when any argument may be unscoped.
none_of always returns a WhereClause. Use apply_clauses for a
result that may be the sentinel, which is not callable.
all_of(), any_of(), and none_of() with no arguments raise.
Passing UNSCOPED explicitly counts as an argument.
Other operands are validated even when UNSCOPED determines the whole
result. A raw expression or a ContextParam in a boolean composition
still raises. Once validated, clauses discarded by OR or NOT
simplification are not evaluated and need no bound values.
apply_clauses(stmt, UNSCOPED) adds no restriction. Existing filters
on stmt stay in place, and other supplied clauses still apply.
UNSCOPED does not clear a statement that was already filtered.
Supplying values#
A clause takes no arguments. A value that changes per request or per call
is a member of a
ContextNamespace: the
application’s Current, or a small namespace declared
next to the code that uses it. The examples use the Current declared at
the top of this page.
Embed a member in a condition. It becomes a placeholder, filled with the bound value each time the clause is applied:
owned_item = fr.where_clause(Item.user_id == Current.user_id)
owned_collection = fr.where_clause(Collection.user_id == Current.user_id)
with Current.bind(user_id=42):
items = fr.apply_clauses(select(Item), owned_item)
collections = fr.apply_clauses(select(Collection), owned_collection)
One binding supplies every clause that uses the member.
Current.user_id without parentheses is the placeholder.
Current.user_id() reads the value immediately, which would require
an active binding at declaration time.
Put the column first. Item.user_id == Current.user_id builds SQL because
the column builds the comparison. Current.role != "member" raises
TypeError: Python would compare the member object, and the rule would
compile to WHERE true. A rule that compares a bound value with a constant
is a Python branch, so write it as a clause function that reads the member
in its body:
@fr.where_clause
def owned_by_current_user() -> ColumnElement[bool]:
return Item.user_id == Current.user_id()
Arithmetic with the member first, such as Current.now - Item.created_at,
and a member inside a plain tuple raise TypeError when the clause is
applied: SQLAlchemy would pass the member object to the database as a value.
Wrap the member with sqlalchemy.type_coerce(Current.now, DateTime), and
write a tuple as sqlalchemy.tuple_(...).
Both forms read the member when the clause is applied: by
apply_clauses, by a view read, by a reference check, or when the clause
is called. The statement carries the values with it, so it can run after
the binding has ended:
def owned_items_stmt(user_id: int):
with Current.bind(user_id=user_id):
return fr.apply_clauses(select(Item), owned_item)
rows = session.scalars(owned_items_stmt(42))
A member without a binding raises LookupError when the clause is
applied, never a silently unfiltered query. For values supplied per
request, bind Current from a FastAPI dependency.
A value that one part of the application uses gets its own namespace there:
class ReportPeriod(fr.ContextNamespace):
start: fr.ContextParam[datetime]
end: fr.ContextParam[datetime]
in_period = fr.where_clause(
Item.created_at.between(ReportPeriod.start, ReportPeriod.end)
)
with Current.bind(user_id=42), ReportPeriod.bind(start=aug_1, end=aug_31):
stmt = fr.apply_clauses(select(Item), owned_item, in_period)
Member names are local to their namespace. ReportPeriod.start and
another namespace’s start can appear in one statement, and neither
collides with a column or a hand-written bindparam() of the same name:
each placeholder compiles to its own numbered parameter, such as
:start_1.
A placeholder is filled only when a clause is applied. An expression that
embeds a member and never passes through a clause fails at execution with
SQLAlchemy’s “A value is required for bind parameter”. Call the clause for
its expression, or read the value with Current.user_id().
Applying clauses to a statement#
Statement construction stays plain SQLAlchemy.
fr.apply_clauses is the
bridge: it adds the clauses’ predicates to the statement’s WHERE:
from sqlalchemy import select
with Current.bind(user_id=42):
stmt = fr.apply_clauses(
select(Item), ItemClauses.visible, has_active_subscription
)
update() and delete() statements take clauses the same way. The
result is a normal Select, Update or Delete of the exact type the
statement had, so chain onto it freely:
from sqlalchemy import update
with Current.bind(user_id=42):
stmt = fr.apply_clauses(select(Item), ItemClauses.visible).where(Item.id == item_id)
restore = (
fr.apply_clauses(update(Item), ItemClauses.trashed)
.where(Item.id == item_id)
.values(deleted_at=None)
)
apply_clauses rejects a predicate that references a table the
statement does not select from:
fr.apply_clauses(select(Item), fr.where_clause(Collection.archived_at.is_(None)))
# TypeError: clause references table(s) not in the statement: collection;
# express the condition as EXISTS (.any()/.has())
Without this check SQLAlchemy adds the missing table to the FROM clause
and the query becomes a cartesian product that filters almost nothing.
Predicates inside EXISTS and IN (SELECT ...) subqueries bring their
own FROM and pass the check.
Using a clause in plain SQLAlchemy#
Calling a WhereClause
returns the raw ColumnElement, which drops into any SQLAlchemy
expression position: a hand-built .where(), a join condition, a CASE:
from sqlalchemy import and_, case, select
stmt = select(Item).where(ItemClauses.is_deleted(), Item.deleted_at < cutoff)
collection_is_archived = fr.where_clause(Collection.archived_at.is_not(None))
stmt = stmt.join(
Collection,
and_(Collection.id == Item.collection_id, collection_is_archived()),
)
status = case((ItemClauses.is_deleted(), "trash"), else_="live")
The call reads context members like any other application of the clause.
This path skips the table validation that apply_clauses performs. A
clause is never a boolean: if ItemClauses.is_deleted: raises
TypeError instead of always passing.
A value the caller already has#
A condition whose value is in hand where the statement is built needs no clause. Write a plain function that returns the expression:
def name_matches(term: str) -> ColumnElement[bool]:
return Item.name.ilike(f"%{term}%")
with Current.bind(user_id=42):
stmt = fr.apply_clauses(select(Item), ItemClauses.visible).where(
name_matches(term)
)
Wrap the expression where a clause is required, such as a per-read
scope=:
scope = fr.all_of(ItemClauses.visible, fr.where_clause(name_matches(term)))
A list route on a view takes the expression directly:
handle_get_many(list_params, where=...) narrows
inside the scope.
See also
Clauses API lists every symbol with its signature.