Source code for codegen_database.plugins.soft_delete
"""Soft-delete plugin for codegen_database dimensions.
Intercepts the INSTEAD OF DELETE trigger on the dimension view and
converts it into a timestamp-based soft-delete. The dimension view is
filtered to hide soft-deleted rows, keeping the interface clean.
The plugin itself is factory-agnostic: it only records *intent* in the
context (the soft-delete column name and the delete-trigger template to
use). Each factory's table plugin injects the ``deleted_at`` column on
the correct backing table, and each factory's delete template performs
the timestamp update. The exact behaviour per factory type:
- **Simple** (``table_key="raw_table"``): ``UPDATE raw_table SET
deleted_at = now() WHERE id = OLD.id``. The view appends
``WHERE deleted_at IS NULL``.
- **AppendOnly** (``table_key="attributes"``): inserts a new attributes
version with ``deleted_at = now()`` and updates the root FK pointer.
The view appends ``WHERE deleted_at IS NULL``.
- **EAV** (``table_key="entity"``): ``UPDATE entity SET deleted_at =
now() WHERE id = OLD.id``. The ``deleted_at`` column lives on the
auto-generated entity table and the pivot view filters it *in-query*
(a plain ``WHERE`` append would land after the pivot's ``GROUP BY``),
so the view-patching step is skipped.
- **Ledger**: not applicable -- ledger rows are immutable by design.
Usage::
# Simple dimension
class Users(Base):
__tablename__ = "users"
__table_args__ = {"schema": "public"}
__plugins__ = [SoftDeletePlugin()]
name = Column(String, nullable=False)
# AppendOnly dimension
class Students(Base):
__tablename__ = "students"
__table_args__ = {"schema": "public"}
__factory__ = CodegenDatabaseAppendOnly
__plugins__ = [SoftDeletePlugin(table_key="attributes")]
name = Column(String, nullable=False)
# EAV dimension
class Products(Base):
__tablename__ = "products"
__table_args__ = {"schema": "public"}
__factory__ = CodegenDatabaseEAV
__plugins__ = [SoftDeletePlugin(table_key="entity")]
sku = Column(String, nullable=False)
The ``deleted_at`` column is injected automatically onto the backing
table -- you do **not** need to declare it on the model. For Simple and
AppendOnly dimensions you *may* declare it explicitly (as a
``nullable=True`` ``DateTime`` column) if you want it visible on the
model; the injection is skipped when the column already exists. For EAV
you must **not** declare it: user columns become EAV attributes, and the
soft-delete flag must live on the entity table instead.
After soft-deletion the row remains in the backing table with
``deleted_at`` set. The view ``{tablename}`` only shows rows where
``deleted_at IS NULL``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime
from codegen_database.plugin import (
Dynamic,
Plugin,
PluginCollection,
produces,
requires,
)
from codegen_database.plugins.trigger import (
DELETE_TRIGGER_OVERRIDE_KEY,
DeleteTriggerOverride,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from codegen_database.factory.context import FactoryContext
[docs]
def soft_delete_columns(ctx: FactoryContext) -> list[Column]:
"""Return the soft-delete column(s) to inject onto a backing table.
Factory table plugins splat this into their ``Table(...)`` call so
the ``deleted_at`` column is created whenever a
:class:`SoftDeletePlugin` recorded its intent in ``ctx``.
Returns an empty list when no soft-delete is configured, or when the
user already declared the column among ``schema_items`` (Simple and
AppendOnly allow that), so it is safe to call unconditionally.
Args:
ctx: The factory context.
Returns:
A one-element list with the nullable ``DateTime`` soft-delete
column, or an empty list.
"""
name = ctx.get("deleted_at_column")
if name is None or any(col.key == name for col in ctx.columns):
return []
return [Column(name, DateTime(timezone=True), nullable=True)]
[docs]
@produces(
"deleted_at_column", DELETE_TRIGGER_OVERRIDE_KEY, "exclude_from_mutations"
)
class SoftDeletePluginBeforeRoot(Plugin):
"""Record soft-delete intent for the factory's table/trigger plugins.
Stores the soft-delete column name under ``deleted_at_column`` (read
by the factory's table plugin to create the column and by EAV's view
builder to filter the pivot) and injects a
:class:`~codegen_database.plugins.trigger.DeleteTriggerOverride` so
the factory's ``InsteadOfTriggerPlugin`` renders a soft-delete body
instead of a physical DELETE -- without the factory's ops builder
needing any soft-delete-specific code. This plugin runs *before* the
table plugin (which declares ``deleted_at_column`` in its
``requires``).
Args:
column_name: Name of the soft-delete timestamp column
(default ``"deleted_at"``).
"""
def __init__(self, column_name: str = "deleted_at") -> None:
"""Store the column name."""
self.column_name = column_name
[docs]
def run(self, ctx: FactoryContext) -> None:
"""Record the soft-delete column and delete override in ctx."""
ctx["deleted_at_column"] = self.column_name
ctx[DELETE_TRIGGER_OVERRIDE_KEY] = DeleteTriggerOverride(
template_name="soft_delete.plpgsql.mako",
template_vars={"deleted_at_column": self.column_name},
)
# The trigger writes deleted_at directly, so it must never
# appear in the INSERT/UPDATE column lists the ops builder
# generates from the user's dimension columns.
ctx.setdefault("exclude_from_mutations", set()).add(self.column_name)
[docs]
@requires("primary", Dynamic("table_key"))
class SoftDeletePluginAfterRoot(Plugin):
"""Patch the view to filter out soft-deleted rows."""
def __init__(
self,
column_name: str = "deleted_at",
table_key: str = "raw_table",
) -> None:
"""Store the column name and target table key."""
self.column_name = column_name
self.table_key = table_key
[docs]
def run(self, ctx: FactoryContext) -> None:
"""Append ``WHERE deleted_at IS NULL`` to the registered view.
Skipped when the factory already filters soft-deleted rows
inside its view query (it sets ``soft_delete_view_filtered``).
A blind ``WHERE`` append cannot be used there -- e.g. EAV's
pivot view ends in ``GROUP BY`` and the clause would be
misplaced.
"""
if ctx.get("soft_delete_view_filtered"):
return
views_holder = ctx.metadata.info.get("views")
if views_holder is not None:
for view in views_holder.views:
if view.name == ctx.tablename and view.schema == ctx.schemaname:
view.definition = (
view.definition.rstrip()
+ f"\nWHERE {self.column_name} IS NULL"
)
break
[docs]
class SoftDeletePlugin(PluginCollection):
"""Soft delete plugin.
Args:
column_name: Name of the soft-delete timestamp column
(default ``"deleted_at"``).
table_key: Backing table the soft-delete column lives on:
``"raw_table"`` for Simple (default), ``"attributes"`` for
AppendOnly, ``"entity"`` for EAV.
"""
def __init__(
self,
column_name: str = "deleted_at",
table_key: str = "raw_table",
) -> None:
"""Store the column name and target table key."""
self.column_name = column_name
self.table_key = table_key
def __iter__(self) -> Iterator[Plugin]:
"""Yield the before-root and after-root plugins in order."""
return iter(
[
SoftDeletePluginBeforeRoot(column_name=self.column_name),
SoftDeletePluginAfterRoot(
column_name=self.column_name,
table_key=self.table_key,
),
]
)