Mixins
Standard mixins in simple_module_db.mixins. Compose as many as you need alongside the per-module Base.
from simple_module_db.base import create_module_base
from simple_module_db.mixins import (
AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin,
)
Base = create_module_base("orders")
class Order(
Base, AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin,
table=True,
):
__tablename__ = "orders_order"
id: int | None = Field(default=None, primary_key=True)
...Each mixin adds columns and attaches SQLAlchemy event listeners. The session dependency (get_db) wires the request user / tenant into those listeners so writes are stamped automatically.
AuditMixin
Adds:
created_at: datetime— set on INSERT.updated_at: datetime— set on INSERT and on every UPDATE.created_by: int | None— stamped fromrequest.state.principal.user_id.updated_by: int | None— same, on every update.
A before_insert listener and a before_update listener populate these. When no principal is in scope (e.g. a migration data-migration or a boot-time seeder), created_by and updated_by stay null. The timestamps always fire.
Use on every table that participates in business workflows. Skip on pure enum / lookup tables.
SoftDeleteMixin
Adds:
is_deleted: bool— defaultFalse.deleted_at: datetime | None.deleted_by: int | None.
On session.delete(instance), a before_delete listener converts the delete into an UPDATE that sets the three fields. The row stays.
Selects auto-filter soft-deleted rows. A before_compile query rewrite appends WHERE is_deleted = FALSE to any query touching a SoftDeleteMixin table.
Bypass for admin / audit
To include soft-deleted rows explicitly (admin view, audit reports), pass include_deleted=True as an execution option:
from sqlmodel import select
stmt = select(Order).execution_options(include_deleted=True)
rows = (await session.exec(stmt)).all()The flag is checked in the query-rewrite hook; when set, the filter is skipped.
Hard-delete
If you genuinely need to remove the row, call the underlying SQLAlchemy DELETE — the soft-delete hook only triggers for session.delete(instance):
from sqlmodel import delete
await session.exec(
delete(Order).where(Order.id == order_id)
)Use sparingly — audit trails and downstream systems may depend on historical rows.
MultiTenantMixin
Adds:
tenant_id: str— populated fromrequest.state.tenant_id(set byTenantMiddleware).
Automatic filtering
When SM_MULTI_TENANT=true and a request has an active tenant, selects auto-filter: WHERE tenant_id = :current_tenant. The filter runs for every SELECT that touches a MultiTenantMixin table.
Automatic stamping
INSERTs populate tenant_id from the request context. Cross-tenant writes raise ValueError — if a session somehow ends up with two tenants' rows, the commit fails loudly.
Bypass
Admin operations that span tenants (billing consolidation, platform-wide reports) need to opt out:
stmt = select(Order).execution_options(skip_tenant_filter=True)Or run inside a context manager that clears the tenant:
from simple_module_db.tenancy import no_tenant
async with no_tenant():
rows = (await session.exec(select(Order))).all()Use these escape hatches rarely and in well-named admin endpoints; they defeat the primary isolation guarantee.
VersionedMixin
Adds:
version: int— default1, incremented on every UPDATE viabefore_updatelistener.
Useful for optimistic concurrency control: check the version didn't change between read and write, abort if it did.
async def update(self, order_id: int, expected_version: int, data):
order = await self.session.get(Order, order_id)
if order.version != expected_version:
raise StaleVersion(order.id, order.version, expected_version)
for k, v in data.model_dump(exclude_none=True).items():
setattr(order, k, v)
await self.session.flush()
return orderVersionedMixin doesn't ship with a check — you perform the comparison in the service layer. The mixin only ensures version increments.
Composing mixins
Order matters only for MRO of columns that share names; the mixins here don't overlap, so combine in any order. A typical "fully-featured" table:
class Order(
Base,
AuditMixin, # created/updated timestamps + author
SoftDeleteMixin, # is_deleted flag + auto-filter
MultiTenantMixin, # tenant_id + auto-filter
table=True,
):
__tablename__ = "orders_order"
...That covers: who created it, who last touched it, when, whether it's been deleted, and which tenant owns it — 8 columns of framework concern, 0 lines of service code.
What the mixins don't do
- They don't add indexes beyond the PKs. Add your own on
created_at/tenant_id/is_deletedwhen you query them heavily. - They don't cascade. Soft-deleting an
Orderdoes not soft-delete itsOrderLines. Implement cascades explicitly in the service if needed. - They don't replace authorization.
MultiTenantMixinfilters reads; it does not check that the requesting user is allowed to see rows in this tenant. Enforce that in middleware.