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

Terminal
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
INI
# .env
DATABASE_URL=postgresql+asyncpg://app:secret@localhost:5432/notes
JWT_SECRET=dev-only-change-me

#ধাপ ২: Model

Python
# 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())
Python
# 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")
Python
# 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")
Python
# 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

Terminal
uv run alembic init -t async migrations

migrations/env.py এ:

Python
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.metadata
Terminal
uv run alembic revision --autogenerate -m "users and notes"
# migrations/versions/xxxx_users_and_notes.py খুলে পড়ো!
uv run alembic upgrade head

#ধাপ ৪: Schema (API এর চুক্তি)

Python
# 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: int

from_attributes=True না দিলে Pydantic SQLAlchemy এর Note object কে চিনবে না, শুধু dict চিনবে।

#ধাপ ৫: Repository আর Service

Repository আগের অধ্যায়ের মতো, সাথে search:

Python
# 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 0

Service আগের অধ্যায়ের মতোই, শুধু page() যোগ:

Python
    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

Python
@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 করলে কী হবে?

Python
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 একবার দেখি

Flutter POST /v1/notes Router NoteCreate যাচাই Service ৫০০ এর সীমা? Repository add + commit PostgreSQL INSERT ... RETURNING 201 Created + NoteOut (JSON)
প্রতিটা স্তর নিজের কাজ করে পরের স্তরে দেয়, আর উত্তর একই পথে ফেরে।

#অনুশীলন

#নিজেকে যাচাই করো

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 ভরে যাবে।