initial commit

This commit is contained in:
2025-10-19 22:09:35 +03:00
commit 6d593b4554
114 changed files with 23622 additions and 0 deletions

View 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.