Skip to content

Examples

The following example demonstrates how to implement the SQLAlchemy History in a project:

models.py

from types import SimpleNamespace
from uuid import UUID

from sqla_history import (
    BaseHistoryChanges,
    InsertStmtBuilder,
    WithUserChangeEventHandler,
)
from sqlalchemy import Connection, ForeignKey, MetaData, String, event
from sqlalchemy.orm import DeclarativeBase, Mapped, Mapper, mapped_column
from uuid_utils.compat import uuid7

meta = MetaData(
    naming_convention={
        "ix": "ix_%(column_0_label)s",
        "uq": "uq_%(table_name)s_%(column_0_name)s",
        "ck": "ck_%(table_name)s_%(constraint_name)s",
        "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
        "pk": "pk_%(table_name)s",
    },
)


class Base(DeclarativeBase):
    metadata = meta


class User(Base):
    __tablename__ = "user"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid7)


class HistoryChanges(BaseHistoryChanges, Base):
    __tablename__ = "history_changes"

    user_id: Mapped[UUID | None] = mapped_column(
        ForeignKey("user.id", ondelete="SET NULL")
    )
    entity_id: Mapped[UUID] = mapped_column(index=True)


class Note(Base):
    __tablename__ = "note"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid7)
    is_active: Mapped[bool] = mapped_column(default=True)
    name: Mapped[str] = mapped_column(String(255))
    description: Mapped[str] = mapped_column(String(512))


class EntityName(SimpleNamespace):
    note = "note"


@event.listens_for(Note, "after_update")
def create_note_history(
    mapper: Mapper,
    connection: Connection,
    target: Note,
) -> None:
    stmt_builder = InsertStmtBuilder(history_model=HistoryChanges)
    handler = WithUserChangeEventHandler(
        entity_name=EntityName.note,
        stmt_builder=stmt_builder,
    )
    handler(mapper=mapper, connection=connection, target=target)

main.py

import pprint
from collections.abc import Iterator
from contextlib import contextmanager

from sqla_history.context import CurrentUserId, EventId
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from uuid_utils.compat import uuid7

from . import models

engine = create_engine("sqlite:///:memory:")
session_factory = sessionmaker(bind=engine)

OLD_NAME = "Name"
OLD_DESCRIPTION = "Description"

NEW_NAME = "Changed Name"
NEW_DESCRIPTION = "NANIIIIIIIII"


@contextmanager
def create_db() -> Iterator[None]:
    models.Base.metadata.create_all(engine)
    yield
    models.Base.metadata.clear()


def main() -> None:
    with create_db(), session_factory.begin() as session:
        # Creation of User and Note
        user = models.User()
        note = models.Note(name=OLD_NAME, description=OLD_DESCRIPTION)
        session.add_all((user, note))
        session.flush()

        # The context variable must be set to `current_user_id``
        CurrentUserId.set(user.id)

        # The context variable must be set to `event_id`
        event_id = uuid7()
        EventId.set(event_id)

        # Applying changes
        note = _mutate_note(note)
        session.add(note)
        session.flush()

        # Searching HistoryChanges
        stmt = select(cls := models.HistoryChanges).where(cls.event_id == event_id)
        result = session.scalars(stmt).all()

        assert result

        for item in result:
            if item.field_name == models.Note.name.key:
                assert item.prev_value == OLD_NAME
                assert item.new_value == NEW_NAME

            elif item.field_name == models.Note.description.key:
                assert item.prev_value == OLD_DESCRIPTION
                assert item.new_value == NEW_DESCRIPTION

            pprint.pprint(
                {
                    "id": str(item.id),
                    "user_id": str(item.user_id),
                    "entity_id": str(item.entity_id),
                    "event_id": str(item.event_id),
                    "changed_at": item.changed_at.isoformat(),
                    "entity_name": item.entity_name,
                    "field_name": item.field_name,
                    "prev_value": item.prev_value,
                    "new_value": item.new_value,
                },
                sort_dicts=False,
                indent=2,
            )


def _mutate_note(note: models.Note) -> models.Note:
    note.name = NEW_NAME
    note.description = NEW_DESCRIPTION
    return note


if __name__ == "__main__":
    main()