SQLAlchemy 2.0 ও Alembic

Python থেকে database, আর migration

রোডম্যাপের মাস ৩পড়তে প্রায় ১৭ মিনিট

SQL জানলে এখন Python থেকে database ব্যবহার করবো। প্রতিটা query হাতে string হিসেবে লেখার বদলে SQLAlchemy ব্যবহার করবো। এটা Python এর সবচেয়ে জনপ্রিয় database library, FastAPI এর সাথে সবচেয়ে বেশি এটাই চলে। আর Alembic দিয়ে database এর গঠনের বদল (migration) সামলাবো, ঠিক যেভাবে Laravel এ php artisan migrate করো।

#ORM কী, কেন

ORM (Object Relational Mapper) database এর table কে Python class হিসেবে দেখায়, আর row কে object হিসেবে:

Database Python (SQLAlchemy)
Table customers Class Customer
Row Customer object
Column name Attribute customer.name
Foreign key Relationship order.customer

সুবিধা: SQL injection থেকে স্বয়ংক্রিয় সুরক্ষা, type hint সহ auto-complete, database বদলানো সহজ, relationship সহজে আনা।

সাবধানতা: ORM SQL লুকিয়ে রাখে, তাই না বুঝে লিখলে অদক্ষ query হয় (বিশেষ করে N+1, নিচে দেখবো)। এজন্যই আগে SQL শিখেছি।

#Setup

Terminal
uv add "sqlalchemy[asyncio]" asyncpg alembic

#Model লেখা (SQLAlchemy 2.0 ধরনে)

Python
# app/models.py
from datetime import datetime
from sqlalchemy import BigInteger, ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    phone: Mapped[str] = mapped_column(String(15), unique=True)
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())

    orders: Mapped[list["Order"]] = relationship(back_populates="customer")


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"), index=True)
    total: Mapped[int]                                   # পয়সায়
    status: Mapped[str] = mapped_column(String(20), default="pending")
    note: Mapped[str | None]                             # None হতে পারে → NULL হতে পারে
    created_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)

    customer: Mapped[Customer] = relationship(back_populates="orders")

Mapped[str] মানে NOT NULL, Mapped[str | None] মানে NULL চলে। Type hint থেকেই SQLAlchemy বুঝে নেয়।

#Engine আর Session

Python
# app/db.py
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

DATABASE_URL = "postgresql+asyncpg://app:secret@localhost:5432/food"

engine = create_async_engine(
    DATABASE_URL,
    pool_size=10,          # একসাথে সর্বোচ্চ ১০টা খোলা connection
    max_overflow=5,        # চাপে আরও ৫টা পর্যন্ত
    pool_pre_ping=True,    # ব্যবহারের আগে connection জীবিত কিনা দেখা
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
  • Engine: database এর সাথে সংযোগের কারখানা, পুরো app এ একটা
  • Connection pool: প্রতিটা query তে নতুন connection খোলা ধীর (TCP + authentication)। Pool কয়েকটা connection খোলা রেখে বারবার ব্যবহার করে।
  • Session: একটা কাজের একক (unit of work)। এক request এ একটা session।

#CRUD

Python
import asyncio
from sqlalchemy import select, update, delete, func
from app.db import SessionLocal
from app.models import Customer, Order


async def main() -> None:
    async with SessionLocal() as session:
        # CREATE
        rahim = Customer(name="Rahim", phone="01711111111")
        session.add(rahim)
        await session.flush()                  # INSERT চালাও, id পাও (commit এখনো না)
        session.add(Order(customer_id=rahim.id, total=45000))
        await session.commit()                 # সব স্থায়ী

        # READ: একটা
        c = await session.get(Customer, rahim.id)

        # READ: query দিয়ে
        stmt = select(Customer).where(Customer.phone == "01711111111")
        c = await session.scalar(stmt)          # একটা বা None

        stmt = (
            select(Order)
            .where(Order.status == "pending", Order.total > 10000)
            .order_by(Order.created_at.desc())
            .limit(20)
        )
        orders = (await session.scalars(stmt)).all()

        # UPDATE: object বদলে commit
        c.name = "Rahim Uddin"
        await session.commit()

        # UPDATE: সরাসরি অনেকগুলো
        await session.execute(
            update(Order).where(Order.status == "pending").values(status="cancelled")
        )
        await session.commit()

        # DELETE
        await session.delete(c)
        await session.commit()


asyncio.run(main())

#JOIN আর হিসাব

Python
stmt = (
    select(Customer.name, func.count(Order.id), func.sum(Order.total))
    .join(Order, Order.customer_id == Customer.id)
    .where(Order.status == "delivered")
    .group_by(Customer.name)
    .order_by(func.sum(Order.total).desc())
)
for name, count, total in await session.execute(stmt):
    print(name, count, total / 100)

তৈরি হওয়া SQL দেখতে চাইলে print(stmt), বা engine এ echo=True দিলে প্রতিটা query log হবে। শেখার সময় echo=True রাখো।

#N+1 সমস্যা: ORM এর সবচেয়ে বড় ফাঁদ

Python
orders = (await session.scalars(select(Order).limit(20))).all()
for o in orders:
    print(o.customer.name)     # প্রতিটা order এ আলাদা query!

এটা চালায় ১টা query (২০টা order) + ২০টা query (প্রতিটার customer) = ২১টা query। ১০০০টা order হলে ১০০১টা। Async SQLAlchemy তে এটা আসলে error দেয় (lazy load অনুমোদিত না), যা আসলে ভালো, কারণ ভুলটা চোখে পড়ে।

সমাধান: আগে থেকে বলে দাও কী কী লাগবে (eager loading):

Python
from sqlalchemy.orm import selectinload, joinedload

# পদ্ধতি ১: JOIN করে একসাথে (many-to-one এর জন্য ভালো)
stmt = select(Order).options(joinedload(Order.customer)).limit(20)

# পদ্ধতি ২: দ্বিতীয় একটা query তে সবগুলো (one-to-many এর জন্য ভালো)
stmt = select(Customer).options(selectinload(Customer.orders)).limit(20)
# SELECT ... FROM customers LIMIT 20
# SELECT ... FROM orders WHERE customer_id IN (1, 2, 3, ...)   ← মাত্র ২টা query

#Transaction

Python
async with SessionLocal() as session:
    async with session.begin():               # এই block শেষে commit, error হলে rollback
        sender = await session.get(Wallet, 1, with_for_update=True)    # row lock
        receiver = await session.get(Wallet, 2, with_for_update=True)
        if sender.balance < 50000:
            raise InsufficientBalanceError(...)
        sender.balance -= 50000
        receiver.balance += 50000

with_for_update=True মানে SELECT ... FOR UPDATE: এই row গুলো lock করো যাতে একই সময়ে আরেকটা transfer একই balance পড়ে ভুল হিসাব না করে। কেন এটা জরুরি সেটা Transaction অধ্যায়ে বিস্তারিত।

#Alembic: database এর version control

Model এ নতুন column যোগ করলে database এ আপনা আপনি যোগ হয় না। Alembic প্রতিটা বদলের জন্য একটা migration file বানায়, যেটা Git এ থাকে। Team এর সবাই, আর production server, একই ক্রমে বদলগুলো চালায়।

Terminal
uv run alembic init -t async migrations

migrations/env.py এ তোমার model চেনাও:

Python
from app.models import Base
target_metadata = Base.metadata

আর alembic.ini বা env.py এ database URL (environment variable থেকে পড়াই ভালো)।

Terminal
# model দেখে migration বানাও
uv run alembic revision --autogenerate -m "create customers and orders"

# database এ প্রয়োগ করো
uv run alembic upgrade head

# এক ধাপ পেছনে
uv run alembic downgrade -1

# কোন version এ আছি
uv run alembic current

তৈরি হওয়া file দেখতে এরকম:

Python
def upgrade() -> None:
    op.add_column("orders", sa.Column("note", sa.String(), nullable=True))

def downgrade() -> None:
    op.drop_column("orders", "note")

#Production এ নিরাপদ migration

লাখ লাখ row এর table এ কিছু বদল database কে মিনিটের পর মিনিট lock করে রাখে, তখন app কাজ করে না। কয়েকটা নিয়ম:

  • নতুন column যোগ করো nullable বা default সহ, পরে ধীরে ধীরে data ভরো
  • Column এর নাম বদলাতে হলে: নতুন column যোগ → দুই জায়গায় লেখা → পুরনো data copy → নতুন থেকে পড়া → পরের release এ পুরনো মুছে ফেলা
  • বড় table এ index: CREATE INDEX CONCURRENTLY (lock ছাড়া)
  • Deploy এর আগে production এর মতো data এর copy তে migration চালিয়ে সময় দেখো

#অনুশীলন

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

Connection pool কেন দরকার?

প্রতিটা query তে নতুন database connection খোলা ধীর (network handshake আর authentication)। Pool কয়েকটা connection খোলা রেখে বারবার ব্যবহার করে, আর একসাথে কতগুলো connection খুলবে তার সীমাও রাখে যাতে database ডুবে না যায়।

N+1 সমস্যা কী, আর SQLAlchemy তে সমাধান কী?

একটা query তে N টা row এনে, তারপর প্রতিটার সম্পর্কিত data আনতে আলাদা query চালানো, মোট N+1 query। সমাধান eager loading: joinedload (JOIN করে একসাথে) বা selectinload (দ্বিতীয় একটা IN query তে সবগুলো)।

`session.flush()` আর `session.commit()` এর পার্থক্য কী?

flush জমে থাকা বদলগুলো database এ পাঠায় (যেমন INSERT চালিয়ে id পাওয়া যায়) কিন্তু transaction শেষ করে না, তাই পরে rollback করা যায়। commit transaction শেষ করে বদলগুলো স্থায়ী করে।

Alembic autogenerate এর বানানো migration চালানোর আগে কেন পড়তে হবে?

এটা সব বদল সঠিকভাবে বোঝে না। যেমন column এর নাম বদলকে "মুছে নতুন যোগ" ভাবতে পারে, ফলে data হারায়। Production এ যাওয়ার আগে মানুষের চোখে যাচাই জরুরি।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।