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

EXCLUDE

"exclude"

Default - live rows only (deleted_at IS NULL)

INCLUDE

"include"

Live and deleted rows (no filter on deleted_at)

ONLY

"only"

Deleted rows only (deleted_at IS NOT NULL)


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 as None (the foreign-key column keeps its value);

  • traversal filters such as filter(author__name="Bob") do not match through a tombstoned author;

  • 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"})