The composable model behaviours in sillo.record.mixins. Soft deletes, timestamps, ULIDs, serialisation, validation before save, and cascading deletes.
from sillo.record.mixins import ( SoftDeletesMixin, TimestampsMixin, HasUlidMixin, SerializesToDictMixin, ValidatesBeforeSaveMixin, CascadesDeletesMixin,)Six behaviours you compose onto a model. Two of them (soft deletes and
serialisation) are already on the base model; the mixins exist
so a model inheriting from Tortoise’s Model directly can have them, and so
the behaviour has a name.
class Invoice(Model, ValidatesBeforeSaveMixin, CascadesDeletesMixin): _cascade_deletes = ["line_items"]
async def validate(self): if self.total < 0: raise ValueError("total cannot be negative")SoftDeletesMixin
Section titled “SoftDeletesMixin”Deleting a row is usually the wrong thing. The posts it authored, the orders it placed and the audit trail it appears in all still have to resolve to something.
await invoice.soft_delete() # sets deleted_atawait invoice.restore() # clears itawait invoice.force_delete() # actually deletes the row
Invoice.active() # deleted_at IS NULLInvoice.only_trashed() # deleted_at IS NOT NULLInvoice.with_trashed() # everything
invoice.is_trashed # boolA soft delete does not cascade, and does not release a unique constraint. A soft-deleted account still occupies its email address, usually correct, and worth knowing before someone tries to re-register.
TimestampsMixin
Section titled “TimestampsMixin”await post.touch() # updated_at = now, savedpost.set_created_at() # created_at = now, not savedtouch() is for recording activity that changed nothing else, a “last seen”
without another column.
The fields themselves are on the base model; this adds the two methods.
HasUlidMixin
Section titled “HasUlidMixin”from sillo.record.fields import ULIDFieldfrom sillo.record.mixins import HasUlidMixin
class Event(Model, HasUlidMixin): id = ULIDField()Generates a ULID primary key before insert. A 26-character identifier that sorts by creation time as a string, so it is usable as a clustered key without leaking a row count the way an auto-increment does.
Needs the python-ulid package:
uv add python-ulidWithout it, the mixin raises with that instruction rather than an
AttributeError deep in a save.
SerializesToDictMixin
Section titled “SerializesToDictMixin”post.to_dict(exclude=["body"])post.to_dict(include=["id", "title"])post.to_dict(max_depth=1)post.to_json(indent=2)The same to_dict/to_json as the base model, plus max_depth (default 3)
for how far into fetched relations to descend.
Depth exists because a serialiser that follows relations without a limit turns one row into the whole graph, and a cycle turns it into a hang. Cap it at what the response actually needs.
For anything leaving the process, prefer a Pydantic response model. See the caution under Serialisation.
ValidatesBeforeSaveMixin
Section titled “ValidatesBeforeSaveMixin”class Invoice(Model, ValidatesBeforeSaveMixin): async def validate(self): if self.total < 0: raise ValueError("total cannot be negative") if self.due_at and self.due_at < self.issued_at: raise ValueError("due date precedes the issue date")validate() runs before every save(). Raise to stop it.
This is the invariant layer: rules that must hold however the row was written, including from a console command, a migration or a test. Request-shape validation belongs in front of the handler, where it can produce a 422 with field-level detail.
It is async, so a uniqueness check that has to query is allowed:
async def validate(self): if await Invoice.filter(number=self.number).exclude(id=self.id).exists(): raise ValueError(f"invoice number {self.number} is already used")Although a unique constraint is the reliable version of that. The query above still races. Use both: the constraint for correctness, the check for a decent error message.
CascadesDeletesMixin
Section titled “CascadesDeletesMixin”class Order(Model, CascadesDeletesMixin): _cascade_deletes = ["line_items", "shipments"]Deleting an order deletes the related rows named in _cascade_deletes first.
Each name is a related-name on this model. They are deleted in the order listed, then the row itself.
Wrap it in a transaction. A cascade that fails halfway has already deleted the children.
async with transaction(): await order.delete()