Skip to content

feat: add TimestampMixin (created_at/updated_at only) with declared_attr and server_default #10

@fsecada01

Description

@fsecada01

Summary

Two related improvements surfaced while integrating sqlmodel-crud-utilities into a FastAPI/SQLModel microservice. Both concern mixin design for table=True models.


Issue 1 — AuditMixin uses Field(sa_column=Column(...)), which breaks when applied to more than one model

Problem

When a mixin defines a field as Field(sa_column=Column(...)), the same Column object is shared across every model class that inherits the mixin. SQLAlchemy raises on the second table:

sqlalchemy.exc.ArgumentError: Column object 'created_at' already assigned to Table 'location'

Root cause

Column(...) is evaluated once at class-body time and stored as the FieldInfo.sa_column value. SQLModel passes that exact object to every Table that inherits the mixin — but SQLAlchemy requires each Table to own its own Column instance.

Fix

Use declared_attr instead. SQLAlchemy calls the decorated method once per concrete model class, producing a fresh Column for each table:

from sqlalchemy import Column, DateTime, func
from sqlalchemy.orm import declared_attr

class TimestampMixin:
    @declared_attr
    def created_at(cls):
        return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

    @declared_attr
    def updated_at(cls):
        return Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

Note: Do not add return-type annotations (-> Column) to the declared_attr methods — SQLAlchemy's Annotated Declarative scanner picks them up as bare field annotations and raises MappedAnnotationError (requires Mapped[]).


Issue 2 — AuditMixin uses default_factory (Python-side); bulk-upsert paths need a DB-side server_default

Problem

The current AuditMixin sets:

created_at: datetime = Field(default_factory=_utc_now, ...)

default_factory is evaluated by SQLModel/Pydantic when constructing a model instance in Python. Operations that bypass the ORM session — such as bulk_upsert_mappingsdo not trigger Python-side defaults. For services that write rows in bulk, created_at will be NULL unless the caller explicitly sets it.

Fix

Use server_default=func.now() (or sa.text("now()")) so PostgreSQL sets the value at the database level, regardless of how the row was inserted:

@declared_attr
def created_at(cls):
    return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

Feature request — add a lightweight TimestampMixin

AuditMixin is the right tool when user tracking (created_by / updated_by) is needed. But many services have no auth context to populate those fields. A standalone TimestampMixin with only the two timestamp columns would cover the common case without schema noise:

# sqlmodel_crud_utils/mixins.py

from sqlalchemy import Column, DateTime, func
from sqlalchemy.orm import declared_attr


class TimestampMixin:
    """Adds created_at / updated_at audit columns to any table=True model.

    Uses declared_attr so each concrete model class receives its own
    fresh Column instance — safe to apply to multiple models.

    Uses server_default=func.now() so timestamps are set by the database
    even when rows are inserted via bulk operations that bypass the ORM.
    """

    @declared_attr
    def created_at(cls):
        return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

    @declared_attr
    def updated_at(cls):
        return Column(
            DateTime(timezone=True),
            server_default=func.now(),
            onupdate=func.now(),
            nullable=False,
        )

Usage:

class MyModel(TimestampMixin, SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

Environment

  • sqlmodel-crud-utilities v0.1.0 / v0.2.0
  • SQLModel 0.0.21+
  • SQLAlchemy 2.x
  • Python 3.13

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions