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
uv add "sqlalchemy[asyncio]" asyncpg alembicsqlalchemy: ORMasyncpg: PostgreSQL এর async driver (async কেন দরকার)alembic: migration
#Model লেখা (SQLAlchemy 2.0 ধরনে)
# 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
# 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
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 আর হিসাব
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 এর সবচেয়ে বড় ফাঁদ
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):
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
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 += 50000with_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, একই ক্রমে বদলগুলো চালায়।
uv run alembic init -t async migrationsmigrations/env.py এ তোমার model চেনাও:
from app.models import Base
target_metadata = Base.metadataআর alembic.ini বা env.py এ database URL (environment variable থেকে পড়াই ভালো)।
# 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 দেখতে এরকম:
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 ভরে যাবে।