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, ), ] )