Soft delete¶
When a model opts in to soft deletion, calling delete() never removes the
physical row. Instead, alchemiq stamps a deleted_at timestamp on the row
and all subsequent queries silently skip it. Callers work with “live” data by
default; tombstoned rows are always there when you need them.
Enabling soft delete¶
Set soft_delete = True in the model’s inner Meta class:
from alchemiq import Model
from alchemiq.types import PK
class Article(Model):
id: PK[int]
title: str
class Meta:
soft_delete = True
Alchemiq injects a deleted_at column (TIMESTAMP WITH TIME ZONE,
nullable) automatically. You do not declare it yourself.
Default behaviour - deleted rows are excluded¶
Every query on a soft-delete model applies WHERE deleted_at IS NULL
transparently. No filter code is needed:
from alchemiq import Repository
from myapp.models import Article
articles = Repository(Article)
# Only live (non-deleted) articles are returned - no extra filter needed.
live = await articles.filter().all()
one = await articles.get(id=42)
Deleting a row¶
await articles.delete(42)
# The row still exists in the database; deleted_at is now set.
Signals pre_delete / post_delete fire normally.
Querying tombstoned rows¶
Two chainable methods control which rows are visible.
Include deleted rows alongside live ones¶
all_articles = await articles.filter().with_deleted().all()
Show only deleted rows¶
tombstones = await articles.filter().only_deleted().all()
These methods are also available on QuerySet directly:
from alchemiq import QuerySet
from myapp.models import Article
qs = QuerySet(Article).with_deleted().filter(title__icontains="draft")
results = await qs.all()
Deleted-mode constants¶
The _deleted parameter on QuerySet accepts one of three
string constants from alchemiq.query.soft_delete:
Constant |
Value |
Effect |
|---|---|---|
|
|
Default - live rows only ( |
|
|
Live and deleted rows (no filter on |
|
|
Deleted rows only ( |
Relationships and joins¶
The liveness filter follows the query through relationships, not just the root
model. By default (EXCLUDE mode):
prefetch_related()collections contain live rows only;a
select_related()target that has been soft-deleted loads asNone(the foreign-key column keeps its value);traversal filters such as
filter(author__name="Bob")do not match through a tombstonedauthor;to_dict(relations=...)therefore never serializes tombstones.
with_deleted() lifts the filter for the whole statement - relationship
loads and joins included:
post = await posts.with_deleted().select_related("author").get(id=3)
post.author # loaded even if the author is soft-deleted
only_deleted() applies to the root model only; relations of a tombstoned
row load unfiltered so that administrative and restore tooling can see the
full picture.
Two escape hatches bypass the filter entirely: repo.restore() /
repo.hard_delete() (they must reach tombstones), and any session you
create yourself with native SQLAlchemy - the filter is attached only to
sessions created by alchemiq.
Restoring a deleted row¶
article = await articles.restore(42)
# deleted_at is cleared; the row rejoins the live set.
restore() raises ConfigError if called on a non-soft-delete model and
NotFoundError if no tombstoned row with that primary key exists.
Hard deletion¶
When you need to remove the physical row regardless of its deletion state, use
hard_delete():
await articles.hard_delete(42)
# The row is gone from the database.
hard_delete() fires the pre_delete / post_delete signals and works
on both live and already soft-deleted rows.
Serialization note¶
to_dict() serializes every column declared on the model, including the
injected deleted_at key. to_schema() exposes deleted_at as a field
in the generated Pydantic class. A live row will have "deleted_at": None
in the serialized output; a tombstoned row will carry the timestamp:
data = article.to_dict(mode="json")
# {"id": 42, "title": "...", "deleted_at": "2025-03-01T12:00:00+00:00"}
Exclude it explicitly when it should not appear in API responses:
data = article.to_dict(exclude={"deleted_at"})