# Database Models This section explains how SQLAlchemy models are implemented in the boilerplate, how to create new models, and the patterns used for relationships, validation, and data integrity. ## Model Structure Models are defined in `src/app/models/` using SQLAlchemy 2.0's declarative syntax with `Mapped` type annotations. ### Base Model All models inherit from `Base` defined in `src/app/core/db/database.py`: ```python from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass ``` **SQLAlchemy 2.0 Change**: Uses `DeclarativeBase` instead of the older `declarative_base()` function. This provides better type checking and IDE support. ### Model File Structure Each model is in its own file: ```text src/app/models/ ├── __init__.py # Imports all models for Alembic discovery ├── user.py # User authentication model ├── post.py # Example content model with relationships ├── tier.py # User subscription tiers └── rate_limit.py # API rate limiting configuration ``` **Import Requirement**: Models must be imported in `__init__.py` for Alembic to detect them during migration generation. ## Design Decision: No SQLAlchemy Relationships The boilerplate deliberately avoids using SQLAlchemy's `relationship()` feature. This is an intentional architectural choice with specific benefits. ### Why No Relationships **Performance Concerns**: - **N+1 Query Problem**: Relationships can trigger multiple queries when accessing related data - **Lazy Loading**: Unpredictable when queries execute, making performance optimization difficult - **Memory Usage**: Loading large object graphs consumes significant memory **Code Clarity**: - **Explicit Data Fetching**: Developers see exactly what data is being loaded and when - **Predictable Queries**: No "magic" queries triggered by attribute access - **Easier Debugging**: SQL queries are explicit in the code, not hidden in relationship configuration **Flexibility**: - **Query Optimization**: Can optimize each query for its specific use case - **Selective Loading**: Load only the fields needed for each operation - **Join Control**: Use FastCRUD's join methods when needed, skip when not ### What This Means in Practice Instead of this (traditional SQLAlchemy): ```python # Not used in the boilerplate class User(Base): posts: Mapped[List["Post"]] = relationship("Post", back_populates="created_by_user") class Post(Base): created_by_user: Mapped["User"] = relationship("User", back_populates="posts") ``` The boilerplate uses this approach: ```python # DO - Explicit and controlled class User(Base): # Only foreign key, no relationship tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), index=True, default=None) class Post(Base): # Only foreign key, no relationship created_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) # Explicit queries - you control exactly what's loaded user = await crud_users.get(db=db, id=1) posts = await crud_posts.get_multi(db=db, created_by_user_id=user.id) # Or use joins when needed posts_with_users = await crud_posts.get_multi_joined( db=db, join_model=User, schema_to_select=PostRead, join_schema_to_select=UserRead ) ``` ### Benefits of This Approach **Predictable Performance**: - Every database query is explicit in the code - No surprise queries from accessing relationships - Easier to identify and optimize slow operations **Better Caching**: - Can cache individual models without worrying about related data - Cache invalidation is simpler and more predictable **API Design**: - Forces thinking about what data clients actually need - Prevents over-fetching in API responses - Encourages lean, focused endpoints **Testing**: - Easier to mock database operations - No complex relationship setup in test fixtures - More predictable test data requirements ### When You Need Related Data Use FastCRUD's join capabilities: ```python # Single record with related data post_with_author = await crud_posts.get_joined( db=db, join_model=User, schema_to_select=PostRead, join_schema_to_select=UserRead, id=post_id ) # Multiple records with joins posts_with_authors = await crud_posts.get_multi_joined( db=db, join_model=User, offset=0, limit=10 ) ``` ### Alternative Approaches If you need relationships in your project, you can add them: ```python # Add relationships if needed for your use case from sqlalchemy.orm import relationship class User(Base): # ... existing fields ... posts: Mapped[List["Post"]] = relationship("Post", back_populates="created_by_user") class Post(Base): # ... existing fields ... created_by_user: Mapped["User"] = relationship("User", back_populates="posts") ``` But consider the trade-offs and whether explicit queries might be better for your use case. ## User Model Implementation The User model (`src/app/models/user.py`) demonstrates authentication patterns: ```python import uuid as uuid_pkg from datetime import UTC, datetime from sqlalchemy import DateTime, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column from ..core.db.database import Base class User(Base): __tablename__ = "user" id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) # User data name: Mapped[str] = mapped_column(String(30)) username: Mapped[str] = mapped_column(String(20), unique=True, index=True) email: Mapped[str] = mapped_column(String(50), unique=True, index=True) hashed_password: Mapped[str] = mapped_column(String) # Profile profile_image_url: Mapped[str] = mapped_column(String, default="https://profileimageurl.com") # UUID for external references uuid: Mapped[uuid_pkg.UUID] = mapped_column(default_factory=uuid_pkg.uuid4, primary_key=True, unique=True) # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) # Status flags is_deleted: Mapped[bool] = mapped_column(default=False, index=True) is_superuser: Mapped[bool] = mapped_column(default=False) # Foreign key to tier system (no relationship defined) tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), index=True, default=None, init=False) ``` ### Key Implementation Details **Type Annotations**: `Mapped[type]` provides type hints for SQLAlchemy 2.0. IDE and mypy can validate types. **String Lengths**: Explicit lengths (`String(50)`) prevent database errors and define constraints clearly. **Nullable Fields**: Explicitly set `nullable=False` for required fields, `nullable=True` for optional ones. **Default Values**: Use `default=` for database-level defaults, Python functions for computed defaults. ## Post Model with Relationships The Post model (`src/app/models/post.py`) shows relationships and soft deletion: ```python import uuid as uuid_pkg from datetime import UTC, datetime from sqlalchemy import DateTime, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column from ..core.db.database import Base class Post(Base): __tablename__ = "post" id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) # Content title: Mapped[str] = mapped_column(String(30)) text: Mapped[str] = mapped_column(String(63206)) # Large text field media_url: Mapped[str | None] = mapped_column(String, default=None) # UUID for external references uuid: Mapped[uuid_pkg.UUID] = mapped_column(default_factory=uuid_pkg.uuid4, primary_key=True, unique=True) # Foreign key (no relationship defined) created_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) # Timestamps (built-in soft delete pattern) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) is_deleted: Mapped[bool] = mapped_column(default=False, index=True) ``` ### Soft Deletion Pattern Soft deletion is built directly into models: ```python # Built into each model that needs soft deletes class Post(Base): # ... other fields ... # Soft delete fields is_deleted: Mapped[bool] = mapped_column(default=False, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) ``` **Usage**: When `crud_posts.delete()` is called, it sets `is_deleted=True` and `deleted_at=datetime.now(UTC)` instead of removing the database row. ## Tier and Rate Limiting Models ### Tier Model ```python # src/app/models/tier.py class Tier(Base): __tablename__ = "tier" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) ``` ### Rate Limit Model ```python # src/app/models/rate_limit.py class RateLimit(Base): __tablename__ = "rate_limit" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) tier_id: Mapped[int] = mapped_column(ForeignKey("tier.id"), nullable=False) path: Mapped[str] = mapped_column(String(255), nullable=False) limit: Mapped[int] = mapped_column(nullable=False) # requests allowed period: Mapped[int] = mapped_column(nullable=False) # time period in seconds name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) ``` **Purpose**: Links API endpoints (`path`) to rate limits (`limit` requests per `period` seconds) for specific user tiers. ## Creating New Models ### Step-by-Step Process 1. **Create model file** in `src/app/models/your_model.py` 2. **Define model class** inheriting from `Base` 3. **Add to imports** in `src/app/models/__init__.py` 4. **Generate migration** with `alembic revision --autogenerate` 5. **Apply migration** with `alembic upgrade head` ### Example: Creating a Category Model ```python # src/app/models/category.py from datetime import datetime from typing import List from sqlalchemy import String, DateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.db.database import Base class Category(Base): __tablename__ = "category" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) description: Mapped[str] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) ``` If you want to relate Category to Post, just add the id reference in the model: ```python class Post(Base): __tablename__ = "post" ... # Foreign key (no relationship defined) category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), index=True) ``` ### Import in __init__.py ```python # src/app/models/__init__.py from .user import User from .post import Post from .tier import Tier from .rate_limit import RateLimit from .category import Category # Add new model ``` **Critical**: Without this import, Alembic won't detect the model for migrations. ## Model Validation and Constraints ### Database-Level Constraints ```python from sqlalchemy import CheckConstraint, Index class Product(Base): __tablename__ = "product" price: Mapped[float] = mapped_column(nullable=False) quantity: Mapped[int] = mapped_column(nullable=False) # Table-level constraints __table_args__ = ( CheckConstraint('price > 0', name='positive_price'), CheckConstraint('quantity >= 0', name='non_negative_quantity'), Index('idx_product_price', 'price'), ) ``` ### Unique Constraints ```python # Single column unique email: Mapped[str] = mapped_column(String(100), unique=True) # Multi-column unique constraint __table_args__ = ( UniqueConstraint('user_id', 'category_id', name='unique_user_category'), ) ``` ## Common Model Patterns ### Timestamp Tracking ```python class TimestampedModel: created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False ) # Use as mixin class Post(Base, TimestampedModel, SoftDeleteMixin): # Model automatically gets created_at, updated_at, is_deleted, deleted_at __tablename__ = "post" id: Mapped[int] = mapped_column(primary_key=True) ``` ### Enumeration Fields ```python from enum import Enum from sqlalchemy import Enum as SQLEnum class UserStatus(Enum): ACTIVE = "active" INACTIVE = "inactive" SUSPENDED = "suspended" class User(Base): status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.ACTIVE) ``` ### JSON Fields ```python from sqlalchemy.dialects.postgresql import JSONB class UserProfile(Base): preferences: Mapped[dict] = mapped_column(JSONB, nullable=True) metadata: Mapped[dict] = mapped_column(JSONB, default=lambda: {}) ``` **PostgreSQL-specific**: Uses JSONB for efficient JSON storage and querying. ## Model Testing ### Basic Model Tests ```python # tests/test_models.py import pytest from sqlalchemy.exc import IntegrityError from app.models.user import User def test_user_creation(): user = User( username="testuser", email="test@example.com", hashed_password="hashed123" ) assert user.username == "testuser" assert user.is_active is True # Default value def test_user_unique_constraint(): # Test that duplicate emails raise IntegrityError with pytest.raises(IntegrityError): # Create users with same email pass ``` ## Migration Considerations ### Backwards Compatible Changes Safe changes that don't break existing code: - Adding nullable columns - Adding new tables - Adding indexes - Increasing column lengths ### Breaking Changes Changes requiring careful migration: - Making columns non-nullable - Removing columns - Changing column types - Removing tables ## Next Steps Now that you understand model implementation: 1. **[Schemas](schemas.md)** - Learn Pydantic validation and serialization 2. **[CRUD Operations](crud.md)** - Implement database operations with FastCRUD 3. **[Migrations](migrations.md)** - Manage schema changes with Alembic The next section covers how Pydantic schemas provide validation and API contracts separate from database models.