Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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