add brain
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user