initial commit
This commit is contained in:
484
docs/user-guide/database/models.md
Normal file
484
docs/user-guide/database/models.md
Normal file
@ -0,0 +1,484 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user