Database¶
The async SQLAlchemy-backed Model base class, session/transaction
management, audit logging, full-text search index creation, N+1 query
detection, a request/query profiler, and factories & seeders for tests
and local data.
db ¶
Model ¶
Bases: Base
Base class for application models.
Provides an Active-Record style async API (create, find, all,
save, delete) plus soft deletes and audit timestamps out of the
box. All methods operate on the session bound to the current request
via :data:zeython.db.session.current_session.
observe
classmethod
¶
Register an observer for this model class -- a class (instantiated
with no arguments) or an already-constructed instance. Typically
called once, e.g. in a service provider's boot().
Source code in src/zeython/db/model.py
validate ¶
Run __rules__ against the current field values. Does not raise.
A thin wrapper over :func:zeython.validation.validate applied to
this instance's own field values -- use that function directly to
run the same rule sets against a plain dict (a request payload, not
yet a model instance).
Source code in src/zeython/db/model.py
saving
async
¶
saved
async
¶
creating
async
¶
created
async
¶
updating
async
¶
updated
async
¶
deleting
async
¶
deleted
async
¶
find
async
classmethod
¶
find(
id_: Any,
*,
include_deleted: bool = False,
include: Iterable[str] = (),
for_update: bool = False,
) -> Self | None
Find by primary key, or None.
Pass for_update=True to lock the row for the rest of this
transaction (SELECT ... FOR UPDATE) on Postgres/MySQL -- a
second, concurrent find(..., for_update=True) for the same row
elsewhere (this process or another) blocks until this transaction
commits or rolls back, instead of both transactions reading the
same not-yet-committed state and independently making the same
decision from it. See :func:zeython.mfa.verify_and_consume for a
real example this closes: two concurrent requests spending the
same one-time recovery code.
SQLite has no row-level locking -- there, for_update=True
compiles away to an ordinary, unlocked SELECT (and logs a
warning saying so): two concurrent SQLite readers still both see
the pre-write state, the same as without this flag. Postgres and
MySQL support it fully.
Source code in src/zeython/db/model.py
paginate
async
classmethod
¶
paginate(
*,
page: int = 1,
per_page: int = 20,
include_deleted: bool = False,
include: Iterable[str] = (),
) -> Page[Self]
One page of results, plus the total row count for building pager UI.
total (and therefore total_pages) reflects every matching
row, not just this page -- it costs a second query (a COUNT(*)
over the same filters) to get that number. If you only need the
rows themselves, all() is cheaper.
Source code in src/zeython/db/model.py
search
async
classmethod
¶
Full-text search over __searchable__'s columns, ranked most
relevant first -- requires a matching index created once via a
migration (:mod:zeython.search, see docs/search.md)::
class Post(Model):
__searchable__ = ("title", "body")
results = await Post.search("async orm")
Dispatches to SQLite's FTS5 (MATCH + built-in rank) or
Postgres's tsvector/ts_rank depending on the current
connection's dialect -- whichever index your migration created.
Raises RuntimeError if __searchable__ is empty (search
isn't configured for this model) or the dialect isn't one of
those two (search isn't supported there yet).
A blank query (or one that's only whitespace) returns []
without touching the database -- there's nothing to search for.
Otherwise query is treated as plain text a user typed, not a
query mini-language: every term matches literally, so a stray
quote, hyphen, colon, or the word "AND" is searched for as text
instead of raising a syntax error or being parsed as an operator.
A model that declares a tenant_id column is scoped to
:func:~zeython.tenancy.current_tenant_id, the same as
find/all/find_by/paginate (see
:meth:_base_select) -- this hand-written SQL doesn't build on
_base_select() the way those do, so without this it would
silently search every tenant's rows.
Source code in src/zeython/db/model.py
to_dict ¶
Serialize columns, plus any eagerly-loaded relationships named in include.
Raises RuntimeError (not the framework's problem to swallow) if
a name in include wasn't eager-loaded on the query that fetched
this instance -- see docs/relationships.md for why serializing an
unloaded relationship isn't something this silently attempts.
Source code in src/zeython/db/model.py
Observer ¶
Base class for model observers -- mirrors Laravel's Model::observe().
A model's own lifecycle hooks (saving/saved/...) live on the
model itself, one implementation per class. An observer is a separate
object registered against a model class with :meth:Model.observe,
for cross-cutting concerns that don't belong on the model (search-index
sync, cache invalidation, audit logging) and that several independent
observers might want to react to. Override any subset of these hooks;
the rest are no-ops. Called in the same order as the model's own hooks,
immediately after each one.
Page
dataclass
¶
Bases: Generic[T]
One page of results from :meth:Model.paginate.
to_dict ¶
Serialize this page: items (via .to_dict() for Model
instances, as-is otherwise) plus pagination metadata.
Pass the current request to also get next_url/prev_url --
the same URL with only the page query param changed (every
other query param, e.g. per_page or a filter, carries over),
None when there is no next/previous page.
Source code in src/zeython/db/model.py
Base ¶
Bases: DeclarativeBase
Declarative base shared by every Zeython model.
Database ¶
Owns the async engine and session factory for a Zeython application.
read_url, if given, points at a read replica: :meth:read_replica
opens a session against it instead of the primary. Optional -- with no
read_url, :meth:read_replica just falls back to the primary, so
code written against it works unchanged whether or not a replica is
configured. See docs/database.md#read-replicas.
Source code in src/zeython/db/session.py
create_all
async
¶
Create all tables known to :class:Base. Intended for tests/dev; use migrations in production.
session
async
¶
Open a session, bind it to the current context, commit on success.
Source code in src/zeython/db/session.py
read_replica
async
¶
Open a session against the read replica (or the primary, if no
read_url was configured) -- for a read-heavy path that can
tolerate replication lag (a report, a dashboard, an analytics
query), not a substitute for :meth:session in general.
Read-only in practice, not by any framework-enforced check, and
Zeython doesn't duplicate one in Python: whether a write attempted
here is actually rejected depends entirely on how the replica is
configured at the database level (Postgres's
default_transaction_read_only, a MySQL replica user with no
write grants). Configured that way, it fails with a database error;
left writable, the write silently succeeds against the replica and
is never flushed back to the primary, since there's no commit here
-- a replica session is for reads, so nothing needs to be flushed.
Configure the replica read-only at the database level if you want a
stray write here to fail loudly instead of vanishing.
Source code in src/zeython/db/session.py
current_session ¶
Return the session bound to the current async context.
Raises if called outside a request handled by DatabaseSessionMiddleware
or outside an explicit async with database.session(): block.
Source code in src/zeython/db/session.py
transaction
async
¶
A SAVEPOINT-scoped nested transaction within the current session
-- for a chunk of work that should roll back independently of the rest
of the request if it fails, without undoing writes made earlier in the
same request or ending it. Requires an active session (see
:func:current_session)::
async with transaction():
await from_account.save()
await to_account.save()
# a failure inside the block rolls back only these two writes --
# anything saved before entering it, or after it exits normally,
# is unaffected.
Rolling back an entire request already happens for free and needs
no extra API: DatabaseSessionMiddleware rolls back the whole
session whenever the response status ends up 4xx/5xx --
whether that's a genuinely unhandled exception unwinding past it, or
one of zeython's own HTTPException subclasses your handler
raised (NotFoundException, ValidationException, etc.), which
Starlette's inner ExceptionMiddleware turns into that response
without ever re-raising past this middleware -- see
:class:DatabaseSessionMiddleware. transaction() is for the
narrower case where you catch the failure yourself, keep the request
going, and still return a success response, but don't want that
chunk's partial writes kept.
Source code in src/zeython/db/session.py
audit_log ¶
Audit logging: an automatic changelog of who created, updated, or deleted which model records, and what changed.
Built entirely on the existing :class:~zeython.db.model.Observer system
rather than a new mechanism -- :class:AuditObserver is an Observer
you attach to whichever models need a paper trail::
Post.observe(AuditObserver(AuditLog))
AuditLog is your own :class:~zeython.db.Model subclass (columns:
auditable_type, auditable_id, event, changes, actor_type,
actor_id) -- the same record_model pattern
:mod:zeython.notifications's database channel uses, so each project
owns its own migration for the table instead of the framework mandating
one. Generated for you by zeython new under
app/Models/audit_log.py.
Every audited write and its audit-log row share the same database session
and therefore the same transaction: :class:~zeython.db.session.DatabaseSessionMiddleware
commits or rolls back both together, so an audit entry can never exist
without the change it describes actually landing, or vice versa.
The actor attributed to each entry -- who did this -- comes from
:func:current_actor, a contextvar :class:AuditActorMiddleware sets
automatically per request from whichever authentication scheme is
active. Outside a request (a background job, a console command), call
:func:set_actor yourself before making the change.
AuditActorMiddleware ¶
Pure ASGI middleware: sets :func:current_actor for the duration of
the request from whichever authentication scheme resolves a user first
-- :func:zeython.auth.current_user (session cookie), then
:func:zeython.api_auth.current_api_user (bearer token). Leaves the
actor anonymous (None) if neither resolves one, or if neither
module's middleware is registered at all -- both are tried
best-effort, so this works whether an app uses cookie auth, token
auth, both, or neither.
Registered by default in a generated project, the same reasoning
:class:~zeython.request_id.RequestIdServiceProvider relies on: with
no model actually being audited (nothing calls Model.observe(AuditObserver(...))),
this only sets a contextvar nothing reads, so there's nothing to get
wrong by always registering it.
AuditObserver ¶
Bases: Observer
Writes one row to record_model for every create/update/delete of
whatever model this is attached to, via :meth:~zeython.db.model.Model.observe::
Post.observe(AuditObserver(AuditLog))
changes records: every non-hidden field's value for created;
only the fields that actually changed, as {"old": ..., "new": ...}
pairs, for updated (nothing recorded if nothing did); every
non-hidden field's last known value (as "old", "new" left
None) for deleted -- captured before the row is gone, so a hard
delete's audit trail still shows what was deleted, not just that
something was. Fields named in the audited model's own __hidden__
(the same convention :meth:~zeython.db.model.Model.to_dict uses to
keep password hashes etc. out of serialized output) are never
recorded, in either direction.
Never attach this to record_model itself -- AuditLog.observe(AuditObserver(AuditLog))
-- each audit-log row's own creation would trigger another one,
without end.
Source code in src/zeython/audit_log.py
AuditLogServiceProvider ¶
Bases: ServiceProvider
Registers :class:AuditActorMiddleware, so :func:current_actor is
set automatically from whichever authentication scheme handled the
current request::
app.register(AuditLogServiceProvider)
Register this first -- before DatabaseServiceProvider,
AuthServiceProvider, ApiAuthServiceProvider, before anything
else that calls add_middleware(). add_middleware() prepends,
so the most recently registered middleware wraps outermost; every
middleware-adding provider registered after this one then wraps
around AuditActorMiddleware, pushing it further inward no
matter how many more get added later. It needs to end up innermost:
resolving the actor needs a database session already open (a query
against your user model) and, for cookie auth, SessionMiddleware
to have already parsed the session cookie -- both are provided by
middleware layers that must therefore sit outside this one.
Registered too late (anywhere after DatabaseServiceProvider),
current_user()/current_api_user() both fail silently from
inside :meth:AuditActorMiddleware._resolve_actor's broad except,
and every audited entry ends up anonymous even for an authenticated
request -- the same class of subtle ordering trap documented on
:class:~zeython.maintenance.MaintenanceModeServiceProvider.
Doesn't attach auditing to any model on its own -- that needs your own
record_model (see :mod:zeython.audit_log's own docstring) and at
least one SomeModel.observe(AuditObserver(YourAuditLog)) call,
typically in another service provider's boot() once your models
are all defined. See docs/audit-log.md.
Source code in src/zeython/providers.py
current_actor ¶
The (actor_type, actor_id) attributed to audit log entries written
from here on in the current context, or None for an anonymous/system
action. actor_type is the actor model's class name (e.g. "User").
Source code in src/zeython/audit_log.py
set_actor ¶
Attribute audit log entries written from here on (in this async
context) to actor -- for code with no request/middleware to infer
it from (a background job, a console command, a scheduled task)::
async def handle(self) -> None:
set_actor(await User.find(self.performed_by_id))
await order.update(status="shipped")
Pass None to explicitly record subsequent changes as anonymous/system
-- e.g. to override an actor :class:AuditActorMiddleware already set,
for a change your own code makes on the user's behalf but that isn't
really their action.
Source code in src/zeython/audit_log.py
audit_trail
async
¶
Every audit log entry for model, oldest first::
history = await audit_trail(AuditLog, post)
Source code in src/zeython/audit_log.py
search ¶
Full-text search index creation for :meth:zeython.db.model.Model.search
-- run once from your own Alembic migration, not at request time.
Dispatches to whichever native full-text engine your database already has
-- SQLite's FTS5 virtual tables, Postgres's tsvector/GIN indexes -- so
there's no new dependency and no separate search service to run and keep
in sync. That's also the trade-off: cross-database consistency and
relevance tuning are whatever each engine's own full-text implementation
gives you, not a shared abstraction over both. For requirements a
database's own full-text search can't meet (typo tolerance, faceting,
huge multi-tenant indexes), point at a dedicated search service instead
and skip this module entirely -- Model.search() is deliberately just a
thin dispatch over raw SQL, not a search engine of its own.
create_fts5_index ¶
Create a SQLite FTS5 external-content index over table's
columns, backfilled from every existing row, and kept in sync by
three triggers (AFTER INSERT/UPDATE/DELETE) -- SQLite's own
documented recipe for external-content FTS5 tables. Unlike an
ORM-level hook, these triggers fire for any write to table
(raw SQL, another process), not just ones that go through
:class:~zeython.db.model.Model.
Call this once from a migration's upgrade()::
from zeython.search import create_fts5_index
def upgrade() -> None:
create_fts5_index(op, "posts", ["title", "body"])
Requires id to be the table's integer primary key (true of every
:class:~zeython.db.model.Model subclass) -- FTS5's content_rowid
is set to it.
Source code in src/zeython/search.py
drop_fts5_index ¶
Undo :func:create_fts5_index -- call from a migration's downgrade().
Source code in src/zeython/search.py
create_tsvector_index ¶
create_tsvector_index(
op: Any,
table: str,
columns: Iterable[str],
*,
language: str = "english",
column_name: str = "search_vector",
) -> None
Add a generated tsvector column over table's columns plus
a GIN index on it -- Postgres keeps a GENERATED ALWAYS ... STORED
column in sync automatically on every write, no triggers needed.
Call this once from a migration's upgrade()::
from zeython.search import create_tsvector_index
def upgrade() -> None:
create_tsvector_index(op, "posts", ["title", "body"])
language must match whatever Model.__search_language__ the
corresponding model uses (default "english" on both sides).
Source code in src/zeython/search.py
drop_tsvector_index ¶
Undo :func:create_tsvector_index -- call from a migration's downgrade().
Source code in src/zeython/search.py
database ¶
Factory ¶
Bases: ABC, Generic[ModelT]
Base class for a model factory. One subclass per model.
::
class UserFactory(Factory[User]):
model = User
def definition(self, sequence: int) -> dict:
return {
"name": f"User {sequence}",
"email": f"user{sequence}@example.com",
"password_hash": hash_password("password"),
}
user = await UserFactory().create()
users = await UserFactory().create_many(5)
unsaved = UserFactory().make(name="Override") # not persisted
definition
abstractmethod
¶
Default attributes for one instance.
sequence starts at 1 and increments on every make()/
create() call on this factory instance -- use it to keep
unique-constrained columns (an email, a slug) actually unique
across a batch, without reaching for a random-data library.
Source code in src/zeython/database/factory.py
make ¶
Build an instance in memory. Not persisted -- nothing touches the database.
Source code in src/zeython/database/factory.py
create
async
¶
Build an instance and save it, the same way Model.create() does.
Requires an active database session (a request, or an explicit
async with database.session(): block) -- same requirement as
every other persistence method in the framework.
Source code in src/zeython/database/factory.py
Seeder ¶
Bases: ABC
Base class for a database seeder.
::
class UserSeeder(Seeder):
async def run(self) -> None:
await UserFactory().create(email="admin@example.com")
await UserFactory().create_many(9)
class DatabaseSeeder(Seeder):
async def run(self) -> None:
await self.call(UserSeeder, PostSeeder)
Runs inside a single database session opened by the CLI (zeython db
seed) -- Model.create()/factory create() calls inside run()
work exactly as they do in a request handler, no session parameter to
thread through.
Source code in src/zeython/database/seeder.py
discover_seeders ¶
Every :class:Seeder subclass in database/seeders/*.py, keyed by class name.
Source code in src/zeython/database/seeder.py
n_plus_one ¶
N+1 query detection: an opt-in, dev-only warning when a single request
fires the exact same SQL statement shape suspiciously many times -- the
classic symptom of fetching each row's related object one at a time in a
loop instead of eager-loading with include=(...) (see
docs/relationships.md).
Hooks SQLAlchemy's before_cursor_execute event on the engine and
counts statements (bound parameters aside -- the same query with
different parameter values still produces an identical parameterized
statement string, so SELECT ... WHERE id = ? run once per row in a
loop groups together as one entry with a high count) per request, via a
:class:contextvars.ContextVar, the same mechanism
:mod:zeython.request_id uses.
N1QueryDetectionMiddleware ¶
Pure ASGI middleware: counts SQL statements per request, warning
(zeython.n_plus_one, at WARNING) for any statement shape that
ran more than threshold times in the same request.
Source code in src/zeython/n_plus_one.py
N1QueryDetectionServiceProvider ¶
Bases: ServiceProvider
Hooks the SQLAlchemy engine and registers
:class:N1QueryDetectionMiddleware -- not registered by default, and a
no-op in boot() unless APP_DEBUG is true, so it's safe to
always register (including in a production main.py) without
worrying about the per-query event-listener overhead or leaking query
text/counts from real traffic into production logs::
# main.py
app.register(DatabaseServiceProvider) # must run first -- binds Database
app.register(N1QueryDetectionServiceProvider(app))
Configurable via .env:
N1_QUERY_THRESHOLD-- default 10.
Source code in src/zeython/providers.py
profiler ¶
Request/query profiler: an opt-in, dev-only record of every SQL query a request ran, with duration -- the Laravel Telescope / Django Debug Toolbar question ("how many queries did this request run, and how long did they take") answered without a bundled UI, since most of what this framework serves is a JSON API rather than server-rendered HTML pages a toolbar overlay could attach to.
Every response gets X-Debug-Duration-Ms/X-Debug-Query-Count/
X-Debug-Query-Time-Ms headers instead -- inspectable from any HTTP
client, a browser's network tab, or a test assertion, no special
tooling required. A request that crashes gets the same information baked
into the debug error page/body (see :mod:zeython.exceptions).
Deliberately separate from :mod:zeython.n_plus_one, which answers a
different, narrower question (the same statement shape repeated
suspiciously many times) and can be registered independently of this.
RequestProfilerMiddleware ¶
RequestProfilerMiddleware(
app: Callable[..., Any],
*,
slow_query_threshold_ms: float = DEFAULT_SLOW_QUERY_MS,
)
Pure ASGI middleware: records this request's queries via a
:class:~contextvars.ContextVar (the same mechanism
:mod:zeython.request_id/:mod:zeython.n_plus_one use), stamps
X-Debug-Duration-Ms/X-Debug-Query-Count/X-Debug-Query-Time-Ms
on the response, and logs (zeython.profiler, at WARNING) any
individual query at or past slow_query_threshold_ms.
Source code in src/zeython/profiler.py
RequestProfilerServiceProvider ¶
Bases: ServiceProvider
Hooks the SQLAlchemy engine and registers
:class:RequestProfilerMiddleware -- not registered by default, and a
no-op in boot() unless APP_DEBUG is true, so it's safe to
always register (including in a production main.py) without
worrying about the per-query event-listener overhead or leaking query
text/timing from real traffic::
# main.py
app.register(DatabaseServiceProvider) # must run first -- binds Database
app.register(RequestProfilerServiceProvider(app))
Configurable via .env:
PROFILER_SLOW_QUERY_MS-- default 100.
Source code in src/zeython/providers.py
current_queries ¶
The SQL queries executed so far during the current request, in
order -- empty if :class:RequestProfilerServiceProvider isn't
registered (or APP_DEBUG is false), or if called outside a
request/response cycle the middleware wrapped.