Database¶
The async SQLAlchemy-backed Model base class, session/transaction
management, 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
¶
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
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: a real
replica is normally configured read-only at the database level
(Postgres's default_transaction_read_only, a MySQL replica
user with no write grants), so a write attempted here fails with a
clear database error rather than being silently accepted -- Zeython
doesn't duplicate that check in Python. There's also no commit: a
replica session is for reads, so nothing here needs to be flushed.
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: an exception that propagates out of a request handler
unwinds past DatabaseSessionMiddleware and rolls back the whole
session (see :meth:Database.session) -- Starlette re-raises the
original exception to outer ASGI middleware even after one of your own
exception handlers already sent a response for it. transaction()
is for the narrower case where you catch the failure yourself and keep
the request going, but still don't want its partial writes kept.
Source code in src/zeython/db/session.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.