FastAPI + PostgreSQL: পূর্ণ CRUD
Async SQLAlchemy দিয়ে আসল API
আগের অধ্যায়ে structure বানিয়েছি। এবার সেই structure এ আসল PostgreSQL database বসিয়ে Notes API কে সম্পূর্ণ করবো: model, migration, schema, repository, service আর router, শুরু থেকে শেষ পর্যন্ত একটা feature। এরপর যেকোনো নতুন feature (category, tag, share) এই একই ছাঁচে বানাতে পারবে।
#ধাপ ১: Database চালু আর setting
docker run -d --name notes-db -e POSTGRES_USER=app -e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=notes -p 5432:5432 postgres:17
uv add "fastapi[standard]" "sqlalchemy[asyncio]" asyncpg alembic pydantic-settings# .env
DATABASE_URL=postgresql+asyncpg://app:secret@localhost:5432/notes
JWT_SECRET=dev-only-change-me#ধাপ ২: Model
# models/base.py
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())# models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100))
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(default=True)
notes: Mapped[list["Note"]] = relationship(back_populates="owner")# models/note.py
from datetime import datetime
from sqlalchemy import ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Note(TimestampMixin, Base):
__tablename__ = "notes"
__table_args__ = (
Index("ix_notes_owner_updated", "owner_id", "updated_at"), # list query এর জন্য
)
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, default="")
is_pinned: Mapped[bool] = mapped_column(default=False)
image_key: Mapped[str | None] = mapped_column(String(300))
deleted_at: Mapped[datetime | None]
owner: Mapped["User"] = relationship(back_populates="notes")# models/__init__.py ← Alembic যেন সব model চেনে
from app.models.base import Base
from app.models.user import User
from app.models.note import Note
__all__ = ["Base", "User", "Note"]Index টা কেন (owner_id, updated_at)? কারণ সবচেয়ে বেশি চলা query হলো "আমার note গুলো, সর্বশেষ বদলানোগুলো আগে"। Database design অধ্যায়ে দেখেছো composite index কীভাবে এই query কে দ্রুত করে।
#ধাপ ৩: Migration
uv run alembic init -t async migrationsmigrations/env.py এ:
from app.core.config import get_settings
from app.models import Base
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadatauv run alembic revision --autogenerate -m "users and notes"
# migrations/versions/xxxx_users_and_notes.py খুলে পড়ো!
uv run alembic upgrade head#ধাপ ৪: Schema (API এর চুক্তি)
# schemas/note.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(default="", max_length=50_000)
is_pinned: bool = False
class NoteUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
content: str | None = Field(default=None, max_length=50_000)
is_pinned: bool | None = None
class NoteOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # SQLAlchemy object থেকে পড়তে পারবে
id: int
title: str
content: str
is_pinned: bool
created_at: datetime
updated_at: datetime
class NotePage(BaseModel):
items: list[NoteOut]
total: int
limit: int
offset: intfrom_attributes=True না দিলে Pydantic SQLAlchemy এর Note object কে চিনবে না, শুধু dict চিনবে।
#ধাপ ৫: Repository আর Service
Repository আগের অধ্যায়ের মতো, সাথে search:
# repositories/note_repo.py
from sqlalchemy import Select, select, func, or_
class NoteRepository:
def __init__(self, db) -> None:
self.db = db
def _base(self, user_id: int, search: str | None) -> Select:
stmt = select(Note).where(Note.owner_id == user_id, Note.deleted_at.is_(None))
if search:
pattern = f"%{search}%"
stmt = stmt.where(or_(Note.title.ilike(pattern), Note.content.ilike(pattern)))
return stmt
async def page(self, user_id: int, limit: int, offset: int, search: str | None) -> tuple[list[Note], int]:
base = self._base(user_id, search)
total = await self.db.scalar(select(func.count()).select_from(base.subquery()))
rows = await self.db.scalars(
base.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).limit(limit).offset(offset)
)
return list(rows), total or 0Service আগের অধ্যায়ের মতোই, শুধু page() যোগ:
async def page(self, user_id: int, limit: int, offset: int, search: str | None) -> NotePage:
items, total = await self.notes.page(user_id, limit, offset, search)
return NotePage(items=items, total=total, limit=limit, offset=offset)#ধাপ ৬: Router
@router.get("", response_model=NotePage)
async def list_notes(
user: CurrentUser,
svc: ServiceDep,
page: PageDep,
search: str | None = Query(None, max_length=100),
):
return await svc.page(user.id, page.limit, page.offset, search)#Unique আর database এর error সামলানো
দুইজন একই email দিয়ে register করলে কী হবে?
from sqlalchemy.exc import IntegrityError
class ConflictError(AppError):
status_code = 409
code = "CONFLICT"
async def register(self, data: UserCreate) -> User:
user = User(email=data.email.lower(), name=data.name, password_hash=hash_password(data.password))
self.db.add(user)
try:
await self.db.commit()
except IntegrityError as e:
await self.db.rollback()
raise ConflictError("এই email দিয়ে আগেই account আছে") from e
return user#Connection pool আর worker এর হিসাব
Production এ uvicorn ৪টা worker process চালালে, আর প্রতিটা process এর pool size ১০ + overflow ৫ হলে, সর্বোচ্চ connection = ৪ × ১৫ = ৬০। ৩টা server হলে ১৮০। PostgreSQL এর default সীমা প্রায়ই ১০০। ফলে চাপ বাড়লে too many connections error।
সমাধান:
- হিসাব করে pool size দাও:
(server × worker × pool) < database এর সীমা - অনেক server হলে মাঝে PgBouncer এর মতো connection pooler বসাও, যেটা হাজার client connection কে অল্প কিছু আসল connection এ মিলিয়ে দেয়
- Managed database (Supabase, Neon, RDS) প্রায়ই নিজেই pooler দেয়
এটা ঠিক সেই জিনিস যা Scaling অধ্যায়ে বলেছিলাম: app server বাড়ালে চাপ database এ সরে যায়।
#পুরো flow একবার দেখি
#অনুশীলন
#নিজেকে যাচাই করো
Pydantic এ `from_attributes=True` কেন লাগে?
এটা Pydantic কে বলে object এর attribute থেকে data পড়তে (যেমন note.title), শুধু dict থেকে না। SQLAlchemy model কে সরাসরি response হিসেবে ফেরত দিতে এটা দরকার।
Email unique কিনা আগে SELECT করে check করা কেন যথেষ্ট না?
দুইটা request প্রায় একসাথে এলে দুজনেই SELECT এ "নেই" দেখবে আর দুজনেই insert করবে (race condition)। Database এর UNIQUE constraint ই একমাত্র নির্ভরযোগ্য সুরক্ষা, তার error ধরে সুন্দর message দিতে হয়।
৩টা server, প্রতিটায় ৪ worker, pool size ২০ হলে database এ সর্বোচ্চ কত connection হতে পারে? সমস্যা কী?
৩ × ৪ × ২০ = ২৪০ (overflow বাদে)। PostgreSQL এর সীমা (প্রায়ই ১০০) ছাড়িয়ে যাবে, ফলে connection error। Pool ছোট করতে হবে বা PgBouncer এর মতো pooler বসাতে হবে।
চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।