408 lines
9.3 KiB
Markdown
408 lines
9.3 KiB
Markdown
# Database Patterns
|
|
|
|
SQLAlchemy and Alembic patterns for Python backends.
|
|
|
|
## Async Engine + Session
|
|
|
|
Use async engine + async session per request:
|
|
|
|
```python
|
|
from collections.abc import AsyncGenerator
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/app"
|
|
|
|
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
|
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
async with SessionLocal() as session:
|
|
yield session
|
|
```
|
|
|
|
---
|
|
|
|
## Commit/Rollback Pattern
|
|
|
|
Wrap write operations in try/except:
|
|
|
|
```python
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
async def create_user(session: AsyncSession, user: dict) -> User:
|
|
try:
|
|
db_user = User(**user)
|
|
session.add(db_user)
|
|
await session.commit()
|
|
await session.refresh(db_user)
|
|
return db_user
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
```
|
|
|
|
### Nested Transaction with Savepoint
|
|
|
|
```python
|
|
async def transfer_funds(session: AsyncSession, from_id: int, to_id: int, amount: float):
|
|
async with session.begin_nested(): # Creates a savepoint
|
|
from_account = await session.get(Account, from_id)
|
|
to_account = await session.get(Account, to_id)
|
|
|
|
if from_account.balance < amount:
|
|
raise InsufficientFunds()
|
|
|
|
from_account.balance -= amount
|
|
to_account.balance += amount
|
|
|
|
await session.commit()
|
|
```
|
|
|
|
---
|
|
|
|
## Naming Conventions
|
|
|
|
Be consistent with names:
|
|
1. lower_case_snake
|
|
2. singular form (e.g. post, post_like, user_playlist)
|
|
3. group similar tables with module prefix (e.g. payment_account, payment_bill)
|
|
4. stay consistent across tables
|
|
5. _at suffix for datetime, _date suffix for date
|
|
|
|
### Constraint Naming
|
|
|
|
```python
|
|
from sqlalchemy import MetaData
|
|
|
|
NAMING_CONVENTION = {
|
|
"ix": "%(column_0_label)s_idx",
|
|
"uq": "%(table_name)s_%(column_0_name)s_key",
|
|
"ck": "%(table_name)s_%(constraint_name)s_check",
|
|
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
|
"pk": "%(table_name)s_pkey",
|
|
}
|
|
|
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
```
|
|
|
|
---
|
|
|
|
## Alembic Migration Naming
|
|
|
|
Use human-readable file template:
|
|
|
|
```ini
|
|
# alembic.ini
|
|
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
|
# Results in: 2024-08-24_add_users_table.py
|
|
```
|
|
|
|
---
|
|
|
|
## Eager Loading - Avoid N+1
|
|
|
|
### selectinload for collections
|
|
|
|
```python
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
# Load all users with their orders in 2 queries
|
|
stmt = select(User).options(selectinload(User.orders))
|
|
users = await session.scalars(stmt)
|
|
|
|
for user in users:
|
|
print(user.orders) # No additional query
|
|
```
|
|
|
|
### joinedload for single relationships
|
|
|
|
```python
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
# Load orders with their user in 1 query (JOIN)
|
|
stmt = select(Order).options(joinedload(Order.user))
|
|
orders = await session.scalars(stmt)
|
|
```
|
|
|
|
### raiseload to detect N+1
|
|
|
|
```python
|
|
from sqlalchemy.orm import raiseload
|
|
|
|
stmt = select(User).options(
|
|
selectinload(User.orders),
|
|
raiseload("*") # Raise on any other lazy load
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Cascade Delete
|
|
|
|
Configure at both ORM and database level:
|
|
|
|
```python
|
|
from sqlalchemy import ForeignKey, Integer, String
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
|
|
class Parent(Base):
|
|
__tablename__ = "parent"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
children: Mapped[list["Child"]] = relationship(
|
|
back_populates="parent",
|
|
cascade="all, delete",
|
|
passive_deletes=True,
|
|
)
|
|
|
|
class Child(Base):
|
|
__tablename__ = "child"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
parent_id: Mapped[int] = mapped_column(
|
|
ForeignKey("parent.id", ondelete="CASCADE")
|
|
)
|
|
|
|
parent: Mapped["Parent"] = relationship(back_populates="children")
|
|
```
|
|
|
|
---
|
|
|
|
## Soft Delete Pattern
|
|
|
|
Use a deleted_at timestamp instead of hard deletes:
|
|
|
|
```python
|
|
from datetime import datetime
|
|
from sqlalchemy import DateTime
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from sqlalchemy.ext.hybrid import hybrid_property
|
|
|
|
class SoftDeleteMixin:
|
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=None,
|
|
index=True
|
|
)
|
|
|
|
@hybrid_property
|
|
def is_deleted(self) -> bool:
|
|
return self.deleted_at is not None
|
|
|
|
def soft_delete(self) -> None:
|
|
self.deleted_at = datetime.utcnow()
|
|
|
|
def restore(self) -> None:
|
|
self.deleted_at = None
|
|
```
|
|
|
|
Filter soft-deleted:
|
|
|
|
```python
|
|
# Only get non-deleted users
|
|
stmt = select(User).where(User.deleted_at.is_(None))
|
|
|
|
# Only deleted
|
|
stmt_deleted = select(User).where(User.deleted_at.isnot(None))
|
|
```
|
|
|
|
---
|
|
|
|
## Optimistic Locking
|
|
|
|
Use version_id_col to prevent lost updates:
|
|
|
|
```python
|
|
class Article(Base):
|
|
__tablename__ = "article"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
title: Mapped[str] = mapped_column(String(200))
|
|
version_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
|
|
__mapper_args__ = {
|
|
"version_id_col": version_id
|
|
}
|
|
|
|
# Handle StaleDataError
|
|
from sqlalchemy.orm.exc import StaleDataError
|
|
|
|
async def update_article(session: AsyncSession, article_id: int, data: dict):
|
|
try:
|
|
article = await session.get(Article, article_id)
|
|
for key, value in data.items():
|
|
setattr(article, key, value)
|
|
await session.commit()
|
|
except StaleDataError:
|
|
await session.rollback()
|
|
raise ConcurrentModificationError("Article was modified by another user.")
|
|
```
|
|
|
|
---
|
|
|
|
## Timestamp Mixin
|
|
|
|
Automatic created_at and updated_at:
|
|
|
|
```python
|
|
from datetime import datetime
|
|
from sqlalchemy import DateTime, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
class TimestampMixin:
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Bulk Operations
|
|
|
|
### Bulk Insert
|
|
|
|
```python
|
|
from sqlalchemy import insert
|
|
|
|
async def bulk_create_users(session: AsyncSession, users: list[dict]):
|
|
await session.execute(
|
|
insert(User),
|
|
[
|
|
{"name": "Alice", "email": "alice@example.com"},
|
|
{"name": "Bob", "email": "bob@example.com"},
|
|
],
|
|
)
|
|
await session.commit()
|
|
```
|
|
|
|
### Bulk Update
|
|
|
|
```python
|
|
from sqlalchemy import update
|
|
|
|
async def deactivate_old_users(session: AsyncSession, days: int = 365):
|
|
from datetime import datetime, timedelta
|
|
|
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
|
|
|
result = await session.execute(
|
|
update(User)
|
|
.where(User.last_login < cutoff)
|
|
.values(is_active=False)
|
|
)
|
|
await session.commit()
|
|
|
|
return result.rowcount
|
|
```
|
|
|
|
---
|
|
|
|
## Upsert (PostgreSQL)
|
|
|
|
```python
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
|
|
async def upsert_user(session: AsyncSession, user_data: dict):
|
|
stmt = pg_insert(User).values(**user_data)
|
|
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=[User.email],
|
|
set_={
|
|
"name": stmt.excluded.name,
|
|
"updated_at": func.now(),
|
|
}
|
|
)
|
|
|
|
await session.execute(stmt)
|
|
await session.commit()
|
|
```
|
|
|
|
---
|
|
|
|
## UUID Primary Keys
|
|
|
|
```python
|
|
import uuid
|
|
from sqlalchemy import Uuid
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
class User(Base):
|
|
__tablename__ = "user"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid,
|
|
primary_key=True,
|
|
default=uuid.uuid4
|
|
)
|
|
```
|
|
|
|
### Dual ID Pattern (internal + public)
|
|
|
|
```python
|
|
class User(Base):
|
|
__tablename__ = "user"
|
|
|
|
# Internal ID for joins (faster)
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
# Public ID for API (safe to expose)
|
|
public_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid,
|
|
unique=True,
|
|
default=uuid.uuid4,
|
|
index=True
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Repository Pattern
|
|
|
|
```python
|
|
from typing import Generic, TypeVar, Type
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
ModelType = TypeVar("ModelType", bound=Base)
|
|
|
|
class BaseRepository(Generic[ModelType]):
|
|
def __init__(self, session: AsyncSession, model: Type[ModelType]):
|
|
self.session = session
|
|
self.model = model
|
|
|
|
async def get(self, id: int) -> ModelType | None:
|
|
return await self.session.get(self.model, id)
|
|
|
|
async def get_all(self, skip: int = 0, limit: int = 100) -> list[ModelType]:
|
|
result = await self.session.scalars(
|
|
select(self.model).offset(skip).limit(limit)
|
|
)
|
|
return result.all()
|
|
|
|
async def create(self, obj: ModelType) -> ModelType:
|
|
self.session.add(obj)
|
|
await self.session.commit()
|
|
await self.session.refresh(obj)
|
|
return obj
|
|
|
|
class UserRepository(BaseRepository[User]):
|
|
def __init__(self, session: AsyncSession):
|
|
super().__init__(session, User)
|
|
|
|
async def get_by_email(self, email: str) -> User | None:
|
|
result = await self.session.scalar(
|
|
select(User).where(User.email == email)
|
|
)
|
|
return result
|
|
```
|