Skip to content
You're not viewing the latest version. Click here to go to latest.

# Models

Added in `v2.0`

Reusable SQLAlchemy 2.0 mixins for common column patterns, designed to be composed freely on any `DeclarativeBase` model.

## Overview

The `models` module provides mixins that each add a single, well-defined column behaviour. They work with standard SQLAlchemy 2.0 declarative syntax and are fully compatible with `AsyncSession`.

    from fastapi_toolsets.models import UUIDMixin, TimestampMixin

    class Article(Base, UUIDMixin, TimestampMixin):
        __tablename__ = "articles"

        title: Mapped[str]
        content: Mapped[str]

All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are server-side (`clock_timestamp()`), so they are also applied when inserting rows via raw SQL outside the ORM.

## Mixins

### `UUIDMixin`

Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()`. The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`.

Requires PostgreSQL 13+

    from fastapi_toolsets.models import UUIDMixin

    class User(Base, UUIDMixin):
        __tablename__ = "users"

        username: Mapped[str]

    # id is None before flush
    user = User(username="alice")
    session.add(user)
    await session.flush()
    print(user.id)  # UUID('...')

### `UUIDv7Mixin`

Added in `v2.3`

Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7()`. It's a time-ordered UUID format that encodes a millisecond-precision timestamp in the most significant bits, making it naturally sortable and index-friendly.

Requires PostgreSQL 18+

    from fastapi_toolsets.models import UUIDv7Mixin

    class Event(Base, UUIDv7Mixin):
        __tablename__ = "events"

        name: Mapped[str]

    # id is None before flush
    event = Event(name="user.signup")
    session.add(event)
    await session.flush()
    print(event.id)  # UUID('019...')

### `CreatedAtMixin`

Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created.

    from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin

    class Order(Base, UUIDMixin, CreatedAtMixin):
        __tablename__ = "orders"

        total: Mapped[float]

### `UpdatedAtMixin`

Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and automatically updated to `clock_timestamp()` on every ORM-level update (via SQLAlchemy's `onupdate` hook).

    from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin

    class Post(Base, UUIDMixin, UpdatedAtMixin):
        __tablename__ = "posts"

        title: Mapped[str]

    post = Post(title="Hello")
    await session.flush()
    await session.refresh(post)

    post.title = "Hello World"
    await session.flush()
    await session.refresh(post)
    print(post.updated_at)

Note

`updated_at` is updated by SQLAlchemy at ORM flush time. If you update rows via raw SQL (e.g. `UPDATE posts SET ...`), the column will not be updated automatically — use a database trigger if you need that guarantee.

### `TimestampMixin`

Convenience mixin that combines `CreatedAtMixin` and `UpdatedAtMixin`. Equivalent to inheriting both.

    from fastapi_toolsets.models import UUIDMixin, TimestampMixin

    class Article(Base, UUIDMixin, TimestampMixin):
        __tablename__ = "articles"

        title: Mapped[str]

### `WatchedFieldsMixin`

Added in `v2.4`

`WatchedFieldsMixin` provides lifecycle callbacks that fire after commit — meaning the row is durably persisted when your callback runs. If the transaction rolls back, no callback fires.

Three callbacks are available, each corresponding to a `ModelEvent` value:

Callback | Event | Trigger
--- | --- | ---
`on_create()` | `ModelEvent.CREATE` | After `INSERT`
`on_delete()` | `ModelEvent.DELETE` | After `DELETE`
`on_update(changes)` | `ModelEvent.UPDATE` | After `UPDATE` on a watched field

Server-side defaults (e.g. `id`, `created_at`) are fully populated in all callbacks. All callbacks support both `async def` and plain `def`. Use `@watch` to restrict which fields trigger `on_update`:

Decorator | `on_update` behaviour
--- | ---
`@watch("status", "role")` | Only fires when `status` or `role` changes
(no decorator) | Fires when any mapped field changes

`@watch` is inherited through the class hierarchy. If a subclass does not declare its own `@watch`, it uses the filter from the nearest decorated parent. Applying `@watch` on the subclass overrides the parent's filter:

    @watch("status")
    class Order(Base, UUIDMixin, WatchedFieldsMixin):
        ...

    class UrgentOrder(Order):
        # inherits @watch("status") — on_update fires only for status changes
        ...

    @watch("priority")
    class PriorityOrder(Order):
        # overrides parent — on_update fires only for priority changes
        ...

#### Option 1 — catch-all with `on_event`

Override `on_event` to handle all event types in one place. The specific methods delegate here by default:

    from fastapi_toolsets.models import ModelEvent, UUIDMixin, WatchedFieldsMixin, watch

    @watch("status")
    class Order(Base, UUIDMixin, WatchedFieldsMixin):
        __tablename__ = "orders"

        status: Mapped[str]

        async def on_event(self, event: ModelEvent, changes: dict | None = None) -> None:
            if event == ModelEvent.CREATE:
                await notify_new_order(self.id)
            elif event == ModelEvent.DELETE:
                await notify_order_cancelled(self.id)
            elif event == ModelEvent.UPDATE:
                await notify_status_change(self.id, changes["status"])

#### Option 2 — targeted overrides

Override individual methods for more focused logic:

    @watch("status")
    class Order(Base, UUIDMixin, WatchedFieldsMixin):
        __tablename__ = "orders"

        status: Mapped[str]

        async def on_create(self) -> None:
            await notify_new_order(self.id)

        async def on_delete(self) -> None:
            await notify_order_cancelled(self.id)

        async def on_update(self, changes: dict) -> None:
            if "status" in changes:
                old = changes["status"]["old"]
                new = changes["status"]["new"]
                await notify_status_change(self.id, old, new)

#### Field changes format

The `changes` dict maps each watched field that changed to `{"old": ..., "new": ...}`. Only fields that actually changed are included:

    # status changed   → {"status": {"old": "pending", "new": "shipped"}}
    # two fields changed → {"status": {...}, "assigned_to": {...}}

Multiple flushes in one transaction are merged: the earliest `old` and latest `new` are preserved, and `on_update` fires only once per commit.

Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected.

Callbacks fire when the outermost active context (savepoint or transaction) commits.

If you create several related objects using `CrudFactory.create` and need callbacks to see all of them (including associations), wrap the whole operation in a single `get_transaction` or `lock_tables` block. Without it, each `create` call commits its own savepoint and `on_create` fires before the remaining objects exist.

    from fastapi_toolsets.db import get_transaction

    async with get_transaction(session):
        order = await OrderCrud.create(session, order_data)
        item  = await ItemCrud.create(session, item_data)
        await session.refresh(order, attribute_names=["items"])
        order.items.append(item)
    # on_create fires here for both order and item,
    # with the full association already committed.

## Composing mixins

All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.

    from fastapi_toolsets.models import UUIDMixin, TimestampMixin

    class Event(Base, UUIDMixin, TimestampMixin):
        __tablename__ = "events"
        name: Mapped[str]

    class Counter(Base, UpdatedAtMixin):
        __tablename__ = "counters"
        id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
        value: Mapped[int]

API Reference