Examples

This section provides practical examples of how to use activemodel in your projects, demonstrating common patterns for structured data, lifecycle management, testing, and complex schema definitions.

Advanced Field Types and Configuration

activemodel leverages SQLModel and SQLAlchemy to support complex column types, constraints, and schema configurations.

Custom SQLAlchemy Types and Datetimes

You can map specific fields to underlying SQLAlchemy types using sa_type. This is especially useful for timezone-aware datetimes and advanced PostgreSQL types.

import sqlalchemy as sa
from typing import Tuple
from datetime import datetime
from sqlmodel import Field
from activemodel import BaseModel
from sqlalchemy_postgres_point import PointType

class LocationEvent(BaseModel, table=True):
    # Timezone-aware datetimes using sa_type
    booked_datetime: datetime = Field(
        sa_type=sa.DateTime(timezone=True)  # type: ignore
    )
    
    # Custom PostgreSQL Types (e.g. Point)
    location: Tuple[float, float] | None = Field(
        default=None, 
        sa_type=PointType
    )

Defaults, Exclusions, and Constraints

Control schema visibility and enforce constraints directly within the Field definition.

from sqlmodel import Field
from activemodel import BaseModel

class Product(BaseModel, table=True):
    # Require values greater than zero
    price_cents: int = Field(gt=0)
    
    # Exclude internal fields from Pydantic schemas (e.g. FastAPI responses)
    stripe_account_id: str | None = Field(default=None, exclude=True)
    
    # Define max lengths and index the column
    status: str = Field(default="active", max_length=100, index=True)

Advanced TypeID Usage

While TypeIDMixin handles the primary key, you can use TypeIDType and the .foreign_key() helper to manage related IDs securely, and even enforce prefixes.

from activemodel import BaseModel
from activemodel.mixins import TypeIDMixin
from activemodel.types import TypeIDType
from sqlmodel import Field, Relationship

class Distribution(BaseModel, TypeIDMixin("dst"), table=True):
    name: str

class Screening(BaseModel, TypeIDMixin("scr"), table=True):
    # Standard Foreign Key relationship definition helper
    distribution_id: TypeIDType = Distribution.foreign_key(index=True)
    distribution: Distribution = Relationship()

    # Enforce a specific TypeID prefix on a field
    merged_into_screening_id: TypeIDType | None = Field(
        default=None, 
        sa_type=TypeIDType(prefix="scr")  # type: ignore
    )

Structured JSON with Pydantic Models

Using the PydanticJSONMixin, you can store complex Pydantic models or lists of models in PostgreSQL JSONB columns. The mixin handles serialization and deserialization automatically.

Single Model Column

from pydantic import BaseModel as PydanticBaseModel
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin

class UserActionAuditData(PydanticBaseModel):
    ip_address: str
    build_version: str
    timestamp: str

class RefundChoice(
    BaseModel,
    TimestampsMixin,
    PydanticJSONMixin,
    TypeIDMixin("rc"),
    table=True,
):
    chosen_info: UserActionAuditData | None = Field(default=None, sa_type=JSONB)
    "audit data about the user's choice stored as structured JSON"

List of Models Column

You can also store a list of Pydantic models. This is useful for things like transcripts, audit logs, or line items.

from pydantic import BaseModel as PydanticBaseModel
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin

class TranscriptEntry(PydanticBaseModel):
    speaker: str
    content: str
    startTime: int  # in milliseconds
    endTime: int    # in milliseconds

class AIVisitTranscript(
    BaseModel,
    PydanticJSONMixin,
    TypeIDMixin("ai_vt"),
    table=True,
):
    # The mixin will convert this list of dicts to a list of TranscriptEntry objects
    transcript_data: list[TranscriptEntry] = Field(sa_type=JSONB)

Lifecycle Hooks

activemodel supports several lifecycle hooks that allow you to execute logic at specific points in a model’s lifecycle.

Basic Validation and Defaults

from datetime import datetime, timedelta
from activemodel import BaseModel
from activemodel.mixins import TypeIDMixin, TimestampsMixin
from sqlmodel import Field

class Screening(BaseModel, TimestampsMixin, TypeIDMixin("scr"), table=True):
    funding_goal: int = Field(default=0)
    funding_ending_at: datetime | None = Field(default=None)

    def before_create(self):
        """Set a default ending date only on creation."""
        if self.funding_ending_at is None:
            self.funding_ending_at = datetime.now() + timedelta(days=30)

    def before_save(self):
        """Prevent saving invalid data."""
        if self.funding_goal < 0:
            raise ValueError("Funding goal must be non-negative.")

Advanced Hooks: Immutable Fields

You can use before_save to enforce immutability or perform complex validations.

import hashlib
from activemodel import BaseModel
from activemodel.mixins import TypeIDMixin
from sqlmodel import Field

def hash_prompt(prompt: str) -> str:
    return hashlib.sha256(prompt.encode()).hexdigest()

class LLMResponse(BaseModel, TypeIDMixin("llr"), table=True):
    prompt: str
    prompt_hash: str | None = Field(default=None, nullable=False)

    def before_save(self):
        new_hash = hash_prompt(self.prompt)
        
        # Enforce that the prompt cannot be changed once saved
        if self.prompt_hash and self.prompt_hash != new_hash:
            raise ValueError("Prompts should never be modified once they are cached")

        self.prompt_hash = new_hash

Factories with Polyfactory

activemodel integrates with polyfactory via ActiveModelFactory. This is powerful for generating complex, related test data automatically.

Complex Data Generation and Datetimes

Factories can use BaseFactory.__faker__ for generating rich mock data and dynamic values using Use. We also recommend utilizing robust datetime libraries like whenever for generating timestamps.

from polyfactory import BaseFactory, Use
from whenever import Instant
from activemodel.pytest.factories import ActiveModelFactory
from app.models.user import User

class UserFactory(ActiveModelFactory[User]):
    # Static defaults
    status = "active"

    # Using Faker for random structured data
    name = BaseFactory.__faker__.name
    email = BaseFactory.__faker__.email
    
    # Using Use() to execute a function dynamically for each build
    # Combining with `whenever` for timezone-aware datetimes
    last_active_at = Use(
        lambda: Instant.now().to_system_tz().add(days=-5).py_datetime()
    )

Recursive Factory Dependencies

You can use post_build to automatically create and associate related models if they aren’t provided.

from activemodel.pytest.factories import ActiveModelFactory
from app.models.order import TicketReservationOrder

class TicketReservationOrderFactory(ActiveModelFactory[TicketReservationOrder]):
    """
    Advanced factory that automatically handles recursive dependencies.
    """
    ticket_count = 1

    @classmethod
    def post_build(cls, model):
        """
        Automatically ensure the order has a Distribution and a Screening.
        """
        if not model.distribution_id:
            # Recursively use another factory
            from .distribution import DistributionFactory
            model.distribution = DistributionFactory.save()
            model.distribution_id = model.distribution.id

        if not model.screening_id:
            # Create a screening associated with the same distribution
            from .screening import ScreeningFactory
            model.screening = ScreeningFactory.save(
                distribution_id=model.distribution_id
            )
            model.screening_id = model.screening.id

        # Always save the model at the end of post_build in an ActiveModelFactory
        return model.save()

Complex Post-Save Logic

Use post_save for actions that require the record to already have an ID or for complex side effects.

class FullyFundedScreeningFactory(ActiveModelFactory[Screening]):
    @classmethod
    def post_save(cls, model):
        """
        After creating a screening, automatically create enough 
        paid orders to reach the funding goal.
        """
        from .order import TicketReservationOrderFactory
        
        ticket_count = 10 
        for _ in range(ticket_count):
            TicketReservationOrderFactory.save(
                screening_id=model.id,
                status="paid"
            )
        
        # Refresh to ensure any computed fields/relationships are updated
        return model.refresh()

Standard Model Example

A typical model combining common mixins:

from activemodel import BaseModel
from activemodel.mixins import (
    PydanticJSONMixin,
    SoftDeletionMixin,
    TimestampsMixin,
    TypeIDMixin,
)
from activemodel.types import TypeIDType
from sqlmodel import Field, Relationship

class Partner(
    BaseModel,
    TimestampsMixin,
    SoftDeletionMixin,
    TypeIDMixin("prt"),
    table=True,
):
    """
    Comprehensive model example with TypeID, Timestamps, and Soft Delete.
    """
    name: str = Field(nullable=False)
    slug: str = Field(nullable=False, unique=True)
    
    # Using foreign_key() helper for clean relationship definitions
    doctor_id: TypeIDType = Doctor.foreign_key()
    doctor: Doctor = Relationship()