এই পর্বে যা শিখবেন
- ORM কোন সমস্যার সমাধান, আর ভেতরে কী করে — standard library দিয়ে নিজের হাতে একটি ছোট mapper
- SQLAlchemy-র engine, connection URL, model,
Sessionআর পুরো CRUD — 2.0-এর আধুনিক ধরনে - one-to-many relationship (ক্রেতা → অর্ডার),
back_populates, lazy loading আর N+1 সমস্যার সমাধান - unit of work, identity map, transaction; filter, join,
group_byদিয়ে জেলাভিত্তিক রিপোর্ট;text()দিয়ে নিরাপদ raw SQL - Alembic migration, FastAPI-র সাথে Session, in-memory ডেটাবেসে টেস্ট, async, বিকল্প লাইব্রেরি — আর কখন ORM ব্যবহার করবেন না
১ · সমস্যা: ব্যবসার কোডে ছড়ানো SQL — ORM কী করে
পর্ব ২৮-এ আপনি sqlite3 দিয়ে SQL লিখে ডেটাবেসে ডেটা রেখেছেন, পড়েছেন, transaction দিয়ে নিরাপদ রেখেছেন। পর্ব ৫১-এ FastAPI দিয়ে একটি দোকানের API বানিয়েছেন। এবার দুটি জোড়া লাগাতে গেলে একটি অস্বস্তি টের পাবেন। ধরুন একটি অনলাইন দোকানে ক্রেতা আর অর্ডার আছে। প্রায় প্রতিটি ফাংশনে এমন কোড জমতে থাকে:
def customer_summary(conn, customer_id):
row = conn.execute("SELECT id, name, district FROM customers WHERE id = ?",
(customer_id,)).fetchone()
orders = conn.execute("SELECT amount FROM orders WHERE customer_id = ?",
(customer_id,)).fetchall()
return {"name": row[1], "district": row[2], # row[1] মানে কী? মনে রাখতে হয়
"total": sum(r[0] for r in orders)}কোডটি ঠিকই কাজ করে, কিন্তু তিনটি সমস্যা লুকিয়ে আছে। প্রথমত, ব্যবসার কথা আর ডেটাবেসের খুঁটিনাটি মিশে গেছে — “ক্রেতার মোট কেনাকাটা” বোঝার আগে পাঠককে টেবিলের নাম, কলামের ক্রম আর row[1]-এর অর্থ মনে রাখতে হয়। দ্বিতীয়ত, একই SQL বারবার লেখা হয়: পঞ্চাশটি ফাংশনে SELECT ... FROM customers, আর একদিন একটি কলাম যোগ হলে পঞ্চাশটি জায়গায় খুঁজে বদলাতে হয়। তৃতীয়ত, দুই জগতের আকার আলাদা: Python-এ একজন ক্রেতা একটি object, যার ভেতরে তাঁর অর্ডারের একটি list; ডেটাবেসে ক্রেতা একটি টেবিলের একটি সারি, আর অর্ডার আরেক টেবিলে, শুধু একটি সংখ্যা (customer_id) দিয়ে জোড়া।
এই তৃতীয় সমস্যাটির একটি নাম আছে: object-relational impedance mismatch. নিচের টেবিলে দেখুন, দুই জগৎ একই জিনিসকে কতটা ভিন্নভাবে দেখে:
| ধারণা | Python-এর জগৎ | ডেটাবেসের জগৎ |
|---|---|---|
| নকশা | class Customer | customers টেবিল |
| একটি জিনিস | একটি object | একটি সারি (row) |
| পরিচয় | object নিজেই (is) | primary key (id = 7) |
| সম্পর্ক | reference: customer.orders একটি list | foreign key: orders.customer_id |
| ঘুরে দেখা | ডট দিয়ে এক object থেকে আরেকটিতে যাওয়া | একসাথে অনেক সারির ওপর প্রশ্ন — JOIN, WHERE |
| উত্তরাধিকার | inheritance আছে | সরাসরি নেই; নকল করতে হয় |
একটি ORM (Object-Relational Mapper) এই দুই জগতের মাঝের সেতু। আপনি class লেখেন, object বানান, attribute বদলান; ORM সেই কাজগুলোকে SQL-এ অনুবাদ করে ডেটাবেসে পাঠায়, আর ফেরত আসা সারিগুলোকে আবার object বানিয়ে দেয়।
চট্টগ্রামের একজন ব্যবসায়ী চীনের একটি কারখানা থেকে মাল আনেন। তিনি চীনা ভাষা জানেন না, তাই একজন দোভাষী রাখেন। ব্যবসায়ী বাংলায় বলেন, “লাল রঙের ৫০০টি ব্যাগ, আগামী মাসে”; দোভাষী সেটি কারখানার ভাষায় বলেন, আর উত্তর বাংলায় ফিরিয়ে আনেন। এতে কাজ অনেক সহজ। কিন্তু অভিজ্ঞ ব্যবসায়ীরা জানেন, দোভাষী মাঝে মাঝে কথা ঘুরিয়ে ফেলেন, বা একটি প্রশ্নের জন্য পঞ্চাশবার ফোন করেন। তাই তাঁরা অন্তত এতটুকু চীনা শিখে নেন, যাতে বোঝা যায় আসলে কী বলা হচ্ছে। ORM সেই দোভাষী, SQL কারখানার ভাষা। এই পর্বের পুরো সময় আমরা দোভাষীর মুখ থেকে বের হওয়া SQL-ও দেখব।
ORM ভেতরে কী করে, তা বোঝার সবচেয়ে ভালো উপায় নিজে একটি ছোট সংস্করণ বানানো। নিচের প্রোগ্রামে একটি dataclass (পর্ব ৩৪) object থেকে INSERT বানানো হয়েছে, আর SELECT-এর সারি থেকে object. set_trace_callback দিয়ে sqlite3-কে বলেছি, প্রতিটি SQL পাঠানোর আগে ছাপিয়ে দিতে:
import sqlite3
from dataclasses import astuple, dataclass, fields
@dataclass
class Customer:
name: str
district: str
def save(conn, obj): # object → INSERT
table, cols = type(obj).__name__.lower() + "s", [f.name for f in fields(obj)]
marks = ", ".join("?" * len(cols))
conn.execute(f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({marks})", astuple(obj))
def find(conn, cls, **where): # SELECT → objects
cols = ", ".join(f.name for f in fields(cls))
cond = " AND ".join(f"{key} = ?" for key in where)
sql = f"SELECT {cols} FROM {cls.__name__.lower()}s WHERE {cond}"
return [cls(*row) for row in conn.execute(sql, tuple(where.values()))]
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, district TEXT)")
conn.set_trace_callback(lambda sql: print("SQL>", sql))
for c in [Customer("রহিম", "ঢাকা"), Customer("করিম", "খুলনা")]: save(conn, c)
print(find(conn, Customer, district="ঢাকা"))আউটপুট পাঁচ লাইনের: SQL> BEGIN, তারপর SQL> INSERT INTO customers (name, district) VALUES ('রহিম', 'ঢাকা') আর করিমের জন্য একই রকম একটি লাইন, তারপর SQL> SELECT name, district FROM customers WHERE district = 'ঢাকা', আর শেষে [Customer(name='রহিম', district='ঢাকা')]. লক্ষ করুন, আমরা কোথাও SQL হাতে লিখিনি — class-এর নাম থেকে টেবিলের নাম (Customer → customers), fields() থেকে কলামের নাম, আর astuple() থেকে মান এসেছে। ফেরার পথে প্রতিটি সারি cls(*row) দিয়ে আবার object হয়েছে। এটিই ORM-এর মূল কৌশল। প্রথম BEGIN লাইনটি sqlite3 নিজে পাঠিয়েছে, কারণ প্রথম INSERT-এর আগে সে একটি transaction খোলে (পর্ব ২৮)। ট্রেসে মানগুলো বসানো দেখালেও আসলে মান আলাদাভাবে ?-এর জায়গায় যায় (parameterized query). টেবিল আর কলামের নাম f-string দিয়ে বসানো নিরাপদ, কারণ সেগুলো আমাদের কোড থেকে আসে, ব্যবহারকারীর কাছ থেকে নয়। আসল ORM এর ওপর যোগ করে relationship, পরিবর্তন খেয়াল রাখা, cache, ডেটাবেস-ভেদে ভিন্ন SQL আর আরও অনেক কিছু — যা এই পর্বের বাকি অংশ।
ORM SQL-কে বাদ দেয় না, SQL লেখার কাজটি স্বয়ংক্রিয় করে। ডেটাবেসের কাছে শেষ পর্যন্ত যায় সেই SQL-ই। তাই ORM ব্যবহার করলেও প্রশ্ন রাখুন: “এই এক লাইন Python থেকে কয়টি, আর কেমন SQL query যাচ্ছে?” এই প্রশ্নের উত্তর জানাই একজন সাধারণ আর একজন দক্ষ ব্যাকএন্ড প্রোগ্রামারের পার্থক্য।
২ · SQLAlchemy-র মানচিত্র — engine, connection URL, Core আর ORM
Python-এ সবচেয়ে প্রচলিত ORM হলো SQLAlchemy. Flask আর FastAPI-র প্রজেক্টে এটিই সাধারণ পছন্দ, আর অনেক ডেটা টুলও ভেতরে এটি ব্যবহার করে (যেমন pandas-এর read_sql একটি SQLAlchemy engine নিতে পারে)। এটি ব্যবহার করা যায় SQLite, PostgreSQL, MySQL, Oracle, SQL Server — প্রায় সব বড় ডেটাবেসের সাথে।
এই পর্বের SQLAlchemy কোড এই পেজে চালানো যাবে না: পেজের ভেতরের Python-এ শুধু standard library আছে। তাই প্রতিটি SQLAlchemy কোডের নিচে “প্রত্যাশিত আউটপুট” দেখানো হয়েছে — সবগুলো SQLAlchemy 2.1.5 আর in-memory SQLite দিয়ে চালিয়ে যাচাই করা (2.0-এর ধরনে লেখা কোড 2.0 আর 2.1 — দুটিতেই চলে)। লগের লাইন থেকে সময়ের মাপ আর এমন বন্ধনী-অংশ ([generated in 0.0001s], [insertmanyvalues 1/2 ...]) বাদ দিয়ে ছোট করা হয়েছে। নিজের কম্পিউটারে পর্ব ২৩-এর মতো একটি virtual environment-এ চালিয়ে দেখুন:
python -m venv .venv
.venv\Scripts\activate # Linux/macOS: source .venv/bin/activate
pip install sqlalchemy
python -c "import sqlalchemy; print(sqlalchemy.__version__)"SQLAlchemy দুই তলার একটি বাড়ি। নিচের তলা Core: ডেটাবেসের সাথে সংযোগ, টেবিলের বর্ণনা, আর Python দিয়ে SQL বানানোর একটি ভাষা (select(), insert(), text())। ওপরের তলা ORM: class আর object, Session, relationship. ORM নিজের সব কাজ Core দিয়েই করে, আর দরকার হলে আপনি যেকোনো সময় নিচের তলায় নামতে পারেন। পুরো পথটি এমন:
| স্তর | কী করে | আপনি যা লেখেন |
|---|---|---|
| ORM | class ↔ টেবিল, object ↔ সারি; পরিবর্তন খেয়াল রাখা | Customer(...), session.add(), session.scalars(select(...)) |
| Core | Python expression থেকে SQL বানানো | select(), func.count(), text() |
| Engine | সংযোগের কারখানা; connection pool রাখে | create_engine("sqlite:///shop.db") |
| Dialect | প্রতিটি ডেটাবেসের SQL-এর “উপভাষা” | URL-এর প্রথম অংশ থেকে নিজেই বাছাই হয় |
| DBAPI driver | আসল সংযোগ — sqlite3, psycopg | pip দিয়ে ইনস্টল (DB-API, PEP 249) |
Connection URL — ডেটাবেসের ঠিকানা
Engine বানাতে একটি connection URL লাগে, যা একটি ওয়েব ঠিকানার মতো দেখতে। কয়েকটি উদাহরণ:
import os
from sqlalchemy import create_engine
engine = create_engine("sqlite:///shop.db") # এই ফোল্ডারের shop.db ফাইল
engine = create_engine("sqlite://") # শুধু RAM-এ (in-memory)
engine = create_engine("sqlite:///shop.db", echo=True) # প্রতিটি SQL লগে দেখাবে
# PostgreSQL: dialect+driver://user:password@host:port/database
# password কোডে নয় — environment variable থেকে
engine = create_engine(os.environ["DATABASE_URL"])
# DATABASE_URL=postgresql+psycopg://shop_user:গোপন@localhost:5432/shopURL-এর গঠন: dialect+driver://ব্যবহারকারী:password@host:port/ডেটাবেস. postgresql+psycopg মানে PostgreSQL-এর dialect আর psycopg (সংস্করণ ৩) driver; driver আলাদা করে pip install "psycopg[binary]" দিয়ে ইনস্টল করতে হয়। SQLite-এর driver sqlite3 Python-এর সাথেই আসে। sqlite:///-এর তিনটি স্ল্যাশ লক্ষ করুন — পরের অংশটি ফাইলের পথ। আর echo=True এই পর্বের সবচেয়ে কাজের বন্ধু: এটি চালু থাকলে SQLAlchemy যা SQL পাঠায়, সব লগে দেখায়। তবে production-এ এটি বন্ধ রাখুন, কারণ লগ ভরে যায় আর তাতে ব্যবহারকারীর ডেটাও চলে আসে।
Engine একবারই বানাবেন, পুরো প্রোগ্রামের জন্য — সাধারণত একটি module-এর শুরুতে। এটি নিজে সংযোগ নয়, বরং সংযোগের একটি কারখানা আর ভাণ্ডার (pool): একটি সংযোগ খুলতে নেটওয়ার্ক আর লগইনের সময় লাগে, তাই engine কিছু সংযোগ খোলা রেখে বারবার ব্যবহার করে। প্রতিটি request-এ নতুন engine বানানো একটি পরিচিত ভুল।
৩ · Model লেখা — DeclarativeBase, Mapped আর mapped_column
এবার দোকানের দুটি টেবিল — ক্রেতা আর অর্ডার — Python class হিসেবে লিখি। SQLAlchemy 2.0-এর ধরনে টেবিলের নকশা লেখা হয় type hint দিয়ে (পর্ব ৪০), যাকে বলে declarative mapping:
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
district: Mapped[str] = mapped_column(String(30))
orders: Mapped[list["Order"]] = relationship(back_populates="customer")
def __repr__(self) -> str:
return f"Customer(id={self.id}, name={self.name!r})"
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
amount: Mapped[int]
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
customer: Mapped["Customer"] = relationship(back_populates="orders")লাইন ধরে পড়ুন:
class Base(DeclarativeBase)— সব model-এর একটি সাধারণ মা-class. এর ভেতরে থাকেBase.metadata, যেখানে প্রতিটি টেবিলের বর্ণনা জমা হয়। পর্ব ৩৮-এর__init_subclass__-এর মতো কৌশলে, প্রতিটি subclass লেখার মুহূর্তেই SQLAlchemy সেটি পড়ে টেবিলের নকশা বানায়।__tablename__— ডেটাবেসে টেবিলের নাম।Mapped[int]— এই attribute একটি কলাম, আর Python-এ এর typeint.Mapped[str]মানে কলামটিNOT NULL; খালি রাখা যায় এমন কলাম চাইলে লিখুনMapped[str | None]. অর্থাৎ type hint থেকেই SQLAlchemy কলামের type আর NULL হতে পারবে কি না — দুটোই বোঝে। mypy-ও এই hint পড়ে ভুল ধরতে পারে।- mapped_column(...) — কলামের অতিরিক্ত তথ্য:
primary_key=True,String(50)(সর্বোচ্চ দৈর্ঘ্য),ForeignKey("customers.id"),unique=True,index=True,default=.... কিছু না লাগলে (যেমনamount: Mapped[int]) পুরোটাই বাদ দেওয়া যায়। - relationship(...) — এটি কোনো কলাম নয়। এটি বলে, “
customer.ordersপড়লে foreign key ধরে অর্ডারগুলো এনে দাও”। অংশ ৫-এ বিস্তারিত।
Model লেখা হলো, কিন্তু ডেটাবেসে এখনো কোনো টেবিল নেই। একটি লাইনে সব টেবিল বানানো যায়:
from sqlalchemy import create_engine
from models import Base
engine = create_engine("sqlite://", echo=True)
Base.metadata.create_all(engine) # যে টেবিল নেই, শুধু সেগুলো বানায়প্রত্যাশিত আউটপুট (echo=True-এর লগ, সংক্ষেপে):
BEGIN (implicit)
PRAGMA main.table_info("customers")
PRAGMA main.table_info("orders")
CREATE TABLE customers (
id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
district VARCHAR(30) NOT NULL,
PRIMARY KEY (id)
)
CREATE TABLE orders (
id INTEGER NOT NULL,
amount INTEGER NOT NULL,
customer_id INTEGER NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(customer_id) REFERENCES customers (id)
)
COMMIT
প্রথমে PRAGMA table_info দিয়ে দেখে নিল টেবিল আগে থেকে আছে কি না (আসল লগে temp schema-র জন্যও একই প্রশ্ন আছে), তারপর CREATE TABLE. Type hint কীভাবে SQL হলো, মিলিয়ে দেখুন: Mapped[str] + String(50) → VARCHAR(50) NOT NULL; ForeignKey → FOREIGN KEY ... REFERENCES. একটি গুরুত্বপূর্ণ সীমা: create_all শুধু নতুন টেবিল বানায়, আগের টেবিল বদলায় না। পরে Customer-এ একটি phone কলাম যোগ করলে create_all কিছুই করবে না। চলমান ডেটাবেসের নকশা বদলানোর উপায় অংশ ৮-এর Alembic.
৪ · Session আর CRUD — unit of work, commit আর rollback
ORM-এর কেন্দ্রে আছে Session. একে ভাবুন একটি দোকানের হিসাবের খাতার মতো: দিনের মধ্যে আপনি খাতায় লিখে রাখেন — “রহিম নতুন ক্রেতা”, “জয়ার জেলা বদল”, “এই অর্ডার বাতিল”। দোকান বন্ধের সময় একবারে সব হিসাব মূল রেজিস্টারে তোলেন। মাঝপথে বড় ভুল ধরা পড়লে খাতার পাতা ছিঁড়ে ফেলেন — মূল রেজিস্টার যেমন ছিল তেমনই থাকে। Session ঠিক তাই করে: object-এর বদলগুলো জমিয়ে রাখে, আর commit()-এ সব একসাথে একটি transaction-এ ডেটাবেসে পাঠায়। এই নকশার নাম unit of work.
Create — object বানিয়ে add
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from models import Base, Customer, Order
engine = create_engine("sqlite://", echo=True)
Base.metadata.create_all(engine)
with Session(engine) as session:
rahim = Customer(name="রহিম", district="ঢাকা")
rahim.orders = [Order(amount=1200), Order(amount=450)]
session.add(rahim)
print("commit-এর আগে id:", rahim.id)
session.commit()
print("commit-এর পরে id:", rahim.id)প্রত্যাশিত আউটপুট (create_all-এর লগ বাদ দিয়ে):
commit-এর আগে id: None
BEGIN (implicit)
INSERT INTO customers (name, district) VALUES (?, ?)
('রহিম', 'ঢাকা')
INSERT INTO orders (amount, customer_id) VALUES (?, ?) RETURNING id
(1200, 1)
INSERT INTO orders (amount, customer_id) VALUES (?, ?) RETURNING id
(450, 1)
COMMIT
BEGIN (implicit)
SELECT customers.id, customers.name, customers.district
FROM customers
WHERE customers.id = ?
(1,)
commit-এর পরে id: 1
ROLLBACK
এই আউটপুটে চারটি শিক্ষা আছে। (১) add()-এর পরেও id None — তখনো কোনো SQL যায়নি। (২) আমরা শুধু rahim-কে add করেছি, কিন্তু অর্ডার দুটিও INSERT হয়েছে — relationship ধরে Session নিজেই খুঁজে নিয়েছে (একে বলে cascade). আর অর্ডারের customer_id-এ 1 বসেছে, যা আগের INSERT-এ ডেটাবেস দিয়েছিল; ক্রমটিও Session ঠিক করেছে — আগে ক্রেতা, পরে অর্ডার। (৩) commit()-এর পরে rahim.id পড়তেই একটি নতুন SELECT গেল। কারণ commit-এর পর Session সব object-কে “মেয়াদোত্তীর্ণ” (expired) চিহ্নিত করে — অন্য কেউ এর মধ্যে ডেটা বদলে থাকতে পারে, তাই পরের বার পড়ার সময় তাজা মান আনে। (৪) শেষের ROLLBACK ভয়ের কিছু নয়: with শেষ হওয়ার সময় খোলা থাকা পড়ার transaction-টি বন্ধ হলো; লেখার কিছু বাকি ছিল না। পুরো প্রক্রিয়াটি ধাপে ধাপে দেখুন:
1with Session(engine) as session:2 karim = Customer(name="করিম", district="খুলনা")3 karim.orders.append(Order(amount=800))4 session.add(karim)5 session.commit()
| karim | |
| karim.id | |
| order.customer_id |
- একটি সাধারণ Python object.
Customer(...)বানালে ডেটাবেস কিছুই জানে না। SQLAlchemy-র ভাষায় object-টি transient — Session-এর সাথে কোনো সম্পর্ক নেই,idনেই। - Relationship-এ অর্ডার যোগ।
karim.orders.append(...)শুধু Python-এর list-এ যোগ করল।back_populates-এর কারণে উল্টো দিকও সঙ্গে সঙ্গে ঠিক হলো:order.customer is karimএখনTrue, যদিও ডেটাবেসে কিছু যায়নি। add()— খাতায় লেখা। karim এখন pending: Session জানে, এটি পরে INSERT করতে হবে (session.new-এ দেখা যায়)। অর্ডারটিও cascade হয়ে একই তালিকায়। তবু এখনো কোনো SQL নেই — unit of work বদল জমিয়ে রাখে।commit()-এর প্রথম ভাগ: flush. Session হিসাব করে কোন ক্রমে লিখতে হবে — অর্ডারেরcustomer_id-এর জন্য ক্রেতার id লাগবে, তাই আগে ক্রেতা। INSERT-এর পর ডেটাবেস id2দিল, Session সেটিkarim.id-তে আর অর্ডারেরcustomer_id-তে বসিয়ে দ্বিতীয় INSERT পাঠাল। জমানো বদল SQL হয়ে যাওয়াকে বলে flush; চাইলেsession.flush()দিয়ে আগেও করা যায়।commit()-এর দ্বিতীয় ভাগ: COMMIT. transaction স্থায়ী হলো — দুটি INSERT একসাথে টিকল; মাঝপথে ভুল হলে একটিও টিকত না (পর্ব ২৮-এর atomicity). তারপর সব object expired: পরের বার attribute পড়লে তাজা মান আনতে একটি SELECT যাবে।
Read, Update, Delete
ধরে নিই ডেটাবেসে এখন চারজন ক্রেতা: রহিম (ঢাকা, ২টি অর্ডার), করিম (খুলনা, ১টি), সালমা (ঢাকা, ৩টি) আর জয়া (রাজশাহী, কোনো অর্ডার নেই)। পড়ার জন্য SQLAlchemy 2.0-এ একটিই পথ — Core-এর select():
from sqlalchemy import select
with Session(engine) as session:
stmt = select(Customer).where(Customer.district == "ঢাকা").order_by(Customer.name)
for c in session.scalars(stmt): # Read: অনেকগুলো
print(c)
joya = session.get(Customer, 4) # Read: primary key দিয়ে একটি
joya.district = "বগুড়া" # Update: শুধু attribute বদলান
session.commit()
session.delete(joya) # Delete
session.commit()SELECT customers.id, customers.name, customers.district
FROM customers
WHERE customers.district = ? ORDER BY customers.name
('ঢাকা',)
Customer(id=1, name='রহিম')
Customer(id=3, name='সালমা')
SELECT customers.id, customers.name, customers.district FROM customers WHERE customers.id = ?
(4,)
UPDATE customers SET district=? WHERE customers.id = ?
('বগুড়া', 4)
COMMIT
...
DELETE FROM customers WHERE customers.id = ?
(4,)
COMMIT
কয়েকটি বিষয় লক্ষ করুন। Customer.district == "ঢাকা" দেখতে একটি তুলনা, কিন্তু এটি True/False দেয় না — পর্ব ৩২-এর __eq__ overload করে SQLAlchemy একটি SQL expression বানায়, যা WHERE customers.district = ? হয়। মান যায় আলাদা করে (('ঢাকা',)), তাই SQL injection-এর ভয় নেই। session.scalars(stmt) প্রতিটি সারির প্রথম (এখানে একমাত্র) জিনিসটি — Customer object — দেয়; সাথে আছে .all() (list), .first() (প্রথমটি বা None), .one() (ঠিক একটি না হলে exception — একাধিক পেলে MultipleResultsFound) আর .one_or_none(). একাধিক কলাম বা object একসাথে চাইলে session.execute(stmt), যা সারি (row) দেয়।
Update-এ কোনো SQL লিখিনি, শুধু attribute বদলেছি। Session প্রতিটি object-এর আগের মান মনে রাখে, আর flush-এর সময় শুধু বদলানো কলামের জন্য UPDATE ... SET district=? পাঠায়। একই মান আবার বসালে (যেমন "ঢাকা"-কে "ঢাকা") কোনো UPDATE-ই যায় না। session.get()-এর একটি বিশেষ গুণ আছে, যা নিচের stepper-এ দেখবেন।
Rollback আর transaction-এর নিরাপদ ছাঁচ
with Session(engine) as session:
karim = session.get(Customer, 2)
karim.district = "ভুল জেলা"
session.rollback() # জমানো বদল বাতিল, object expired
print(karim.district) # নতুন SELECT → খুলনা
# ছাঁচ: সফল হলে নিজেই commit, exception হলে নিজেই rollback
with Session(engine) as session, session.begin():
session.add(Customer(name="সালমা", district="ঢাকা"))প্রত্যাশিত আউটপুট: খুলনা. rollback() শুধু ডেটাবেসের transaction বাতিল করে না, Python object-এর বদলানো মানও ফেলে দেয় — পরের বার পড়লে ডেটাবেসের আসল মান আসে। দ্বিতীয় ছাঁচটি (session.begin()) পর্ব ৩৯-এর context manager-এর সুন্দর ব্যবহার: block সফলভাবে শেষ হলে commit, মাঝে exception উঠলে rollback, আর শেষে Session বন্ধ। বড় প্রজেক্টে সাধারণত SessionLocal = sessionmaker(engine) একবার বানিয়ে প্রতিবার with SessionLocal.begin() as session: লেখা হয়।
Autoflush — query-র আগে নিজে থেকে flush: session.add(Customer(name="সালমা", district="ঢাকা")) করে commit না করেই ঢাকার ক্রেতা গুনলে, SQLAlchemy query পাঠানোর আগে নিজেই INSERT-টি flush করে, তাই গণনায় নতুন ক্রেতাও আসে। এতে Session-এর ভেতরে যা দেখছেন আর ডেটাবেস যা বলছে, তা মিলে থাকে। কিন্তু flush মানে commit নয়: এরপর rollback() করলে INSERT-টিও বাতিল হয়।
Identity map — একই সারি, একই object
Session-এর ভেতরে একটি dict থাকে, যার চাবি “কোন class, কোন primary key”, আর মান সেই object. একে বলে identity map. এর ফল দেখতে অবাক লাগতে পারে:
1a = session.get(Customer, 1)2b = session.scalars(select(Customer)3 .where(Customer.name == "রহিম")).one()4print(a is b)5c = session.get(Customer, 1)
| (Customer, 1) | |
| মোট SQL |
- প্রথম পড়া।
session.get(Customer, 1)আগে identity map-এ দেখে; নেই, তাই SELECT পাঠায়। সারি থেকে object #A বানায় আর map-এ রাখে:(Customer, 1) → #A. - অন্য পথে একই সারি। নাম দিয়ে খোঁজা একটি ভিন্ন query, তাই SQL যায়। কিন্তু ফেরত সারির primary key
1— map-এ আগেই আছে। SQLAlchemy নতুন object বানায় না, #A-ই ফেরত দেয়। #A-তে commit না করা কোনো বদল থাকলে সেটিও মুছে যায় না। a is b→True. একটি Session-এর ভেতরে ডেটাবেসের একটি সারির জন্য Python-এ ঠিক একটি object. তাই এক জায়গায়a.districtবদলালেb.district-ও বদলায় — দুটি আসলে একই জিনিস (পর্ব ১৯-এর alias).- আবার
get— SQL ছাড়াই।session.get()primary key দিয়ে খোঁজে, তাই map-এ পেলে ডেটাবেসে যায়ই না। এটি একটি ছোট cache-এর কাজও করে। তবে মনে রাখুন, map প্রতিটি Session-এর নিজের; Session বন্ধ হলে map-ও শেষ। তাই Session-কে অল্প সময়ের জন্য রাখুন — সাধারণত একটি request বা একটি কাজের জন্য একটি।
৫ · Relationship — একজন ক্রেতা, অনেক অর্ডার
অংশ ৩-এর model-এ সম্পর্কটি দুই স্তরে লেখা। ডেটাবেসের স্তরে: orders.customer_id একটি foreign key, যা customers.id-কে নির্দেশ করে। Python-এর স্তরে: দুই দিকে দুটি relationship() — Customer.orders (একটি list) আর Order.customer (একটি object). একজন ক্রেতার অনেক অর্ডার, কিন্তু প্রতিটি অর্ডারের একজনই ক্রেতা — এটি one-to-many সম্পর্ক, ডেটাবেসে সবচেয়ে বেশি দেখা ধরন।
back_populates দুই দিককে জুড়ে রাখে: Customer.orders-এ লেখা back_populates="customer" বলে, “উল্টো দিকের নাম Order.customer”. ফলে এক দিক বদলালে অন্য দিক Python-এর ভেতরেই সঙ্গে সঙ্গে ঠিক হয় — rahim.orders.append(o) করলে o.customer is rahim হয়, আবার o.customer = rahim লিখলে rahim.orders-এ o ঢোকে। দুই পাশে নাম ভুল মেলালে SQLAlchemy model প্রথমবার ব্যবহারের সময়ই error দেয়।
এবার মূল প্রশ্ন: customer.orders পড়লে অর্ডারগুলো আসে কখন? স্বাভাবিক নিয়ম হলো lazy loading — যখন প্রথমবার attribute-টি ছোঁয়া হয়, ঠিক তখন:
1c = session.get(Customer, 3)2print(c.name)3amounts = [o.amount for o in c.orders]4again = [o.amount for o in c.orders]5print(c.orders[0].customer is c)
| c | |
| c.orders | |
| মোট SQL |
- শুধু ক্রেতার সারি।
session.get()একটি SELECT পাঠায়, তবে শুধুcustomersটেবিল থেকে। অর্ডার আনা হয়নি — হয়তো কখনো লাগবেই না, তাই আগে থেকে আনা অপচয়। - সাধারণ কলাম — বিনা খরচে।
c.nameপ্রথম SELECT-এই এসেছিল, তাই নতুন কোনো query নেই। - প্রথমবার
c.orders— lazy load. attribute-টি ছোঁয়ামাত্র SQLAlchemy foreign key ধরে দ্বিতীয় SELECT পাঠাল:WHERE ? = orders.customer_id, মান3. তিনটি Order object বানিয়ে list-এ রাখল। কোডে দেখতে একটি সাধারণ attribute পড়া, অথচ ভেতরে একটি ডেটাবেস query — এটিই lazy loading-এর সুবিধা আর বিপদ, দুটোই। - দ্বিতীয়বার — আর query নেই। list একবার আনা হলে Session-এর ভেতরে থেকে যায়, তাই একই Session-এ আবার পড়লে SQL যায় না।
- উল্টো দিকে যাওয়া — identity map.
c.orders[0].customer-এর জন্য দরকারcustomer_id = 3-এর ক্রেতা। identity map-এ(Customer, 3)আগেই আছে, তাই SQL ছাড়াই একই objectcফেরত —True. সতর্কতা: Session বন্ধ হওয়ার পর প্রথমবারc.ordersপড়লে lazy load-এর জন্য কোনো সংযোগ থাকে না, তখন আসেDetachedInstanceError.
মুছে ফেলার সময় সন্তানদের কী হবে — cascade
রহিমের দুটি অর্ডার আছে। session.delete(rahim) করে commit করলে কী হয়? SQLAlchemy-র স্বাভাবিক আচরণ হলো অর্ডারগুলোর customer_id-কে NULL করে দেওয়া। কিন্তু আমাদের model-এ customer_id: Mapped[int] মানে NOT NULL, তাই আসে IntegrityError: NOT NULL constraint failed: orders.customer_id, আর কিছুই মোছে না। এটি আসলে ভালো খবর — ডেটাবেসের নিয়ম ভুল আটকে দিল। কী চাইছেন, তা স্পষ্ট বলে দিতে হয়। একটি ব্লগে পোস্ট মুছলে তার মন্তব্যগুলো রাখার কোনো মানে নেই:
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100))
comments: Mapped[list["Comment"]] = relationship(
back_populates="post", cascade="all, delete-orphan")
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(primary_key=True)
body: Mapped[str]
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"))
post: Mapped["Post"] = relationship(back_populates="comments")cascade="all, delete-orphan" মানে: পোস্ট মুছলে তার সব মন্তব্যও মুছবে (cascade delete), আর কোনো মন্তব্যকে পোস্টের list থেকে সরিয়ে দিলে সেটিও মুছবে (সে তখন “এতিম”)। তিনটি মন্তব্যসহ দুটি পোস্টের একটিকে (দুটি মন্তব্যসহ) মুছে গুনলে প্রত্যাশিত আউটপুট 1 — একটি মন্তব্য বাকি। কিন্তু দোকানের অর্ডারে এই নিয়ম বিপজ্জনক: ক্রেতার account মুছলে বিক্রির হিসাবও মুছে যাবে, যা হিসাবরক্ষণ আর আইন — দুই দিক থেকেই সমস্যা। সেখানে বরং ক্রেতাকে “নিষ্ক্রিয়” চিহ্নিত করা হয়। কোন সম্পর্কে cascade চলবে, তা প্রোগ্রামিংয়ের নয়, ব্যবসার সিদ্ধান্ত।
৬ · N+1 সমস্যা — লুপের ভেতরে লুকানো query
Lazy loading একটি অর্ডারের জন্য চমৎকার। কিন্তু একটি তালিকার পেজ ভাবুন: “সব ক্রেতা আর প্রত্যেকের অর্ডার সংখ্যা”। স্বাভাবিকভাবে লিখলে:
with Session(engine) as session:
for c in session.scalars(select(Customer)):
print(c.name, len(c.orders)) # প্রতিটি c.orders → একটি নতুন SELECTকোডটি নিরীহ দেখায়, আর আউটপুটও ঠিক — রহিম 2, করিম 1, সালমা 3, জয়া 0. কিন্তু echo=True লগ খুললে দেখবেন ১টি SELECT ক্রেতাদের জন্য, তারপর প্রত্যেক ক্রেতার জন্য আলাদা একটি SELECT ... FROM orders WHERE ? = orders.customer_id — মোট ৫টি query. ক্রেতা চারজন হলে ৫টি, এক হাজার হলে ১,০০১টি। একে বলে N+1 query problem: তালিকার জন্য ১টি, আর তালিকার N-টি উপাদানের প্রত্যেকটির জন্য ১টি করে। নিজের কম্পিউটারের SQLite-এ প্রতিটি query হয়তো এক মিলিসেকেন্ডেরও কম, তাই টেরই পাবেন না। কিন্তু production-এ ডেটাবেস থাকে আলাদা সার্ভারে, প্রতিটি query-তে নেটওয়ার্কের যাওয়া-আসা কয়েক মিলিসেকেন্ড; ১,০০১ × ২ মিলিসেকেন্ড = দুই সেকেন্ডের বেশি — শুধু একটি পেজের জন্য।
ORM ছাড়াও এই সমস্যা হয়; ORM শুধু এটিকে লুকিয়ে ফেলে। sqlite3 দিয়ে দুটি পদ্ধতির query গুনে দেখি — প্রতিটি SQL একটি list-এ জমা হবে:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.executescript("""
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount INTEGER);
INSERT INTO customers (name) VALUES ('রহিম'), ('করিম'), ('সালমা'), ('জয়া');
INSERT INTO orders (customer_id, amount) VALUES (1, 1200), (1, 450), (2, 800), (3, 300);
""")
queries = []
conn.set_trace_callback(queries.append) # প্রতিটি SQL গুনে রাখি
customers = conn.execute("SELECT id, name FROM customers").fetchall()
for cid, name in customers: # lazy: প্রতি ক্রেতায় একটি query
conn.execute("SELECT amount FROM orders WHERE customer_id = ?", (cid,)).fetchall()
print("lazy loop :", len(queries), "queries")
queries.clear() # selectin: সব id একসাথে
ids = [cid for cid, _ in conn.execute("SELECT id, name FROM customers").fetchall()]
marks = ", ".join("?" * len(ids))
rows = conn.execute(f"SELECT customer_id, amount FROM orders WHERE customer_id IN ({marks})", ids).fetchall()
print("IN (...) :", len(queries), "queries")
print(queries[-1])আউটপুট তিন লাইনের: lazy loop : 5 queries, IN (...) : 2 queries, আর SELECT customer_id, amount FROM orders WHERE customer_id IN (1, 2, 3, 4). প্রথম পদ্ধতি হুবহু lazy loading-এর অনুকরণ: ক্রেতাদের জন্য ১টি, তারপর লুপে প্রত্যেকের জন্য ১টি — ৪+১ = ৫। দ্বিতীয় পদ্ধতিতে আগে সব ক্রেতার id জোগাড় করে একটি মাত্র WHERE customer_id IN (...) query-তে সবার অর্ডার আনা হয়েছে — ক্রেতা চারজন হোক বা চার হাজার, query সবসময় ২টি। এরপর Python-এ অর্ডারগুলো ক্রেতা ধরে ভাগ করে নিলেই চলে। এখানে f-string দিয়ে শুধু ? চিহ্নগুলো বসানো হয়েছে, মান নয় — তাই নিরাপদ। set_trace_callback-এ দেওয়া ফাংশন প্রতিটি SQL-এর লেখা পায়; এখানে আমরা queries.append দিয়ে সেগুলো শুধু জমিয়েছি। SQLAlchemy-র selectinload ঠিক এই দ্বিতীয় কৌশলটিই করে।
সমাধান: আগে থেকে বলে দেওয়া — eager loading
যখন জানেন যে relationship লাগবেই, query-তেই সেটি বলে দিন। একে বলে eager loading, আর SQLAlchemy-তে দুটি প্রধান কৌশল:
1# (ক) lazy loading2for c in session.scalars(select(Customer)):3 print(c.name, len(c.orders))4# (খ) selectinload5stmt = select(Customer).options(6 selectinload(Customer.orders))7for c in session.scalars(stmt):8 print(c.name, len(c.orders))
| পদ্ধতি | |
| এ পর্যন্ত query |
- (ক) প্রথম query — ক্রেতারা।
select(Customer)শুধুcustomersটেবিল পড়ে। চারটি Customer object, কারওordersএখনো আনা হয়নি। - লুপের প্রথম ঘর — লুকানো query.
len(c.orders)ছুঁতেই রহিমের জন্য lazy load: একটি নতুন SELECT. কোডে কোথাও “query” শব্দটি নেই, তবু ডেটাবেসে যাওয়া হলো। - বাকি তিনজন — আরও তিনটি query. করিম, সালমা, জয়া — প্রত্যেকের জন্য একই ধরনের SELECT, শুধু মান বদলায়। মোট ১ + ৪ = ৫। জয়ার কোনো অর্ডার নেই, তবু তাঁর জন্যও একটি query গেল, যার উত্তর খালি।
- (খ) একই প্রথম query, সাথে একটি নির্দেশ।
.options(selectinload(Customer.orders))SQLAlchemy-কে আগেই জানিয়ে দেয়: “এই ক্রেতাদের অর্ডারও লাগবে”। প্রথম SELECT একই রকম। - দ্বিতীয় query — সবার অর্ডার একসাথে। ক্রেতাদের সারি আসামাত্র SQLAlchemy তাঁদের id জোগাড় করে একটি
WHERE orders.customer_id IN (...)পাঠায়, আর অর্ডারগুলো ক্রেতা ধরে ভাগ করে প্রত্যেকেরorders-এ বসিয়ে দেয়। লুপেc.ordersপড়লে আর কোনো query যায় না। আউটপুট হুবহু এক, কিন্তু query ৫টির বদলে ২টি — আর ক্রেতা হাজারজন হলেও ২টি।
দ্বিতীয় কৌশল joinedload: একটি মাত্র query-তে LEFT OUTER JOIN দিয়ে ক্রেতা আর অর্ডার একসাথে আনে। প্রত্যাশিত SQL: SELECT customers.id, customers.name, customers.district, orders_1.id AS id_1, ... FROM customers LEFT OUTER JOIN orders AS orders_1 ON customers.id = orders_1.customer_id. JOIN-এ একজন ক্রেতা যতগুলো অর্ডার, ততগুলো সারিতে আসে, তাই collection-এর জন্য joinedload ব্যবহার করলে SQLAlchemy 2.0 ফলের ওপর .unique() ডাকতে বাধ্য করে — না ডাকলে আসে InvalidRequestError: The unique() method must be invoked on this Result...:
from sqlalchemy.orm import joinedload, selectinload
stmt = select(Customer).options(joinedload(Customer.orders))
customers = session.scalars(stmt).unique().all() # collection-এ unique() বাধ্যতামূলক
stmt = select(Order).options(joinedload(Order.customer)) # অর্ডার → একজন ক্রেতা: joinedload মানানসই| কৌশল | query | কখন |
|---|---|---|
lazy (স্বাভাবিক) | প্রথমবার ছোঁয়ার সময়, প্রতিটি object-এর জন্য ১টি | একটি object-এর সম্পর্ক, যা হয়তো লাগবে না |
selectinload | মোট ২টি (দ্বিতীয়টি IN (...)) | one-to-many collection — বেশিরভাগ ক্ষেত্রে প্রথম পছন্দ |
joinedload | ১টি, JOIN দিয়ে | many-to-one (অর্ডার → ক্রেতা), যেখানে সারি বাড়ে না |
raiseload | lazy load হলেই error | N+1 ধরার পাহারাদার — টেস্টে বা কঠোর কোডে |
N+1 খুঁজে বের করার অভ্যাস: নতুন কোনো তালিকার পেজ বা API লিখলে অন্তত একবার echo=True দিয়ে চালান, আর query গুনুন। ডেটার পরিমাণ দ্বিগুণ করলে query-র সংখ্যাও দ্বিগুণ হলে সেখানে N+1 আছে। কঠোর হতে চাইলে relationship(..., lazy="raise") বা query-তে .options(raiseload("*")) দিন — তখন ভুল করে lazy load হলে InvalidRequestError: 'Customer.orders' is not available due to lazy='raise' আসবে, আর বাগটি production-এ পৌঁছানোর আগেই ধরা পড়বে।
৭ · প্রশ্ন সাজানো — filter, join, group_by আর text()
select() দিয়ে প্রায় পুরো SQL-ই Python-এ লেখা যায়। প্রতিটি method একটি নতুন statement ফেরত দেয়, তাই একটির পর একটি জোড়া যায় — পর্ব ২৮-এর SQL-এর প্রতিটি অংশের একটি করে জোড়া আছে:
| SQL | SQLAlchemy 2.0 |
|---|---|
WHERE amount >= 500 AND ... | .where(Order.amount >= 500, ...) |
WHERE name LIKE 'র%' | .where(Customer.name.startswith("র")) |
WHERE district IN (...) | .where(Customer.district.in_(["ঢাকা", "খুলনা"])) |
ORDER BY amount DESC LIMIT 10 | .order_by(Order.amount.desc()).limit(10) |
JOIN orders ON ... | .join(Customer.orders) (foreign key থেকে শর্ত নিজেই) |
COUNT(*), SUM(amount) | func.count(), func.sum(Order.amount) |
GROUP BY district | .group_by(Customer.district) |
জেলাভিত্তিক বিক্রির রিপোর্ট
ব্যবস্থাপক জানতে চান, কোন জেলা থেকে কত অর্ডার আর কত টাকার বিক্রি। এখানে object নয়, শুধু হিসাবের ফল দরকার — তাই select()-এ পুরো class-এর বদলে কলাম আর aggregate function:
from sqlalchemy import func, select
stmt = (
select(Customer.district, func.count(Order.id), func.sum(Order.amount))
.join(Customer.orders)
.group_by(Customer.district)
.order_by(func.sum(Order.amount).desc())
)
with Session(engine) as session:
for district, n, total in session.execute(stmt):
print(f"{district}: {n}টি অর্ডার, মোট {total} টাকা")SELECT customers.district, count(orders.id) AS count_1, sum(orders.amount) AS sum_1 FROM customers JOIN orders ON customers.id = orders.customer_id GROUP BY customers.district ORDER BY sum(orders.amount) DESC ঢাকা: 5টি অর্ডার, মোট 2800 টাকা খুলনা: 1টি অর্ডার, মোট 800 টাকা
এখানে একটি মাত্র query; হিসাবটি করেছে ডেটাবেস, Python নয়। এটিই ঠিক পথ — সব অর্ডার Python-এ এনে লুপে যোগ করা হতো ধীর আর স্মৃতি-খরচে। session.execute() object নয়, সারি দেয়, যা tuple-এর মতো unpack করা যায়। আর রাজশাহী নেই কেন? জয়ার কোনো অর্ডার নেই, আর সাধারণ JOIN শুধু মিল পাওয়া সারি রাখে। শূন্য অর্ডারের জেলাও দেখাতে চাইলে .outerjoin(Customer.orders) লিখুন — তখন রাজশাহী আসবে 0 অর্ডার নিয়ে (তবে sum হবে None, তাই func.coalesce(func.sum(Order.amount), 0) লাগবে)। GROUP BY-এর পর দলের ওপর শর্ত দিতে .having(func.count(Order.id) >= 2).
একই ছাঁচে ব্লগের রিপোর্ট — প্রতিটি পোস্টে কয়টি মন্তব্য, মন্তব্যহীন পোস্টসহ:
stmt = (select(Post.title, func.count(Comment.id))
.outerjoin(Post.comments)
.group_by(Post.id)
.order_by(func.count(Comment.id).desc(), Post.title))
for title, n in session.execute(stmt):
print(f"{title}: {n}টি মন্তব্য")
# প্রত্যাশিত আউটপুট:
# Python শেখার রোডম্যাপ: 2টি মন্তব্য
# SQL-এর JOIN: 1টি মন্তব্য
# নতুন পোস্ট: 0টি মন্তব্যcount(Comment.id) (count(*) নয়) গুনেছে বলে মন্তব্যহীন পোস্টে 0 এসেছে — outer join-এ সেই সারির Comment.id NULL, আর COUNT NULL গোনে না।
Raw SQL দরকার হলে — text() আর bound parameter
কখনো এমন SQL লাগে, যা select() দিয়ে লেখা কঠিন বা ডেটাবেস-নির্দিষ্ট। তখন SQLAlchemy Core-এর text() ব্যবহার করুন। মান বসানোর নিয়ম পর্ব ২৮-এর মতোই কঠোর: মান যাবে :নাম placeholder দিয়ে, f-string দিয়ে কখনো নয়।
from sqlalchemy import text
district = "ঢাকা"
rows = session.execute(
text("SELECT name FROM customers WHERE district = :d ORDER BY name"),
{"d": district},
)
print([r.name for r in rows]) # ['রহিম', 'সালমা']
evil = "x' OR '1'='1" # দুষ্ট ব্যবহারকারীর input
bad = text(f"SELECT name FROM customers WHERE district = '{evil}'") # ভুল!
print(len(session.execute(bad).all())) # 3 — সব ক্রেতা ফাঁস
good = text("SELECT name FROM customers WHERE district = :d")
print(len(session.execute(good, {"d": evil}).all())) # 0 — নিরাপদf-string-এর সংস্করণে দুষ্ট input SQL-এর অংশ হয়ে গেছে: WHERE district = 'x' OR '1'='1' — শর্তটি সবসময় সত্য, তাই সব ক্রেতা বেরিয়ে এল। :d-এর সংস্করণে পুরো লেখাটি শুধু একটি মান, যার সাথে কোনো জেলা মেলে না। মনে রাখবেন, ORM ব্যবহার করলেই নিরাপদ নন: text()-এর ভেতরে f-string ঢোকালে ORM-এর সব সুরক্ষা অকেজো।
অনেকগুলো সারি একসাথে বদলাতে প্রতিটিকে object হিসেবে আনার দরকার নেই; Core-এর update() একটি SQL-এই কাজ সারে: session.execute(update(Order).where(Order.amount < 1000).values(amount=Order.amount + 50)). এটি প্রতিটি সারিকে object হিসেবে আনে না, তাই হাজারো সারিতে অনেক দ্রুত। আর Session-এর ভেতরে আগে থেকে থাকা মিলে যাওয়া object-গুলোর মানও SQLAlchemy 2.0 সাধ্যমতো মিলিয়ে নেয় (synchronize_session-এর স্বাভাবিক আচরণ)।
৮ · আসল প্রজেক্টে — Alembic, FastAPI, টেস্ট, async আর বিকল্প
Alembic — চলমান ডেটাবেসের নকশা বদলানো
অ্যাপ চালু হয়ে গেছে, ডেটাবেসে হাজারো ক্রেতা। এখন ক্রেতার ফোন নম্বর রাখতে হবে। Model-এ phone: Mapped[str | None] = mapped_column(String(15)) যোগ করলেন, কিন্তু অংশ ৩-এ দেখেছেন create_all পুরোনো টেবিল ছোঁয় না। টেবিল মুছে নতুন করে বানানো মানে সব ডেটা হারানো। সমাধান schema migration: নকশার প্রতিটি বদল একটি ছোট, সংস্করণ-নম্বরযুক্ত Python ফাইল, যা git-এ থাকে আর প্রতিটি সার্ভারে একই ক্রমে চলে। SQLAlchemy-র জন্য এই কাজের টুল Alembic, যা SQLAlchemy-র নির্মাতারই লেখা:
pip install alembic
alembic init migrations # migrations/ ফোল্ডার আর alembic.ini তৈরি
# alembic.ini-তে sqlalchemy.url, আর migrations/env.py-তে
# target_metadata = Base.metadata বসান
alembic revision --autogenerate -m "add phone to customers"
alembic upgrade head # সব বাকি migration চালাও
alembic downgrade -1 # শেষেরটি উল্টাও
alembic current # ডেটাবেস এখন কোন সংস্করণে--autogenerate আপনার model আর আসল ডেটাবেস মিলিয়ে দেখে পার্থক্যের একটি খসড়া লেখে। আমাদের ক্ষেত্রে এটি জানায় Detected added column 'customers.phone', আর তৈরি ফাইলের ভেতরে থাকে:
def upgrade() -> None:
op.add_column('customers', sa.Column('phone', sa.String(length=15), nullable=True))
def downgrade() -> None:
op.drop_column('customers', 'phone')ফাইলের নামের শুরুর অংশটি (এখানে 585395dd432c) প্রতিবার ভিন্ন একটি এলোমেলো সংস্করণ-চিহ্ন। ডেটাবেসের ভেতরে alembic_version নামের একটি ছোট টেবিল মনে রাখে কোন সংস্করণ পর্যন্ত চলেছে, তাই upgrade head শুধু বাকিগুলো চালায়। খসড়াটি সবসময় পড়ে দেখুন — autogenerate নাম বদলকে প্রায়ই “পুরোনো কলাম মুছে নতুন কলাম যোগ” হিসেবে দেখে, যা ডেটা মুছে ফেলবে। তখন হাতে op.alter_column(..., new_column_name=...) লিখতে হয়। নতুন NOT NULL কলামে পুরোনো সারির জন্য একটি default-ও দিতে হয়।
FastAPI-র সাথে — প্রতিটি request-এ একটি Session
পর্ব ৫১-এ Depends দিয়ে yield করা dependency দেখেছেন। Session-এর জন্য সেটিই আদর্শ: প্রতিটি request নিজের Session পায়, আর কাজ শেষে — সফল হোক বা exception — Session বন্ধ হয়।
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, selectinload
from models import Base, Customer
engine = create_engine("sqlite:///shop.db") # একবারই, module-এর শুরুতে
Base.metadata.create_all(engine) # শেখার জন্য; আসল প্রজেক্টে Alembic
app = FastAPI()
def get_session():
with Session(engine) as session:
yield session # request শেষে with বন্ধ করবে
SessionDep = Annotated[Session, Depends(get_session)]
class CustomerOut(BaseModel):
id: int
name: str
district: str
order_count: int
@app.get("/customers/{customer_id}")
def get_customer(customer_id: int, session: SessionDep) -> CustomerOut:
stmt = (select(Customer).where(Customer.id == customer_id)
.options(selectinload(Customer.orders)))
customer = session.scalars(stmt).one_or_none()
if customer is None:
raise HTTPException(status_code=404, detail="ক্রেতা পাওয়া যায়নি")
return CustomerOut(id=customer.id, name=customer.name,
district=customer.district, order_count=len(customer.orders))TestClient দিয়ে যাচাই করা প্রত্যাশিত উত্তর (FastAPI 0.144): একজন ক্রেতা যোগ করার পর GET /customers/1 → 200 {"id": 1, "name": "করিম", "district": "খুলনা", "order_count": 0}; GET /customers/999 → 404 {"detail": "ক্রেতা পাওয়া যায়নি"}. তিনটি নকশা-সিদ্ধান্ত লক্ষ করুন। (১) Handler সাধারণ def, কারণ সাধারণ SQLAlchemy blocking — পর্ব ৫১-এর নিয়মে FastAPI এটিকে thread pool-এ চালায়। (২) Response-এ ORM object সরাসরি না দিয়ে একটি pydantic model; এতে ভেতরের কলাম (যেমন ভবিষ্যতের কোনো গোপন field) ভুল করে বাইরে যায় না, আর response তৈরির সময় লুকানো lazy load-ও ঘটে না। (৩) order_count-এর জন্য selectinload আগে থেকে দেওয়া। তালিকার endpoint-এ এটি না দিলে প্রতিটি request-এ N+1. (শুধু সংখ্যা লাগলে অংশ ৭-এর মতো func.count-এর একটি query আরও ভালো।) SQLModel এই কাজে pydantic model আর table-কে একই class বানায় — নিচের তুলনা দেখুন।
টেস্ট — in-memory SQLite দিয়ে
পর্ব ৪২-এর pytest-এ প্রতিটি টেস্টের জন্য একটি নতুন, খালি ডেটাবেস চাই — আগের টেস্টের ডেটা যেন পরেরটিকে প্রভাবিত না করে। In-memory SQLite এর জন্য নিখুঁত: চোখের পলকে তৈরি, টেস্ট শেষে নিজেই উধাও।
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from models import Base, Customer, Order
@pytest.fixture
def session():
engine = create_engine("sqlite://") # প্রতিটি টেস্টে নতুন ডেটাবেস
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
def test_orders_belong_to_customer(session):
rahim = Customer(name="রহিম", district="ঢাকা",
orders=[Order(amount=1200), Order(amount=450)])
session.add(rahim)
session.commit()
total = session.scalar(select(func.sum(Order.amount))
.where(Order.customer_id == rahim.id))
assert total == 1650প্রত্যাশিত: 1 passed. FastAPI-র TestClient দিয়ে পুরো API টেস্ট করতে পর্ব ৫১-এর app.dependency_overrides[get_session] দিয়ে আসল Session-এর বদলে in-memory ডেটাবেসের Session বসান। একটি খুঁটিনাটি: TestClient অন্য thread-এ চলে, আর প্রতিটি নতুন সংযোগে in-memory SQLite একটি আলাদা, খালি ডেটাবেস খোলে। তাই সেখানে engine বানাবেন create_engine("sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}) দিয়ে — সব জায়গায় একটিই সংযোগ (StaticPool আসে sqlalchemy.pool থেকে)। আরেকটি সতর্কতা: SQLite আর PostgreSQL পুরোপুরি এক নয় (type-এর কঠোরতা, কিছু function). তাই দ্রুত টেস্ট SQLite-এ চালালেও, release-এর আগে আসল ডেটাবেসের বিরুদ্ধে কিছু integration test চালানো ভালো অভ্যাস।
Async SQLAlchemy — এক ঝলক
পুরো অ্যাপ async def-এ লিখলে (পর্ব ৪৫) SQLAlchemy-র async সংস্করণ আছে: from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, URL-এ async driver (sqlite+aiosqlite://, postgresql+asyncpg://), আর প্রতিটি query-তে await: result = await session.scalars(stmt). এর জন্য pip install "sqlalchemy[asyncio]" aiosqlite লাগে। একটি বড় পার্থক্য: async-এ lazy loading চলে না (attribute পড়ার ভেতরে await করা যায় না), তাই relationship সাধারণত selectinload দিয়ে আগেই আনতে হয় (বিকল্প: AsyncAttrs-এর awaitable_attrs) — যা আসলে N+1 থেকে বাঁচার ভালো অভ্যাসও। Async তখনই নিন, যখন সত্যিই অনেক একসাথে অপেক্ষমাণ সংযোগ সামলাতে হয়; নাহলে সাধারণ SQLAlchemy সহজ আর পরিণত।
বিকল্প — আর কখন ORM নয়
SQLModel
FastAPI-র নির্মাতার লাইব্রেরি; ভেতরে SQLAlchemy আর pydantic. একই class টেবিল আর API model — SQLModel. ছোট FastAPI প্রজেক্টে কম কোড; জটিল প্রশ্নে শেষ পর্যন্ত SQLAlchemy-ই লাগে।
Django ORM
Django-র সাথে আসে, আলাদা ব্যবহার কঠিন। Customer.objects.filter(district="ঢাকা") — সহজ, পড়তে আরামের। N+1-এর সমাধান এখানে select_related আর prefetch_related; migration নিজেরই (makemigrations).
Peewee
ছোট, হালকা একটি ORM — ছোট স্ক্রিপ্ট আর হালকা অ্যাপে মানানসই, শেখাও দ্রুত। বড় দল আর জটিল ডেটাবেসে SQLAlchemy-র মতো গভীরতা নেই।
ORM ছাড়া
sqlite3/psycopg দিয়ে সরাসরি SQL, বা SQLAlchemy Core-এর select() আর text(). বড় রিপোর্ট, ডেটা বিশ্লেষণ আর বিপুল ডেটা একসাথে বদলানোর জন্য প্রায়ই এটিই সেরা।
কখন ORM ব্যবহার করবেন না: (১) ভারী রিপোর্টিং — window function, CTE, দশটি টেবিলের JOIN — যেখানে SQL-টিই আসল কাজ; ORM-এর ভাষায় অনুবাদ করলে শুধু পড়া কঠিন হয়। (২) লাখো সারি একসাথে বদলানো বা আমদানি — object বানানোর খরচ অপ্রয়োজনীয়; Core-এর bulk insert()/update() বা ডেটাবেসের নিজের টুল (যেমন PostgreSQL-এর COPY) ভালো। (৩) ডেটা বিশ্লেষণ — পর্ব ৪৯-এর pandas-এ read_sql দিয়ে সরাসরি টেবিল আনুন। (৪) ছোট স্ক্রিপ্ট — দুটি টেবিল, পাঁচটি query; sqlite3-ই যথেষ্ট। SQLAlchemy-র সুবিধা হলো এক প্রজেক্টে দুটোই চলে: সাধারণ CRUD-এ ORM, ভারী জায়গায় Core বা text().
C-তে একটি সারি পড়ে struct-এ বসানো পুরোটাই হাতের কাজ — প্রতিটি কলাম আলাদা করে কপি; ORM ঠিক এই কাজটি স্বয়ংক্রিয় করে। Session-এর with ব্লক C++-এর RAII-এর মতো: scope শেষ হলে সংযোগ ফেরত যায়, exception হলেও। Java জানলে চিনবেন: SQLAlchemy-র Session প্রায় JPA/Hibernate-এর EntityManager-এর মতো — সেখানেও unit of work, identity map (persistence context), lazy loading আর একই N+1 সমস্যা; Spring-এ তা কীভাবে লেখা হয় দেখুন Spring Boot দিয়ে REST API পর্বে, আর ORM-এর নিচের স্তর JDBC-তে।
- ORM কী, আর এর সুবিধা-অসুবিধা? — টেবিলকে class আর সারিকে object হিসেবে ব্যবহার; সুবিধা কম পুনরাবৃত্তি ও নিরাপদ parameter, অসুবিধা লুকানো query (N+1) আর জটিল SQL-এ সীমা।
- N+1 query problem কী, আর সমাধান? — তালিকার জন্য ১টি, প্রতিটি উপাদানের সম্পর্কের জন্য ১টি করে query; সমাধান eager loading (
selectinload,joinedload) বা JOIN. flush()আরcommit()-এর পার্থক্য? — flush জমানো বদল SQL হিসেবে চলমান transaction-এ পাঠায় (rollback করা যায়); commit transaction স্থায়ী করে।- Identity map কী? — একটি Session-এ প্রতিটি primary key-র জন্য একটিই object; একই সারি দুবার আনলে একই object ফেরত।
- Migration কেন লাগে? — চলমান ডেটাবেসের নকশা ডেটা না হারিয়ে ধাপে ধাপে, সংস্করণ মেনে বদলাতে; Python-এ Alembic, Django-তে
makemigrations/migrate.
৯ · AI-এর যুগে — AI-এর লেখা ডেটাবেস কোড যাচাই করা
“SQLAlchemy দিয়ে customer আর order-এর model আর API লিখে দাও” — AI মুহূর্তে লিখে দেবে। কিন্তু SQLAlchemy-র ইতিহাস দীর্ঘ: ২০০৬ থেকে ২০২৩ পর্যন্ত লেখা লক্ষ লক্ষ টিউটোরিয়াল আর উত্তর মূলত 1.x-এর ধরনে, আর AI সেগুলো থেকেই শিখেছে। ফলে AI প্রায়ই দুই যুগের কোড মিশিয়ে ফেলে, যা চলে, কিন্তু পুরোনো, ধীর বা অনিরাপদ। সবচেয়ে বেশি দেখা ভুলগুলো:
| AI প্রায়ই যা লেখে | সমস্যা | কী চাইবেন |
|---|---|---|
session.query(Customer).filter_by(...), declarative_base(), Column(Integer) | 1.x-এর ধরন; এখনো চলে, কিন্তু “legacy” — নতুন ফিচার আর type checking 2.0-এর ধরনে | select(), DeclarativeBase, Mapped[...], mapped_column |
লুপের ভেতরে customer.orders, কোনো loader option নেই | N+1 — ডেটা বাড়লে পেজ ধীর | selectinload/joinedload; echo=True-এ query গোনা |
module-স্তরে একটি global session, কখনো বন্ধ হয় না | request-এর মধ্যে ডেটা মিশে যায়, thread-নিরাপদ নয়, পুরোনো cache | প্রতিটি কাজে/request-এ with Session(...) বা yield dependency |
add() করে commit() ভুলে যাওয়া | Session বন্ধ হলে সব বদল নিঃশব্দে বাতিল | with session.begin(): ছাঁচ |
text(f"... WHERE name = '{name}'") | SQL injection | text("... = :name") আর আলাদা dict |
create_all() দিয়েই নকশা বদলের চেষ্টা | পুরোনো টেবিলে নতুন কলাম আসে না | Alembic migration |
| ORM object সরাসরি API response-এ | গোপন field ফাঁস, response-এর সময় লুকানো lazy load | আলাদা pydantic response model |
AI-কে ডেটাবেসের কোড লিখতে বলার সময় শর্তগুলো আগেই দিন। নিচের কাঠামোটি কপি করে নিজের প্রজেক্ট বসান:
AI-এর লেখা SQLAlchemy কোড পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলো আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Object আর টেবিলের “impedance mismatch” আসলে কী? দুই জগৎ এত আলাদা কেন, যে কয়েক দশকেও পুরোপুরি মেলানো যায়নি?
“Impedance mismatch” শব্দটি ধার করা বৈদ্যুতিক প্রকৌশল থেকে: দুটি সার্কিটের impedance না মিললে শক্তি ঠিকমতো এক থেকে আরেকটিতে যায় না, একটি অংশ ফিরে আসে বা নষ্ট হয়। ১৯৮০-এর দশকে object-oriented ডেটাবেস নিয়ে আলোচনায় শব্দটি সফটওয়্যারে জনপ্রিয় হয় — প্রোগ্রামের ভাষা আর ডেটাবেসের ভাষার মাঝে তথ্য আনা-নেওয়ায় যে ঘর্ষণ, তা বোঝাতে।
দুই জগতের শিকড় আলাদা। Relational model এসেছে গণিত থেকে: ১৯৭০ সালে IBM-এর গবেষক Edgar F. Codd “A Relational Model of Data for Large Shared Data Banks” প্রবন্ধে ডেটাকে set theory আর predicate logic-এর ভিত্তিতে “relation” (টেবিল) হিসেবে দেখার প্রস্তাব দেন। এই জগতে ডেটা হলো তথ্যের সমষ্টি, আর প্রশ্ন হলো set-এর ওপর কাজ — “ঢাকার সব ক্রেতা যাঁদের অর্ডার ৫০০০ টাকার বেশি”। অন্যদিকে object-oriented প্রোগ্রামিং (Simula, Smalltalk থেকে) ডেটাকে দেখে আচরণসহ জীবন্ত জিনিস হিসেবে, যারা একে অপরকে reference দিয়ে চেনে, আর প্রোগ্রাম এক object থেকে আরেকটিতে “হেঁটে” যায়।
এই পার্থক্য থেকে কয়েকটি নির্দিষ্ট সমস্যা দাঁড়ায়। পরিচয়: Python-এ দুটি object একই কি না বোঝায় is; টেবিলে বোঝায় primary key. তাই ORM-কে identity map বানাতে হয়। সম্পর্কের দিক: object-এ reference এক দিকে যায় (customer.orders), foreign key থাকে উল্টো দিকে (orders.customer_id). উত্তরাধিকার: class-এর inheritance টেবিলে নেই — একটি টেবিলে সব subclass, প্রতিটির আলাদা টেবিল, নাকি দুটির মিশ্রণ — প্রতিটি পথের নিজস্ব দাম। পরিমাণ: object-এর জগতে একটি একটি করে হাঁটা স্বাভাবিক, কিন্তু ডেটাবেস set-এ কাজ করলে দ্রুত — এই অমিল থেকেই N+1. লেনদেনের সীমা: object স্মৃতিতে থাকে, ডেটাবেসের সত্যতা transaction-এ; কোন মুহূর্তে কোনটি “সত্যি”, তা মেলাতে expire, refresh আর flush-এর মতো ধারণা লাগে।
১৯৯০-এর দশকে অনেকে ভেবেছিলেন object database সমস্যাটি মিটিয়ে দেবে — object সরাসরি জমা হবে, অনুবাদ লাগবে না। কিছু ক্ষেত্রে তা ব্যবহৃত হলেও relational ডেটাবেস টিকে গেছে, কারণ ডেটা প্রায়ই প্রোগ্রামের চেয়ে বেশি দিন বাঁচে, আর একই ডেটা অনেক প্রোগ্রাম, রিপোর্ট আর ভাষা থেকে পড়া হয়। একটি নির্দিষ্ট প্রোগ্রামের object-এর আকারে বাঁধা ডেটার চেয়ে নিরপেক্ষ টেবিল বেশি টেকসই।
মূল উপলব্ধি: ORM অমিলটি দূর করে না, সামলায়। দুই জগতের নিয়ম আলাদা থাকবেই — তাই ভালো ORM ব্যবহারকারী দুটি ভাষাই জানেন, আর জানেন কোথায় অনুবাদে কিছু হারায়।
প্র ০২Martin Fowler-এর বইয়ের “Active Record” আর “Data Mapper” — দুটি নকশার পার্থক্য কী, আর Django ও SQLAlchemy কোন পথে গেছে?
২০০২ সালে Martin Fowler প্রকাশ করেন Patterns of Enterprise Application Architecture (সংক্ষেপে PoEAA). বইটিতে ব্যবসায়িক সফটওয়্যারে বারবার দেখা সমাধানগুলোকে নাম দিয়ে design pattern হিসেবে সাজানো হয়েছে, আর ORM-এর প্রায় পুরো শব্দভান্ডার এখান থেকে এসেছে: Unit of Work, Identity Map, Lazy Load — এই পর্বে যা দেখেছেন — আর ডেটাবেসের সাথে object জোড়ার দুটি প্রধান নকশা।
Active Record-এ প্রতিটি object একটি সারিকে মোড়ায়, আর নিজেই নিজেকে জমা রাখতে জানে: customer.save(), Customer.find(7). ডেটা, ব্যবসার যুক্তি আর ডেটাবেসে পড়া-লেখা — সব এক class-এ। সরল, শেখা সহজ, আর যখন একটি টেবিল আর একটি class প্রায় হুবহু মেলে, তখন চমৎকার। Ruby on Rails (২০০৪)-এর ORM-এর নামই ActiveRecord, আর Django-র ORM-ও এই ধাঁচের — customer.save(), Customer.objects.get(id=7).
Data Mapper-এ object ডেটাবেসের কথা জানেই না। একটি আলাদা স্তর — mapper — object আর টেবিলের মাঝে আনা-নেওয়া করে। Object-গুলো সাধারণ class থাকে, যাদের নকশা ব্যবসার প্রয়োজন মেনে টেবিলের থেকে আলাদা হতে পারে। SQLAlchemy-র ORM নিজেকে Data Mapper হিসেবেই বর্ণনা করে: Customer-এ কোনো save() নেই; জমা রাখার দায়িত্ব Session-এর (যা Unit of Work আর Identity Map), আর mapping আলাদা স্তরে। Java-র Hibernate আর JPA-ও এই ঘরানার।
কোনটি ভালো? Active Record দ্রুত শুরু আর ছোট-মাঝারি CRUD অ্যাপে খুব কার্যকর; সমস্যা দেখা দেয় যখন ব্যবসার নিয়ম জটিল, আর ডেটাবেসের আকার ও object-এর আকার আলাদা হতে থাকে — তখন একটি class দুই প্রভুর সেবা করে। Data Mapper শুরুতে বেশি ধারণা চায় (Session, flush, expire), কিন্তু বড় সিস্টেমে domain model-কে ডেটাবেসের খুঁটিনাটি থেকে আলাদা রাখে, আর টেস্ট করা সহজ হয়। বাস্তবে সীমারেখা ঝাপসা: SQLAlchemy-র declarative model দেখতে Active Record-এর মতো সহজ, আর Django-তেও repository-ধাঁচে কোড সাজানো যায়।
মূল উপলব্ধি: প্রশ্নটি হলো “ডেটাবেসে পড়া-লেখার দায়িত্ব কার?” — object-এর নিজের, নাকি আলাদা একটি স্তরের। নামগুলো জানলে যেকোনো ভাষার নতুন ORM দেখেই বুঝবেন সেটি কোন দর্শনে চলে, আর কোথায় তার শক্তি ও দুর্বলতা।
প্র ০৩SQLAlchemy ২০০৬ সালে জন্ম নিয়ে ২০২৩ সালে 2.0-এ এসে নিজের ধরন অনেকটা বদলে ফেলল। একটি পরিণত লাইব্রেরি কেন এত বড় পরিবর্তনের ঝুঁকি নিল?
SQLAlchemy-র প্রথম প্রকাশ ২০০৬ সালে, Mike Bayer-এর হাতে। তাঁর দর্শন শুরু থেকেই ছিল অন্য অনেক ORM-এর থেকে আলাদা: SQL লুকানো নয়, বরং SQL-কে Python-এ পূর্ণ মর্যাদায় প্রকাশ করা। SQLAlchemy-র নিজের পরিচিতিতে এমন ভাবনা আছে যে ডেটার পরিমাণ আর গতি যত গুরুত্বপূর্ণ হয়, ডেটাবেস তত কম “object-এর সংগ্রহ”-এর মতো আচরণ করে; আর abstraction যত বাড়ে, object-এর সংগ্রহ তত কম টেবিলের মতো আচরণ করে — তাই দুটিকে জোর করে এক না বানিয়ে দুটি স্তর (Core আর ORM) দেওয়া হয়েছে। Fowler-এর Unit of Work আর Identity Map সরাসরি এর নকশায় আছে। Bayer পরে migration-এর জন্য Alembic-ও লেখেন।
প্রায় দুই দশকে লাইব্রেরিটি বিপুল ব্যবহার পেল, কিন্তু সাথে জমল ইতিহাসের বোঝা। ORM-এ প্রশ্ন করার পথ ছিল session.query(...), আর Core-এ select(...) — একই কাজের দুটি আলাদা ভাষা। “autocommit” ধাঁচে transaction কখন শুরু আর শেষ, তা অস্পষ্ট থাকত। আর Python-এ type hint (পর্ব ৪০) সাধারণ হওয়ার পর পুরোনো Column(Integer) ধাঁচের model থেকে mypy বা editor বুঝত না customer.name-এর type কী।
সমাধানটি ছিল সাবধানী আর ধীর। ২০২১ সালে এল 1.4 — একটি সেতু-সংস্করণ, যেখানে নতুন ধরন (future=True) চালু করা যেত, আর পুরোনো ধরনে deprecation সতর্কবার্তা আসত, যাতে প্রজেক্টগুলো ধাপে ধাপে বদলাতে পারে। ২০২৩ সালের জানুয়ারিতে এল 2.0: ORM আর Core-এ একই select(), স্পষ্ট transaction (commit() না করলে কিছু টেকে না), Mapped[...] আর mapped_column দিয়ে type hint-ভিত্তিক model, async-এর পূর্ণ সমর্থন, আর অনেক ডেটাবেসে bulk INSERT অনেক দ্রুত করার নতুন ব্যবস্থা (echo=True-এর আসল লগে যার নাম দেখা যায় “insertmanyvalues”). session.query() বাতিল হয়নি — “legacy” হিসেবে এখনো চলে, যাতে লাখো লাইন পুরোনো কোড ভেঙে না যায়। এরপর 2.1 সংস্করণও এসেছে, একই ধরন বজায় রেখে।
এই অভিজ্ঞতা Python-এর নিজের Python 2 থেকে 3-এ যাওয়ার কঠিন পথের কথা মনে করায়; SQLAlchemy সেখান থেকে শিখে সেতু-সংস্করণ আর দীর্ঘ সতর্কবার্তার পথ নিয়েছিল। আর এই ইতিহাসই ব্যাখ্যা করে, কেন AI আজও প্রায়ই 1.x-এর কোড লেখে: ইন্টারনেটে সেই কোডের পরিমাণ এখনো বিশাল।
মূল উপলব্ধি: একটি পরিণত লাইব্রেরিও ভাষার বদলের (type hint, async) সাথে তাল মেলাতে নিজেকে নতুন করে গড়ে — তবে সেতু বানিয়ে, ব্যবহারকারীকে ফেলে রেখে নয়। কোড পড়ার সময় তাই প্রথম প্রশ্ন: “এটি কোন যুগের ধরনে লেখা?”
প্র ০৪২০০৬ সালে একজন লেখক ORM-কে বলেছিলেন “কম্পিউটার বিজ্ঞানের ভিয়েতনাম”। কথাটির মানে কী, আর আজ এটি কতটা সত্য?
২০০৬ সালে .NET আর Java জগতের পরিচিত লেখক Ted Neward একটি দীর্ঘ ব্লগ-প্রবন্ধ লেখেন, যার শিরোনামের মূল কথা: ORM হলো “the Vietnam of Computer Science”. তুলনাটি ইচ্ছা করেই তীব্র। তাঁর যুক্তির সারকথা, নিজের ভাষায়: ভিয়েতনাম যুদ্ধে যুক্তরাষ্ট্র শুরুতে ছোট, স্পষ্ট লক্ষ্য নিয়ে নেমেছিল, প্রাথমিক সাফল্যও পেয়েছিল; তারপর প্রতিটি নতুন সমস্যা মেটাতে আরও বেশি সম্পদ ঢালতে হলো, লক্ষ্য ঝাপসা হলো, আর বের হওয়ার কোনো পরিষ্কার পথ রইল না। ORM-ও তেমন: শুরুতে সহজ CRUD-এ দারুণ লাগে, কিন্তু তারপর আসে inheritance-এর mapping, জটিল প্রশ্ন, পারফরম্যান্স, cache, transaction-এর কোণার ঘটনা — আর প্রতিটির সমাধানে আরও জটিলতা। ততদিনে প্রজেক্ট ORM-এর ওপর এত নির্ভরশীল যে ফেরার পথ নেই।
প্রবন্ধটির মূল দাবি ছিল, অংশ ১-এর impedance mismatch-এর কোনো নিখুঁত সমাধান নেই; তাই একটি নিখুঁত, সব-লুকানো ORM-এর স্বপ্ন একটি ফাঁদ। Neward কয়েকটি বিকল্পের কথাও বলেছিলেন — ORM পুরো বাদ দেওয়া, object database, শুধু SQL-এর ভাষায় কাজ করা, বা মেনে নিয়ে হাতে mapping. Jeff Atwood-এর মতো জনপ্রিয় ব্লগাররা তখন এটি নিয়ে লেখায় তর্কটি অনেক দূর ছড়ায়।
উল্টো দিকের সবচেয়ে পরিচিত উত্তর Martin Fowler-এর ২০১২ সালের লেখা “OrmHate”. তাঁর বক্তব্যের সারকথা: সমস্যাটি সত্যিই কঠিন, তাই কোনো সমাধানই নিখুঁত হবে না; কিন্তু ORM সাধারণ কাজের বড় অংশ — একঘেয়ে mapping কোড — ভালোভাবে সারে, আর হতাশার অনেকটা আসে অতিরিক্ত প্রত্যাশা থেকে, যেন ORM থাকলে ডেটাবেস না বুঝলেও চলবে। হাতে mapping লিখতে গেলে শেষ পর্যন্ত একটি অর্ধেক-বানানো নিজস্ব ORM-ই তৈরি হয়।
আজকের বাস্তবতা দুই মতের মাঝামাঝি। আধুনিক ORM-গুলো “সব লুকাও” স্বপ্ন অনেকটাই ছেড়েছে: SQLAlchemy খোলাখুলি SQL দেখায় (echo), Core আর text()-এ নামার পথ রাখে, loading strategy আপনাকেই বাছতে দেয়। অর্থাৎ Neward যে “বের হওয়ার পথ” পাননি, আধুনিক নকশা সেটি শুরু থেকেই রাখে। তবে তাঁর সতর্কবার্তা এখনো খাটে: যে দল SQL না শিখে ORM-এর ওপর সব ছেড়ে দেয়, তারা ঠিক সেই জলাভূমিতেই আটকায় — N+1, ধীর রিপোর্ট, রহস্যময় লক।
মূল উপলব্ধি: ORM-কে একটি সুবিধাজনক সরঞ্জাম হিসেবে নিন, ডেটাবেসের বিকল্প হিসেবে নয়। শুরুতেই ঠিক করে রাখুন কোন কাজে ORM, আর কোন কাজে সরাসরি SQL — তাহলে “বের হওয়ার পথ” সবসময় খোলা থাকে।
প্র ০৫কখন raw SQL ORM-কে হারিয়ে দেয়? Stack Overflow-এর মতো বড় সাইট কেন নিজেদের জন্য একটি “micro-ORM” বানিয়েছিল?
ORM-এর শক্তি সাধারণ কাজে: একটি object আনা, বদলানো, জমা রাখা, সম্পর্ক ধরে হাঁটা। দুর্বলতা দেখা দেয় যখন প্রশ্নটি নিজেই জটিল, বা পরিমাণ বিশাল। কয়েকটি পরিচিত ক্ষেত্র: রিপোর্ট আর বিশ্লেষণ — জেলাভিত্তিক মাসিক বিক্রি, আগের মাসের সাথে তুলনা, শীর্ষ দশ ক্রেতা; এখানে window function (ROW_NUMBER() OVER (...)), CTE (WITH ...) আর অনেক টেবিলের JOIN লাগে। SQLAlchemy Core এগুলোর বেশিরভাগই প্রকাশ করতে পারে, কিন্তু অনেক সময় সরাসরি SQL পড়া আর ডেটাবেস বিশেষজ্ঞের সাথে আলোচনা করা সহজ। বিপুল পরিমাণ — দশ লাখ সারি আমদানি বা বদল; প্রতিটির জন্য Python object বানানো, পরিবর্তন খেয়াল রাখা অপ্রয়োজনীয় খরচ। ডেটাবেস-নির্দিষ্ট ক্ষমতা — PostgreSQL-এর JSONB, full-text search, বিশেষ index. সূক্ষ্ম পারফরম্যান্স — যখন একটি query-র query plan (EXPLAIN) দেখে প্রতিটি অংশ হাতে সাজাতে হয়।
Stack Overflow-এর ঘটনাটি এই আলোচনায় প্রায়ই আসে। সাইটটি .NET-এ লেখা, আর শুরুতে Microsoft-এর LINQ to SQL নামের ORM ব্যবহার করত। ট্রাফিক বাড়ার সাথে দলটি দেখল, অনেক পেজের সময়ের বড় অংশ যাচ্ছে ORM-এর নিজের কাজে — query বানানো আর সারি থেকে object বানানো — ডেটাবেসের কাজে নয়। ২০১১ সালের দিকে Sam Saffron, Marc Gravell-সহ তাদের দল বানায় Dapper: একটি “micro-ORM”, যা শুধু একটি কাজ করে — আপনার লেখা SQL চালিয়ে ফলের সারিগুলো দ্রুত object-এ বসানো। Relationship, change tracking, unit of work — কিছুই নেই। SQL লেখেন আপনি, আর এর বদলে পান প্রায় হাতে লেখা কোডের গতি। Dapper open source হিসেবে প্রকাশিত হয়ে .NET জগতে খুব জনপ্রিয় হয়।
শিক্ষাটি “ORM খারাপ” নয়, বরং “সব কাজে এক সরঞ্জাম নয়”। Stack Overflow-এর মতো জায়গাতেও লেখার সাধারণ কাজে পূর্ণ ORM আর পড়ার গরম পথে micro-ORM পাশাপাশি চলতে পারে। Python-এ SQLAlchemy এই মিশ্রণ এক লাইব্রেরিতেই দেয়: সাধারণ CRUD-এ ORM, রিপোর্টে Core-এর select(), বিশেষ জায়গায় text() — সব একই engine আর transaction-এ। আর সিদ্ধান্ত অনুমানে নয়, মাপে: পর্ব ৪৬-এর মতো profiler দিয়ে দেখুন সময় যাচ্ছে কোথায় — ডেটাবেসে, নেটওয়ার্কে, নাকি object বানানোয়।
মূল উপলব্ধি: ORM সাধারণ পথের জন্য, raw SQL বিশেষ পথের জন্য। দুটি একসাথে চালানোর দক্ষতা — আর মেপে ঠিক করা কোথায় কোনটি — একজন পরিণত ব্যাকএন্ড প্রোগ্রামারের চিহ্ন।
অনুশীলন
বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — অনুমান করা, পড়া আর মিলিয়ে নেওয়াই আসল কাজ।
-
Query গুনুন: ডেটাবেসে ৫০ জন ক্রেতা, প্রত্যেকের কয়েকটি অর্ডার। নতুন Session-এ প্রতিটির জন্য মোট কয়টি SQL query যাবে? (ক)
for c in session.scalars(select(Customer)): print(c.name)(খ) একই লুপেprint(c.name, len(c.orders))(গ) (খ), কিন্তু.options(selectinload(Customer.orders))সহ (ঘ)session.get(Customer, 7)দুবার পরপর।(ক) ১টি — শুধু ক্রেতাদের SELECT;
nameসাধারণ কলাম, তাই আর কিছু লাগে না। (খ) ৫১টি — ১টি ক্রেতাদের জন্য, আর প্রত্যেকেরorderslazy load-এ ১টি করে, ৫০টি। এটিই N+1. (গ) ২টি — ক্রেতাদের SELECT আর একটিWHERE customer_id IN (...). (খুব বড় তালিকায় SQLAlchemy IN-এর ভেতরের id-গুলো কয়েক ভাগে ভাগ করে পাঠায়, তাই হাজার হাজার সারিতে ২-এর একটু বেশি হতে পারে, কিন্তু N-এর সমান কখনো নয়।) (ঘ) ১টি — প্রথমgetSELECT পাঠায়; দ্বিতীয়টি identity map-এ পেয়ে যায়, ডেটাবেসে যায়ই না। -
Commit কোথায় গেল? একজন সহকর্মী লিখেছেন:
def add_customer(name, district):→session = Session(engine),session.add(Customer(name=name, district=district)),return "saved". ফাংশন"saved"ফেরত দেয়, কোনো error নেই, কিন্তু ডেটাবেসে ক্রেতা আসে না। কেন? অন্তত তিনটি সমস্যা বলুন, আর ঠিক সংস্করণটি বর্ণনা করুন।(১)
commit()নেই —add()শুধু Session-এর খাতায় লেখে; commit ছাড়া কোনো INSERT স্থায়ী হয় না, আর Session বাতিল হলে বদলটি নিঃশব্দে হারায়। (২) Session কখনো বন্ধ হয় না —withনেই, তাই সংযোগ pool-এ ঠিকমতো ফেরত যায় না; অনেকবার ডাকলে সংযোগ ফুরিয়ে যেতে পারে। (৩) exception হলে rollback-এর কোনো ব্যবস্থা নেই। (৪) “saved” মিথ্যা বলছে — সফলতার বার্তা দেওয়া উচিত আসলে সফল হওয়ার পর। ঠিক সংস্করণ:with Session(engine) as session, session.begin():ব্লকের ভেতরেsession.add(...); block সফলভাবে শেষ হলে নিজেই commit, exception-এ rollback, আর শেষে বন্ধ। নতুন ক্রেতার id ফেরত দিতে চাইলে ব্লকের ভেতরেsession.flush()করেcustomer.idপড়ুন। -
AI-এর কোডে বাগ খুঁজুন: একটি AI এই অনুসন্ধান ফাংশন দিয়েছে। অন্তত চারটি সমস্যা খুঁজুন:
session = Session(engine)module-এর শুরুতে, তারপরdef search(name):→rows = session.execute(text(f"SELECT * FROM customers WHERE name LIKE '%{name}%'")),result = [],for r in rows: c = session.query(Customer).get(r.id),result.append({"name": c.name, "orders": len(c.orders)}),return result.(১) SQL injection —
text()-এর ভেতরে f-string; নাম হিসেবে' OR '1'='1দিলে সব সারি, আরও খারাপ input-এ আরও খারাপ ফল। ঠিক:text("... WHERE name LIKE :p")আর{"p": f"%{name}%"}— এখানে f-string শুধু মানটি বানাচ্ছে, SQL নয়। আরও ভালো:select(Customer).where(Customer.name.contains(name)). (২) global session — সব request একই Session ভাগ করে; thread-নিরাপদ নয়, identity map পুরোনো ডেটা ধরে রাখে, আর কখনো বন্ধ হয় না। (৩) 1.x-এর legacy API —session.query(Customer).get(...); 2.0-এsession.get(Customer, id). (৪) দ্বিগুণ কাজ আর N+1 — raw SQL-এ সারি এনে আবার প্রতিটির জন্য object আনা, তারপর প্রতিটিরorders-এর জন্য আরেকটি query: ২N+১. ঠিক: একটিselect(Customer).where(...).options(selectinload(Customer.orders)), বা শুধু সংখ্যা লাগলেfunc.countআরgroup_by-এর একটি query. (৫)SELECT *— অপ্রয়োজনীয় সব কলাম আনে, আর টেবিলে কলাম যোগ হলে আচরণ বদলায়। -
একটি রিপোর্ট লিখুন (একমাত্র কোড লেখার কাজ): দোকানের model ব্যবহার করে একটি ফাংশন
top_customers(session, limit)লিখুন, যা মোট কেনাকাটার ভিত্তিতে শীর্ষ ক্রেতাদের(নাম, মোট টাকা)তালিকা দেয় — বড় থেকে ছোট — একটি মাত্র SQL query-তে। যাঁদের কোনো অর্ডার নেই, তাঁরা তালিকায় আসবেন না।একটি সম্ভাব্য সমাধান:
from sqlalchemy import func, select def top_customers(session, limit: int = 3) -> list[tuple[str, int]]: total = func.sum(Order.amount).label("total") stmt = (select(Customer.name, total) .join(Customer.orders) .group_by(Customer.id) .order_by(total.desc()) .limit(limit)) return [(name, t) for name, t in session.execute(stmt)] # অংশ ৪-এর চারজন ক্রেতা নিয়ে প্রত্যাশিত আউটপুট: # [('রহিম', 1650), ('সালমা', 1150), ('করিম', 800)].label("total")দিয়ে একবার নাম দিলে একই expressionorder_by-এ আবার ব্যবহার করা যায়।group_by(Customer.id)— নাম নয়, id দিয়ে দল, কারণ দুজন ক্রেতার নাম এক হতে পারে। সাধারণjoinবলে অর্ডারহীন জয়া স্বাভাবিকভাবেই বাদ। পুরো হিসাব ডেটাবেসে, একটি query-তে — সব অর্ডার Python-এ এনে লুপে যোগ করার চেয়ে অনেক দ্রুত।echo=Trueদিয়ে চালিয়ে নিশ্চিত হোন, সত্যিই একটি মাত্র SELECT যাচ্ছে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমSQLAlchemy-তে নতুন একজন ক্রেতা আর তাঁর একটি অর্ডার ডেটাবেসে জমা হওয়ার ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- 🧱
Customer(...)object তৈরি হয় — এখনো transient, কোনো SQL নেই - 📝
session.add()— object pending, Session-এর খাতায় - 🔁 commit শুরু হলে flush: ক্রেতার INSERT, ডেটাবেস id দেয়
- 🔗 সেই id
customer_id-এ বসিয়ে অর্ডারের INSERT - ✅ COMMIT — transaction স্থায়ী, object-গুলো expired
SQLAlchemy-তে ৫০ জন ক্রেতার তালিকায় লুপ চালিয়ে প্রত্যেকের customer.orders পড়া হলো, কোনো loader option ছাড়া। মোট কয়টি SQL query যাবে?
- ১টি
- ৫১টি
- ২টি
- ১০০টি
SQLAlchemy-র Session-এ session.add(obj) ডাকার সঙ্গে সঙ্গে কী ঘটে?
- ডেটাবেসে সঙ্গে সঙ্গে INSERT চলে আর স্থায়ী হয়
- INSERT আর COMMIT দুটোই হয়ে যায়
- object pending হয়; INSERT যাবে পরে, flush বা commit-এর সময়
- object-এর একটি কপি বানিয়ে Session-এ রাখা হয়
একটি SQLAlchemy Session-এ a = session.get(Customer, 1), তারপর নাম দিয়ে query করে একই সারি b-তে আনা হলো। a is b কী দেবে, আর কেন?
- True — identity map একই সারির জন্য একই object ফেরত দেয়
- False — দুটি ভিন্ন query দুটি আলাদা object বানায়
- exception — একই সারি দুবার আনা নিষেধ
- অনির্দিষ্ট — ডেটাবেসের ওপর নির্ভর করে
SQLAlchemy-তে ব্যবহারকারীর দেওয়া name দিয়ে raw SQL চালানোর সবচেয়ে নিরাপদ উপায় কোনটি?
- text(f"SELECT * FROM customers WHERE name = '{name}'")
- text("SELECT * FROM customers WHERE name = '" + name + "'")
- name থেকে উদ্ধৃতিচিহ্ন মুছে f-string-এ বসানো
- text("... WHERE name = :name") আর আলাদা dict {"name": name}
চলমান একটি অ্যাপের ডেটাবেসে, যেখানে আগে থেকেই ডেটা আছে, customers টেবিলে নতুন phone কলাম যোগ করার ঠিক উপায় কোনটি?
- model-এ কলাম যোগ করে আবার Base.metadata.create_all() চালানো
- Alembic দিয়ে একটি migration বানিয়ে upgrade চালানো
- drop_all() করে আবার create_all() চালানো
- শুধু model-এ কলাম যোগ করা; SQLAlchemy নিজেই টেবিল বদলে নেবে
SQLAlchemy-তে session.flush() আর session.commit()-এর পার্থক্য কোনটি?
- flush SQL পাঠায় কিন্তু transaction খোলা থাকে (rollback সম্ভব); commit transaction স্থায়ী করে
- দুটি একই কাজ, শুধু নাম ভিন্ন
- flush স্থায়ী করে, commit শুধু SQL পাঠায়
- flush শুধু লগ ফাইলে লেখে, ডেটাবেসে কিছু পাঠায় না
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- ORM = দোভাষী: class ↔ টেবিল, object ↔ সারি, reference ↔ foreign key. SQL লেখা স্বয়ংক্রিয় হয়, কিন্তু SQL বোঝার দায় থেকে যায় —
echo=Trueদিয়ে দেখুন কী যাচ্ছে। - SQLAlchemy 2.0-এর ধরন: engine একবার (
create_engine(URL)), modelDeclarativeBase+Mapped[...]+mapped_column, প্রশ্নselect()+session.scalars()/execute();session.query()legacy. - Session = unit of work + identity map:
add()জমায়,commit()flush করে একটি transaction-এ লেখে,rollback()বাতিল করে; একই সারির জন্য একই object. Session অল্পস্থায়ী —withদিয়ে, প্রতিটি কাজে/request-এ একটি। - Relationship আর N+1:
relationship(back_populates=...)দুই দিক জোড়ে; স্বাভাবিকভাবে lazy, তাই লুপে N+1. collection-এselectinload, many-to-one-এjoinedload; cascade ব্যবসার সিদ্ধান্ত। - আসল প্রজেক্ট: হিসাব ডেটাবেসে (
func,group_by), raw SQL শুধুtext()+:param, নকশা বদল Alembic-এ, FastAPI-তেyieldকরা Session, টেস্টে in-memory SQLite; ভারী রিপোর্টে ORM ছেড়ে Core বা SQL.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫৩ · অটোমেশন আর CLI টুল — argparse, ফাইল কাজপরবর্তী পর্বপ্রতিদিনের কাজ স্বয়ংক্রিয় করা আর নিজের কমান্ড-লাইন টুল বানানো।
- Python প্রোগ্রামিং · কোর্স হোম৫৯টি পর্বপুরো কোর্সের রোডম্যাপ — ৮টি মডিউল, কোন পর্ব কখন।
- পর্ব ২৮ · ডেটাবেস — SQLite, SQL আর transactionআবার দেখুনORM-এর নিচের ভাষা — SQL, JOIN, index আর transaction.
- N+1 query আর eager loadingগভীরে যানফ্রেমওয়ার্ক-নিরপেক্ষভাবে N+1, আর ORM প্যাটার্ন ও migration-এর পাশের পর্বগুলো।
- Java · Spring Boot দিয়ে REST APIতুলনা করুনএকই ধারণা Java-র JPA/Hibernate-এ — entity, repository আর transaction.