Queries: Q and QuerySet¶
Alchemiq provides two composable layers for building database queries:
Q - a predicate tree for filtering - and
QuerySet - an immutable lazy builder that compiles to a
SQLAlchemy SELECT.
Q objects¶
A Q object is a single filter condition expressed as
field__operator=value keyword arguments:
from alchemiq import Q
q = Q(status="active") # status = 'active' (implicit __eq)
q = Q(age__gte=18) # age >= 18
q = Q(role__in=["admin", "staff"]) # role IN ('admin', 'staff')
Supported lookup operators¶
Suffix |
SQL equivalent |
|---|---|
(none) or |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Combining predicates¶
Combine Q objects with Python’s bitwise operators:
# AND - both conditions must hold
active_adults = Q(status="active") & Q(age__gte=18)
# OR - at least one must hold
admins_or_staff = Q(role="admin") | Q(role="staff")
# NOT - negate a condition
not_deleted = ~Q(deleted=True)
Operators nest to arbitrary depth, giving you full boolean algebra:
q = (Q(role="admin") | Q(role="staff")) & ~Q(banned=True)
Passing Q to filter/get¶
Pass a composed Q to any filter() or get() call:
from alchemiq import Repository, Q
from myapp.models import User
users = Repository(User)
active = await users.filter(Q(status="active") & Q(age__gte=18)).all()
admin = await users.get(Q(role="admin"), email="alice@example.com")
QuerySet¶
A QuerySet is a lazy, immutable query builder.
Every builder method returns a new QuerySet; the original is unchanged.
No I/O happens until you call a terminal method.
Building a query¶
from alchemiq import QuerySet
from myapp.models import Order
# filter, order, and limit are all lazy - no query yet
qs = (
QuerySet(Order)
.filter(Q(status="paid") & Q(total__gte=100))
.order_by("-created_at")
.limit(50)
)
Builder methods:
Method |
What it does |
|---|---|
|
Narrow results (AND) |
|
Exclude matching rows (NOT AND) |
|
Set |
|
Apply |
|
Apply |
|
Emit |
|
Project to named columns |
|
JOIN-load relationships (joinedload) |
|
SELECT-IN-load relationships (selectinload) |
Slicing also works: qs[10:30] is equivalent to .offset(10).limit(20).
Terminal methods¶
Terminals execute the query and return results:
Terminal |
Returns |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
All terminals are async:
orders = await QuerySet(Order).filter(status="paid").order_by("-created_at").all()
first_order = await QuerySet(Order).order_by("created_at").first()
count = await QuerySet(Order).filter(status="pending").count()
Set-based write operations and the full-table guard¶
QuerySet exposes .update() and .delete() for bulk writes.
Both require at least one filter to guard against accidental full-table mutations:
# raises QueryError - no filter set
await QuerySet(Order).update(status="archived")
# correct: filter first
n = await QuerySet(Order).filter(status="pending").update(status="archived")
When you genuinely need to touch every row, use the explicit escape-hatch methods
.update_all() and .delete_all(), which carry no filter requirement:
# full-table update - intentional, lexically distinct call
await QuerySet(Order).update_all(status="archived")
# full-table delete
await QuerySet(Order).delete_all()
The same guard and escape hatches are available on Repository
directly: repo.filter(...).update(...) vs repo.update_all(...).
Q serialization for RPC¶
In a microservice architecture you may need to pass a filter from one service to
another - over a message queue, HTTP, or a query parameter.
Q is fully serializable:
q = Q(status="active") & Q(age__gte=18)
# serialize
payload = q.to_data() # compact JSON-safe nested list
raw = q.to_bytes() # compact UTF-8 JSON bytes
token = q.to_base64() # urlsafe base64 string (safe for query params)
Reconstruct on the receiving side - always validating fields against the model:
q = Q.from_data(payload, User)
q = Q.from_bytes(raw, User)
q = Q.from_base64(token, User)
Allow-list and deny-list¶
Deserialization validates every field path against the model.
By default, relationship traversal (user__role) is denied.
Use allow to opt individual paths in, and deny to block specific fields:
q = Q.from_data(
payload,
User,
allow={"status", "age"}, # only these fields accepted
deny={"password_hash"}, # also block this one explicitly
)
from_bytes and from_base64 accept the same allow / deny keyword
arguments.
Exceptions raised on invalid payloads:
DeserializationError- malformed payload structure or invalid JSON.DisallowedFieldError- field not in allow-list, in deny-list, or unknown on the model.UnknownOperatorError- unrecognised lookup suffix in the payload.