এই পর্বে যা শিখবেন
- Prisma-য় one-to-one, one-to-many, many-to-many আর self relation লেখা
- Implicit আর explicit join table-এর মধ্যে কারণ দেখিয়ে বেছে নেওয়া
- Cascade, Restrict আর SetNull PostgreSQL-এ আসলে কী করে তা জানা
- Foreign key-র index যাচাই করা, আর nested write দিয়ে একটি অর্ডার লেখা
- দামের snapshot, soft delete, polymorphic সম্পর্ক আর denormalization-এর দাম বোঝা
- পুরো “বাজারঘর”-এর schema, migration আর seed সত্যিই চালানো
১ · সম্পর্ক মানে আসলে একটি কলাম
মুদি দোকানের খাতার কথা ভাবুন। দোকানি প্রতিটি বাকির পাশে ক্রেতার পুরো ঠিকানা লেখেন না, লেখেন খাতার পাতা নম্বর। সেই নম্বর ধরে ক্রেতার পাতায় গেলেই সব পাওয়া যায়। Database-এর সম্পর্ক ঠিক এমন। Order table-এর userId কলামে থাকে User-এর primary key. এই কলামই foreign key.
ER মডেল, cardinality আর normalization-এর তত্ত্ব DBMS কোর্সে আছে। পরিচিত না হলে ER থেকে table আর JOIN পর্ব দুটি একবার দেখে নিন। এখানে সেগুলো আবার শেখানো হবে না। এখানে দেখব Prisma সেগুলো কীভাবে লেখে, আর PostgreSQL-এ সত্যিই কী ঘটে।
Prisma schema-য় একটি সম্পর্কের দুটি দিক লেখা হয়। Foreign key যে দিকে থাকে, সেখানে @relation(fields, references) বসে। অন্য দিকে শুধু একটি list বা optional field থাকে, যা database-এ কোনো কলাম নয়, শুধু Prisma Client-এর সুবিধা।
model User {
id Int @id @default(autoincrement())
addresses Address[] // one-to-many, কলাম নয়
cart Cart? // one-to-one, কলাম নয়
}
model Address {
id Int @id @default(autoincrement())
userId Int // আসল foreign key কলাম
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Cart {
id Int @id @default(autoincrement())
userId Int @unique // @unique-ই এটিকে one-to-one করে
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}One-to-many আর one-to-one-এর পার্থক্য একটিমাত্র শব্দে: @unique. একজন ব্যবহারকারীর অনেক ঠিকানা থাকতে পারে, কিন্তু কার্ট একটিই। Foreign key সবসময় “অনেক” দিকে বসে। One-to-one-এ যে সারি অন্যটি ছাড়া বাঁচতে পারে না, foreign key তার দিকে রাখুন। কার্ট ব্যবহারকারী ছাড়া অর্থহীন, তাই userId থাকে Cart-এ।
Migration চালালে এই লেখা থেকে যা তৈরি হয়, তা সাধারণ SQL. নিচের লাইনগুলো Prisma-র বানানো ফাইল থেকে সরাসরি নেওয়া:
CREATE UNIQUE INDEX "Cart_userId_key" ON "Cart"("userId");
ALTER TABLE "Cart" ADD CONSTRAINT "Cart_userId_fkey" FOREIGN KEY ("userId")
REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
সংস্করণ: এই পর্বের সব চালানো হয়েছে Prisma 7.10.1, PostgreSQL 18.6 আর Node.js 24.15 দিয়ে। লেখার সময়ে npm-এর latest ট্যাগে ছিল 8.0.0-rc.29, একটি release candidate. তাই stable 7.10.1 নির্দিষ্ট করে ইনস্টল করা হয়েছে। সাবধান: prisma.io-র সাধারণ docs পাতা এখন Prisma 8-এর API দেখায়। Version 7-এর docs আছে /docs/orm/v7/ পথে। PostgreSQL চালানো হয়েছে npm-এর embedded-postgres প্যাকেজ দিয়ে, Docker ছাড়াই। প্রজেক্টের গঠন আর config ফাইল আছে পর্ব ৩৯-এ। সেখানে prisma init নাম দিয়েছে prisma7.config.ts, আর এই পর্বের প্রজেক্টে ফাইলটি হাতে লেখা প্রচলিত নাম prisma.config.ts. 7.10.1-এর CLI দুটিই পড়ে, তবে আগে prisma7.config.* খোঁজে।
২ · Many-to-many — implicit না explicit join table
একটি অর্ডারে অনেক পণ্য, একটি পণ্য অনেক অর্ডারে। এই many-to-many সম্পর্ক কোনো একটি table-এর কলামে রাখা যায় না। মাঝখানে লাগে একটি join table, ঠিক যেন বিয়ের রেজিস্টার খাতা, যার প্রতিটি লাইন দুজন মানুষকে জোড়ে।
Prisma দুভাবে এটি লিখতে দেয়। Implicit: দুই দিকে শুধু list লিখলেই Prisma নিজে table বানায়। Explicit: আপনি join table-কে সাধারণ model হিসেবে লেখেন। বাজারঘরে দুটোই আছে:
// implicit: পণ্যের tag, লিংকে কোনো বাড়তি তথ্য নেই
model Tag {
id Int @id @default(autoincrement())
name String @unique
products Product[]
}
// explicit: লিংকটিই একটি আসল জিনিস
model OrderItem {
id Int @id @default(autoincrement())
orderId Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
productName String // snapshot
unitPricePaisa Int // snapshot
quantity Int
@@unique([orderId, productId])
@@index([productId])
}Implicit-এর table Prisma-র নিজের নিয়মে তৈরি হয়। সত্যিকারের run-এ pg_indexes থেকে পাওয়া ফল (আউটপুট ছাঁটা):
"_ProductToTag" (A, B) -- A = Product.id, B = Tag.id
_ProductToTag_AB_pkey : UNIQUE btree ("A", "B")
_ProductToTag_B_index : btree ("B")
rows: { A: 1, B: 1 }, { A: 1, B: 2 }, { A: 2, B: 1 }
কলামের নাম A আর B, কারণ এখানে মানুষের কিছু লেখার জায়গা নেই। নিচের ধাপে দেখুন কোন প্রশ্ন দিয়ে সিদ্ধান্ত নেবেন।
- নিজের ডেটা?
quantityলিংকে তথ্য আছে কি - নিয়ম?
onDeleteলিংকের আচরণ - কেউ নির্দেশ করবে?
idলিংকের পরিচয় - রায়
modelকোনটি বেছে নেবেন
Product — Tag : না, শুধু জোড়া Order — Product : হ্যাঁ: quantity, unitPricePaisa Cart — Product : হ্যাঁ: quantity
Implicit : onDelete লেখা যায় না, দুই দিকেই Cascade OrderItem.order : Cascade OrderItem.product : Restrict
ফেরত (refund) : কোন অর্ডারের কোন লাইন? OrderItem.id : আছে, নির্দেশ করা যায় "_ProductToTag" : A, B ছাড়া কিছু নেই
Product — Tag : implicit চলবে Order — Product : explicit: OrderItem Cart — Product : explicit: CartItem
- লিংকের নিজের ডেটা আছে কি? এটিই সবচেয়ে বড় প্রশ্ন। “তিন বোতল তেল, প্রতিটি ১৮৫ টাকা” তথ্যটি অর্ডারেরও নয়, পণ্যেরও নয়। এটি দুটির সম্পর্কের তথ্য। Implicit table-এ এর কোনো জায়গা নেই।
- দুই দিকে আলাদা নিয়ম লাগবে কি? Prisma-র docs অনুযায়ী implicit সম্পর্কে
onDeleteলেখা যায় না। সত্যিকারের migration-এ দুই foreign key-ইON DELETE CASCADEহয়েছে। অর্ডার মুছলে লাইন মুছবে, কিন্তু পণ্য মুছলে অর্ডারের লাইন মোছা চলবে না। এমন অসম নিয়ম চাইলে explicit লাগবে। - লিংককে কেউ নির্দেশ করবে কি? পরে ফেরতের হিসাব রাখতে হলে তাকে জানতে হবে কোন লাইনের ফেরত। Explicit model-এ লাইনের নিজের
idথাকে। এখানে পরে কলাম যোগ করাও সহজ। - রায়। সবগুলো উত্তর “না” হলে implicit সংক্ষিপ্ত আর ঠিক কাজ করে। Prisma-র docs-ও বলে, বাড়তি তথ্য না থাকলে implicit ব্যবহার করতে। একটি “হ্যাঁ” থাকলেই explicit. পরে বদলাতে হলে পুরো table সরাতে হয়, তাই ব্যবসায়িক ডেটার লিংকে শুরুতেই explicit নেওয়া নিরাপদ।
এবার join table পেরিয়ে পড়ে দেখুন। Live ডেটাবেসটি SQLite (sql.js), আপনার ব্রাউজারেই চলে। আসল বাজারঘর PostgreSQL-এ চলে, তবে এই query-গুলো দুই জায়গাতেই একই। JOIN লিখে ফল বদলে দেখতে পারেন:
-- one-to-many: কার কতগুলো অর্ডার
SELECT u.name, COUNT(o.id) AS orders
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id ORDER BY u.id;
-- many-to-many: join table পেরিয়ে অর্ডার থেকে পণ্যে
SELECT o.id AS order_id, p.name, oi.quantity,
oi.unit_price_paisa * oi.quantity AS line_paisa
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
ORDER BY o.id, p.id;
-- উল্টো দিক: পণ্য থেকে অর্ডারে
SELECT p.name, COUNT(*) AS in_orders, SUM(oi.quantity) AS units
FROM products p
JOIN order_items oi ON oi.product_id = p.id
GROUP BY p.id ORDER BY p.id;প্রথম ফলে সুমির অর্ডার 0, কারণ LEFT JOIN মিল না পাওয়া সারিও রাখে। দ্বিতীয় ফলে চারটি সারি, যার line_paisa হলো 42000, 37000, 18500 আর 14000. প্রথম অর্ডারের যোগফল 42000 + 37000 = 79000, অর্থাৎ ৳৭৯০. তৃতীয় ফলে সয়াবিন তেল 2টি অর্ডারে মোট 3 বোতল। একই join table দুই দিক থেকেই পড়া যায়, এটিই many-to-many-এর মূল সুবিধা।
৩ · Self relation — category-র গাছ
মুদি → চাল → মিনিকেট। প্রতিটি category-র একটি parent, আর সেই parent-ও একটি category. একটি table নিজেকেই নির্দেশ করলে তাকে self relation বলে। পরিবারের গুষ্টিনামায় প্রত্যেকের পাশে শুধু বাবার নাম লেখা থাকলেও পুরো বংশ বের করা যায়। এই সরল পদ্ধতির নাম adjacency list.
model Category {
id Int @id @default(autoincrement())
name String @unique
parentId Int? // মূল category-র parent নেই
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Category[] @relation("CategoryTree")
products Product[]
@@index([parentId])
}এখানে সম্পর্কের একটি নাম ("CategoryTree") দিতে হয়, কারণ একই model-এ দুটি দিক আছে, আর Prisma-কে বলতে হয় এরা জোড়া। include দিয়ে পড়লে গভীরতা আগে থেকে ঠিক করে লিখতে হয়, দুই স্তর চাইলে দুবার children. যত গভীর গাছই হোক, এক query-তে পুরোটা পেতে লাগে recursive CTE. এর তত্ত্ব আছে DBMS কোর্সের CTE পর্বে।
-- নিচের দিকে: মুদি-র ভেতরের পুরো গাছ
WITH RECURSIVE tree AS (
SELECT id, name, 0 AS depth, name AS path
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, t.depth + 1, t.path || ' > ' || c.name
FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT depth, path FROM tree ORDER BY path;
-- ওপরের দিকে: মিনিকেট-এর breadcrumb
WITH RECURSIVE up AS (
SELECT id, name, parent_id, 0 AS step
FROM categories WHERE name = 'মিনিকেট'
UNION ALL
SELECT c.id, c.name, c.parent_id, u.step + 1
FROM categories c JOIN up u ON c.id = u.parent_id
)
SELECT step, name FROM up ORDER BY step DESC;প্রথম ফলে মুদি, মুদি > চাল, মুদি > চাল > মিনিকেট আর মুদি > তেল, চারটি সারি। প্রতি ধাপে আগের ধাপের ফল join হয়, নতুন সারি না পেলে থামে। দ্বিতীয় ফল উল্টো পথে হেঁটে মুদি, চাল, মিনিকেট দেয়, যা দিয়ে পণ্যের পাতায় breadcrumb বানানো যায়। Prisma Client-এ এর জন্য আলাদা API নেই, তাই আসল প্রজেক্টে একই SQL $queryRaw দিয়ে parameter-সহ চালানো হয়েছে। সত্যিকারের run-এ PostgreSQL-ও মুদি 0, চাল 1, তেল 1, মিনিকেট 2 গভীরতা দিয়েছে।
৪ · onDelete — parent মুছলে child-এর কী হবে
একজন ব্যবহারকারী account মুছতে চাইলেন। তাঁর কার্ট, ঠিকানা, অর্ডার, রিভিউ কী হবে? এই প্রশ্নের উত্তর কোড নয়, ব্যবসা ঠিক করে। উত্তরটি foreign key-তে লেখা হয় referential action হিসেবে, আর PostgreSQL নিজে তা মানে। আপনার কোডে ভুল থাকলেও এই নিয়ম ভাঙে না।
| Prisma | PostgreSQL-এ | বাজারঘরে | কারণ |
|---|---|---|---|
Cascade | child-ও মোছে | User → Cart, Order → OrderItem | child একা অর্থহীন |
Restrict | মোছা আটকে দেয় | User → Order, Product → OrderItem | হিসাবের ইতিহাস হারানো চলবে না |
SetNull | কলাম NULL করে | Address → Order, User → Review | child থাকবে, সংযোগটি কাটবে |
NoAction | Restrict-এর মতো আটকায়, তবে যাচাই transaction-এর শেষ পর্যন্ত পেছানো যায় (deferrable হলে) | ব্যবহার করিনি | বিশেষ ক্ষেত্রে |
না লিখলে Prisma ডিফল্ট বসায়: বাধ্যতামূলক সম্পর্কে Restrict, optional সম্পর্কে SetNull. উপরের Product.categoryId-তে কিছু লেখা হয়নি, তাই migration-এ এসেছে ON DELETE RESTRICT. নিচে সত্যিকারের run-এর সংখ্যা দিয়ে একটি মোছা দেখুন।
1-- করিম: অর্ডার নেই2Address.userId CASCADE3Cart.userId CASCADE4CartItem.cartId CASCADE5Review.userId SET NULL6-- রহিমা: অর্ডার আছে7Order.userId RESTRICT
| করিমের ঠিকানা | |
| করিমের কার্ট | |
| মোট cart item | |
| মোট review | |
| userId NULL review |
- শুরু। করিমের একটি ঠিকানা, একটি কার্ট আর একটি রিভিউ আছে, কোনো অর্ডার নেই। Cart item মোট ২টি, এর একটি রহিমার। Query:
prisma.user.delete(...). - ঠিকানা Cascade. ঠিকানা ব্যবহারকারীর ব্যক্তিগত তথ্য, ব্যবহারকারী গেলে এটিও যাবে। করিমের ঠিকানা ১ থেকে ০ হলো। এখানে ধাপগুলো বোঝার জন্য আলাদা দেখানো হয়েছে; আসলে সবই একটি DELETE statement-এর ভেতরে ঘটে, আর শুরু ও শেষের গণনাই মাপা হয়েছে।
- কার্ট, তারপর cart item. Cascade শিকলের মতো চলে। User গেলে Cart, Cart গেলে CartItem. মোট cart item ২ থেকে ১ হলো, রহিমারটি থাকল।
- রিভিউ SetNull. রিভিউ অন্য ক্রেতাদের কাজে লাগে, তাই সারি রইল (মোট ২টিই), শুধু
userIdNULL হলো। লেখকের নাম আর জানা যাবে না। - রহিমার অর্ডার Restrict. এবার রহিমাকে মুছতে গেলে PostgreSQL আটকে দিল। Prisma দিল
P2003, ভেতরে SQLSTATE23001. কোনো সারি বদলায়নি। এটি বাগ নয়, এটিই পাহারা।
সত্যিকারের run-এ Prisma-র error-এর ভেতরের অংশ (আউটপুট ছাঁটা):
code: 'P2003', originalCode: '23001',
update or delete on table "User" violates RESTRICT setting of
foreign key constraint "Order_userId_fkey" on table "Order"
একই run-এ Product, Category আর payment-সহ Order মুছতেও একই P2003 এসেছে। শেষেরটি লক্ষ করুন: Order → OrderItem হলো Cascade, কিন্তু Order → Payment হলো Restrict. Payment-সহ অর্ডার তাই মোছা যায় না। Payment ছাড়া অর্ডার মুছলে লাইনগুলো গেছে (৩ থেকে ২)। ঠিকানা মোছার পর অর্ডারের addressId হয়েছে null, কিন্তু কপি করা shipLine “১২ লেক রোড” ঠিকই রয়ে গেছে। এই কপির কারণ ষষ্ঠ অংশে।
দুটি সতর্কতা। বাধ্যতামূলক কলামে SetNull লিখলে Prisma 7.10.1 শুধু warning দেয়, schema তবু valid থাকে। Docs অনুযায়ী error আসে মোছার সময়। আবার Cascade শিকল অনেক দূর যেতে পারে, তাই একবার মোছার আগে পুরো গাছটি কাগজে এঁকে নিন। ভুল Cascade-এ ডেটা ফেরে শুধু backup থেকে।
PRAGMA foreign_keys = ON; -- SQLite-এ প্রতি সংযোগে আলাদা করে চালু করতে হয়
SELECT COUNT(*) AS items_before FROM order_items;
-- CASCADE: অর্ডার 2 গেলে তার লাইনও যাবে
DELETE FROM orders WHERE id = 2;
SELECT COUNT(*) AS items_after FROM order_items;
-- সুমির কোনো অর্ডার নেই: RESTRICT আটকাবে না
DELETE FROM users WHERE id = 3;
-- RESTRICT: পণ্য 1 অর্ডারে আছে, তাই আটকাবে
DELETE FROM products WHERE id = 1;
SELECT 'এই লাইন চলবে না' AS note;items_before 4, অর্ডার মোছার পর items_after 2. সুমিকে মোছায় ১টি সারি বদলেছে। শেষে আসে FOREIGN KEY constraint failed, আর পরের SELECT চলেই না। SQLite-এর বার্তাটি সাধারণ, PostgreSQL constraint-এর নামসহ বলে। products WHERE id = 3 লিখে দেখুন: অর্ডার 2 মোছার পর ডালের কোনো লাইন নেই, তাই এবার মোছা যাবে।
৫ · Foreign key-র index — PostgreSQL নিজে বানায় না
অনেকের ধারণা, foreign key মানেই index. MySQL-এর InnoDB সত্যিই তা বানায়। Prisma-র relation mode পাতাতেও লেখা আছে database সাধারণত foreign key-তে index বানায়। কিন্তু PostgreSQL বানায় না। তাদের docs স্পষ্ট বলে: “the declaration of a foreign key constraint does not automatically create an index on the referencing columns.” কথাটি শুনে না মেনে যাচাই করি। @@index ছাড়া দুটি model থেকে Prisma যা বানাল (সত্যিকারের prisma migrate diff, ছাঁটা):
$ npx prisma migrate diff --from-empty --to-schema fk/schema.prisma --script
CREATE TABLE "Order" (...);
CREATE TABLE "OrderItem" ( "id" SERIAL NOT NULL, "orderId" INTEGER NOT NULL, ... );
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId")
REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- PostgreSQL-কে জিজ্ঞেস করা হলো:
indexes: [ 'Order_pkey', 'OrderItem_pkey' ] orderId-তে কিছু নেই
এর দাম কত? ১ লাখ অর্ডার আর ৩ লাখ OrderItem ঢুকিয়ে মাপা হলো। এগুলো এই মেশিনের একটি local run, সাধারণ ফল নয়, তবে অনুপাতটি শিক্ষণীয়:
| কাজ (৩ লাখ সারি) | Index ছাড়া | Index-সহ | Plan |
|---|---|---|---|
| একটি অর্ডারের লাইন পড়া (median, ৯ বার) | ১৫.৯ ms | ০.১৬ ms | Parallel Seq Scan → Index Scan |
| ২০০ অর্ডার Cascade-এ মোছা (median, ৩ বার) | ১,৬৭১ ms | ১.১ ms | প্রতি অর্ডারে একবার পুরো table |
Plan-টিও সত্যিকারের run থেকে নেওয়া (ছাঁটা):
EXPLAIN (ANALYZE) SELECT * FROM "OrderItem" WHERE "orderId" = 54321
-- index ছাড়া
Gather (actual rows=3.00 loops=1)
-> Parallel Seq Scan on "OrderItem" (actual rows=1.50 loops=2)
Filter: ("orderId" = 54321)
Rows Removed by Filter: 149998
-- index-সহ
Index Scan using "OrderItem_orderId_idx" on "OrderItem" (actual rows=3.00 loops=1)
Index Cond: ("orderId" = 54321)
হিসাবটি কেন এত বড়
PostgreSQL-এ ON DELETE চলে ভেতরের trigger দিয়ে। প্রতিটি মোছা অর্ডারের জন্য সে child table-এ মিল খোঁজে। Index না থাকলে প্রতিবার Seq Scan:
- পরীক্ষা করা সারি ≈ ২০০ × ৩,০০,০০০ = ৬ কোটি
- প্রতি অর্ডারে সময় = ১৬৭১.৩ ms ÷ ২০০ ≈ ৮.৪ ms
- অনুপাত = ১৬৭১.৩ ÷ ১.১ ≈ প্রায় ১,৫০০ গুণ
- ২,০০০ অর্ডার মুছলে, সময় সমানুপাতিক ধরে: index ছাড়া ≈ ১৬.৭ সেকেন্ড, index-সহ ≈ ১১ ms
সবচেয়ে বিপজ্জনক দিকটি হলো, parent মোছার কোডে child table-এর নাম কোথাও নেই। তাই ধীরগতি দেখে কারণ খুঁজে পাওয়া কঠিন। যে কলামে include, JOIN বা Cascade চলবে, সেখানে index দিন। সব মিলিয়ে নিয়মটি সহজ: সব foreign key-তে @@index, যদি না কলামটি আগে থেকেই কোনো index-এর প্রথম কলাম হয়।
এই শেষ শর্তটি বাজারঘরে কাজে লেগেছে। OrderItem-এ @@unique([orderId, productId]) আছে, তাই orderId-এর আলাদা index লাগেনি, লেগেছে শুধু productId-এ। Order-এ @@index([userId, createdAt(sort: Desc)]) একই সাথে “এই ব্যবহারকারীর সাম্প্রতিক অর্ডার” তালিকার কাজও করে।
একটি সত্যি কথা বলি। এই পর্বের schema লেখার সময় আমরাও Address.userId-তে index দিতে ভুলে গিয়েছিলাম। নিচের query-টি সেটি ধরে ফেলে, আর পরের migration-এ ঠিক করা হয়। একে আপনার প্রজেক্টের CI-তেও রাখতে পারেন:
SELECT c.conrelid::regclass::text AS tbl, a.attname AS col
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND i.indkey[0] = a.attnum);-- প্রথমবার (সত্যিকারের run) -- index যোগের পর
"Address" userId []
আরও গভীরে: Index নিজেও বিনা খরচে আসে না। প্রতিটি INSERT-এ index-ও লিখতে হয়, আর জায়গা লাগে। তাই খুব ছোট, প্রায় অপরিবর্তিত table-এ বাদ দেওয়া যায়, তবে সেটি জেনেবুঝে করুন। চলমান বড় table-এ index যোগ করলে লেখা আটকে যেতে পারে, এজন্য PostgreSQL-এ CREATE INDEX CONCURRENTLY আছে। Index নকশার পুরো আলোচনা আছে DBMS কোর্সে, আর লম্বা তালিকায় cursor-এর জন্য composite index দেখেছেন পর্ব ৩৪-এ।
৬ · অর্ডার লেখা — nested write আর দামের snapshot
আজ রহিমা ১৮৫ টাকায় তেল কিনলেন। আগামী মাসে দাম হলো ২১০ টাকা। তাঁর পুরোনো রসিদ কি বদলে যাবে? রিকশাভাড়ার রসিদ বা মুদি দোকানের মেমো যেমন লেখার পর বদলায় না, অর্ডারও তেমনি একটি ঐতিহাসিক দলিল। তাই অর্ডার লেখার মুহূর্তে দাম, নাম আর ঠিকানা কপি করে রাখা হয়। একে বলে snapshot.
Snapshot-সহ একটি অর্ডার কয়েকটি table-এ লেখা হয়, আর সব একসাথে হতে হবে। অর্ধেক অর্ডার থাকা চলবে না। Prisma-র nested write এই কাজটি এক কলে করে। connect মানে সারিটি আগেই আছে, শুধু জোড়া হবে। আর create মানে নতুন সারি তৈরি হবে। সত্যিকারের run-এর কোড:
const order = await prisma.order.create({
data: {
user: { connect: { id: rahima.id } },
address: { connect: { id: addr.id } },
shipName: rahima.name, // snapshot
shipLine: addr.line, // snapshot
shipCity: addr.city, // snapshot
totalPaisa: 18500,
items: {
create: [{
product: { connect: { id: soybean.id } },
productName: soybean.name, // snapshot
unitPricePaisa: soybean.pricePaisa, // snapshot
quantity: 1,
}],
},
},
include: { items: true },
});Client তৈরিতে log: [{ emit: "event", level: "query" }] দিলে Prisma তার পাঠানো SQL দেখায়। নিচের ধাপগুলো সেই log থেকে নেওয়া, ক্রমও একই:
- যাচাই
Userconnect - লেখা
Orderid পেল - যাচাই
Productconnect - লেখা
OrderItemলাইন - ফেরত
COMMITসব পাকা
SELECT "Address"."id" FROM "Address" WHERE ... SELECT "User"."id" FROM "User" WHERE ...
INSERT INTO "Order" ("userId","addressId","status", "shipName","shipLine","shipCity","totalPaisa",...) VALUES ($1,$2,...) -- ফল: Order id = 2, totalPaisa = 18500
SELECT "Product"."id" FROM "Product" WHERE ...
-- পণ্য 2 পাওয়া গেল
INSERT INTO "OrderItem" ("orderId","productId", "productName","unitPricePaisa","quantity") VALUES ($1,$2,$3,$4,$5) -- orderId=2, unitPricePaisa=18500, quantity=1
SELECT ... FROM "Order" ...
SELECT ... FROM "OrderItem" ...
COMMIT
- Connect মানে আগে যাচাই। Prisma দেখে নেয় ঠিকানা আর ব্যবহারকারী আছে কি না। তারা না থাকলে INSERT-এর আগেই কাজ থামে।
- Parent আগে।
OrderItem-এরorderIdলাগবে, তাই আগেOrderঢোকে। Foreign key এই ক্রম বাধ্য করে। ফল: Order id 2, মোট 18500 পয়সা। - নেস্টেড connect. লাইনের ভেতরের
product-ও একইভাবে যাচাই হয়। সবচেয়ে সরল এই কলেও ৩টি connect-এ ৩টি SELECT গেছে। - লাইন লেখা। এখানে
unitPricePaisa18500 কপি হলো। এখন থেকে পণ্যের দাম বদলালেও এই ঘর বদলাবে না। - পড়া আর COMMIT.
include-এর জন্য দুটি SELECT, তারপরCOMMIT. মোট ৮টি log লাইন। Log-এ COMMIT দেখা গেছে, BEGIN লাইনটি আসেনি, তবে docs বলে nested write এক transaction-এ চলে, আর পরের পরীক্ষা সেটি দেখায়।
অস্তিত্বহীন পণ্য 9999 দিয়ে একই কল চালিয়ে দেখা হলো। অর্ডারের INSERT আগে হলেও শেষে কিছুই রইল না (সত্যিকারের run, ছাঁটা):
code: 'P2025', operation: 'a nested connect', model: 'Product'
orders before/after: 2 2
-- সয়াবিন তেলের দাম বাড়ল
UPDATE products SET price_paisa = 21000 WHERE id = 2;
-- অর্ডার 1: যা দিয়েছেন বনাম আজকের দাম
SELECT p.name, oi.quantity,
oi.unit_price_paisa AS paid,
p.price_paisa AS today
FROM order_items oi JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = 1;
-- snapshot না থাকলে পুরোনো মোট ভুল আসত
SELECT o.id, o.total_paisa,
SUM(oi.unit_price_paisa * oi.quantity) AS by_snapshot,
SUM(p.price_paisa * oi.quantity) AS by_today_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id;তেলের সারিতে paid 18500 কিন্তু today 21000. দ্বিতীয় ফলে অর্ডার 1-এর by_snapshot 79000, যা total_paisa-র সমান, অথচ by_today_price 84000. অর্থাৎ snapshot না থাকলে পুরোনো রসিদ ৫০ টাকা (5000 পয়সা) বেড়ে যেত। সত্যিকারের PostgreSQL run-এও একই: snapshot 18500 current 21000 qty 2 আর order total 79000 sum of snapshot lines 79000. টাকা সবসময় পূর্ণসংখ্যা পয়সায়, পর্ব ৩২-এর মতোই।
Stock কমানো আর একসাথে দুজনের শেষ পণ্য কেনার ঘটনা এখানে নেই। Nested write এক transaction হলেও race condition-এর সব সমাধান দেয় না। সেই আলোচনা পর্ব ৪৩-এ।
৭ · Soft delete — সহজ চেহারার ফাঁদ
পণ্যে অর্ডার থাকলে Restrict তাকে মুছতে দেবে না, অথচ দোকান থেকে সরাতে হবে। সাধারণ সমাধান soft delete: সারি না মুছে deletedAt কলামে সময় বসানো। দোকানের তাক থেকে পণ্য সরিয়ে গুদামে রাখার মতো। এতে ইতিহাস বাঁচে, ভুল করে মুছলে ফেরানো যায়। কিন্তু এই পদ্ধতিতে database আর আপনাকে সাহায্য করে না। সব পাহারা চলে আসে আপনার কোডে। সত্যিকারের run-এ চারটি ফাঁদ:
| ফাঁদ | সত্যিকারের ফল | প্রতিকার |
|---|---|---|
১. where ভুলে যাওয়া | findMany()-এ মোছা চাল এসেছে | সব query এক repository দিয়ে |
২. include-এ ফিল্টার নেই | category.include.products-এ deletedAt=set পণ্য এসেছে | Nested where লেখা |
| ৩. Unique ভাঙে | মোছা email-এ আবার signup: P2002 | Partial unique index |
| ৪. Cascade চলে না | মোছা পণ্য কার্টে রয়ে গেছে (x2 deletedAt=set) | Soft delete-এর সাথে পরিষ্কারের কাজ |
চার নম্বর ফাঁদের কারণ সরল: soft delete আসলে একটি UPDATE, আর ON DELETE শুধু DELETE-এ চলে। তিন নম্বরের প্রতিকার Prisma 7.4 থেকে preview হিসেবে আছে। Generator-এ previewFeatures = ["partialIndexes"] লিখে:
model User {
id Int @id @default(autoincrement())
email String
deletedAt DateTime?
@@unique([email], where: raw("\"deletedAt\" IS NULL"))
}CREATE UNIQUE INDEX "User_email_key" ON "User"("email") WHERE ("deletedAt" IS NULL);
-- সত্যিকারের run
{ id: 3, email: 'sumi@example.com', soft_deleted: true }
{ id: 5, email: 'sumi@example.com', soft_deleted: false } নতুন signup সফল
তৃতীয় চেষ্টা, জীবিত account-এর সাথে একই email: P2002
এখানেই সবচেয়ে সূক্ষ্ম ফাঁদটি ধরা পড়েছে, যা আমরা আগে থেকে ভাবিনি। এই schema-র পরও Prisma 7.10.1-এর type-এ findUnique({ where: { email } }) বৈধ থাকে, অথচ email আর সত্যিই unique নয়। নতুন database-এ পুরোনো আর নতুন সুমি বানিয়ে চালিয়ে দেখা ফল:
findUnique email only: {"id":2,"name":"পুরোনো সুমি","deletedAt":"2026-11-01..."}
findUnique email + deletedAt null: {"id":3,"name":"নতুন সুমি","deletedAt":null}
অর্থাৎ login-এর কোডে শর্তটি ভুলে গেলে মোছা account ফেরত আসতে পারে। তাই partial unique ব্যবহার করলে সবসময় deletedAt: null সাথে লিখুন, আর এর জন্য একটি test রাখুন। Preview feature বদলাতে পারে, তাই নিজের version-এ যাচাই করুন।
কখন soft delete নয়: ব্যক্তিগত তথ্য মুছতে হলে deletedAt যথেষ্ট নয়, কারণ তথ্যটি তখনও database-এ থাকে। প্রায়ই ভালো বিকল্প হলো আসল DELETE-এর সাথে একটি আলাদা archive বা audit table. বাজারঘরে Product-এ soft delete আছে, কারণ অর্ডার তাকে নির্দেশ করে। CartItem-এ নেই, কারণ তার কোনো ইতিহাস-মূল্য নেই।
৮ · Polymorphic সম্পর্ক — একটি ছবি, দুই মালিক
পণ্যের ছবি আছে, রিভিউয়েরও ছবি আছে। একটি Image table কি দুটোরই হতে পারে? যে সম্পর্কে এক সারি একাধিক ধরনের table-এর সাথে জুড়তে পারে, তাকে polymorphic relation বলে। Prisma-র schema-য় এর জন্য সরাসরি কোনো syntax আমরা পাইনি, তাই বিকল্পগুলো জানা দরকার:
| পদ্ধতি | গঠন | সুবিধা | অসুবিধা |
|---|---|---|---|
| Exclusive arc | কয়েকটি nullable FK + CHECK | FK-র পুরো পাহারা | মালিক বাড়লে নতুন কলাম |
| আলাদা table | ProductImage, ReviewImage | সরল, পুরো FK | একই কোড দুবার; সব ছবি একসাথে পড়া কঠিন |
| সাধারণ parent | Gallery table; পণ্য ও রিভিউ দুজনেই তাকে নির্দেশ করে | নতুন মালিক সহজ, FK আছে | একটি বাড়তি join |
ownerType + ownerId | দুটি কলাম, FK নেই | লেখা সহজ, Rails-Laravel-এ পরিচিত | Database পাহারা দিতে পারে না |
শেষ পদ্ধতিটি সবচেয়ে প্রচলিত, আবার সবচেয়ে ঝুঁকিপূর্ণ। সত্যিকারের run-এ PostgreSQL ('Product', 9999) সারিটি চুপচাপ নিয়েছে, যদিও ওই পণ্য নেই (rows inserted: 1). বাজারঘরে তাই exclusive arc. Prisma-র schema-য় CHECK লেখার উপায় নেই, তাই হাতে লেখা migration লাগে। prisma migrate dev --create-only শুধু ফাঁকা বা তৈরি ফাইল বানায়, ফাইলটি আপনি সম্পাদনা করে তারপর চালান:
-- Prisma schema cannot say this, so it is written by hand.
-- A row in Image belongs to exactly one owner.
ALTER TABLE "Image" ADD CONSTRAINT "Image_one_owner"
CHECK (num_nonnulls("productId", "reviewId") = 1);
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_quantity_positive"
CHECK ("quantity" > 0);-- সত্যিকারের run, ছাঁটা
image with no owner : P2039, 23514 "Image_one_owner"
image with two owners : P2039, 23514 "Image_one_owner"
image with one owner : OK {"id":3,"url":"a.webp","productId":2,"reviewId":null}
owner 9999 does not exist: P2003 "Image_productId_fkey"
quantity 0 : P2039, 23514 "OrderItem_quantity_positive"
P2039 লেখার সময়ে Prisma 7-এর error reference-এর তালিকায় নেই। Prisma-র ২০২৬ সালের এপ্রিলের changelog অনুযায়ী এটি একটি সাধারণ “Database error”, যার নিজস্ব Prisma code নেই এমন driver error এতে মোড়া থাকে। তাই কারণ বুঝতে ভেতরের PostgreSQL code (23514, CHECK ভাঙা) আর constraint-এর নামই দেখুন। পর্ব ৪৩-এও একই code আসবে।
CHECK constraint Prisma দেখতে পায় না, তবু এতে সমস্যা হয়নি: migrate status বলেছে “Database schema is up to date!”, আর migrate diff কোনো পার্থক্য দেখায়নি। শুধু মনে রাখুন, নিয়মটি থাকে migration ফাইলে, schema.prisma-য় নয়। সেখানে একটি মন্তব্য লিখে রাখা ভালো।
৯ · পুরো বাজারঘর — schema, migration আর seed
এবার সব জোড়া দিই। নিচের ছবিটি বাজারঘরের ER diagram. ER নকশার নিয়ম জানতে দেখুন DBMS কোর্সের ER পর্ব আর নকশার case study.
বাজারঘরের ডেটা মডেল
১৩টি model. ডটেড দাগ = optional সম্পর্ক। ⟲ = self relation
নিচে সত্যিকারের চূড়ান্ত schema.prisma. প্রতিটি মন্তব্য একটি সিদ্ধান্ত। লম্বা, তাই ধীরে পড়ুন:
generator client {
provider = "prisma-client"
output = "../generated/prisma"
previewFeatures = ["partialIndexes"]
}
datasource db {
provider = "postgresql"
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
}
enum PaymentStatus {
INITIATED
SUCCESS
FAILED
}
model User {
id Int @id @default(autoincrement())
email String
name String
createdAt DateTime @default(now())
deletedAt DateTime?
addresses Address[]
cart Cart?
orders Order[]
reviews Review[]
@@unique([email], where: raw("\"deletedAt\" IS NULL"))
}
model Address {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
label String
line String
city String
orders Order[]
@@index([userId])
}
model Category {
id Int @id @default(autoincrement())
name String @unique
parentId Int?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Category[] @relation("CategoryTree")
products Product[]
@@index([parentId])
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
products Product[] // implicit many-to-many
}
model Product {
id Int @id @default(autoincrement())
name String
pricePaisa Int
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
deletedAt DateTime?
avgRating Float @default(0) // denormalised
reviewCount Int @default(0) // denormalised
tags Tag[]
inventory Inventory?
cartItems CartItem[]
orderItems OrderItem[]
reviews Review[]
images Image[]
@@index([categoryId])
}
model Inventory {
productId Int @id
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
stock Int @default(0)
}
model Cart {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items CartItem[]
updatedAt DateTime @updatedAt
}
model CartItem {
cartId Int
productId Int
quantity Int
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([cartId, productId])
@@index([productId])
}
model Order {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
addressId Int?
address Address? @relation(fields: [addressId], references: [id], onDelete: SetNull)
status OrderStatus @default(PENDING)
shipName String // snapshot
shipLine String // snapshot
shipCity String // snapshot
totalPaisa Int // denormalised, sum of the lines
createdAt DateTime @default(now())
items OrderItem[]
payments Payment[]
@@index([userId, createdAt(sort: Desc)])
@@index([addressId])
}
model OrderItem {
id Int @id @default(autoincrement())
orderId Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
productName String // snapshot
unitPricePaisa Int // snapshot
quantity Int
@@unique([orderId, productId])
@@index([productId])
}
model Payment {
id Int @id @default(autoincrement())
orderId Int
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
method String
amountPaisa Int
status PaymentStatus @default(INITIATED)
createdAt DateTime @default(now())
@@index([orderId])
}
model Review {
id Int @id @default(autoincrement())
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
rating Int
body String
createdAt DateTime @default(now())
images Image[]
@@unique([productId, userId])
@@index([userId])
}
model Image {
id Int @id @default(autoincrement())
url String
productId Int?
product Product? @relation(fields: [productId], references: [id], onDelete: Cascade)
reviewId Int?
review Review? @relation(fields: [reviewId], references: [id], onDelete: Cascade)
@@index([productId])
@@index([reviewId])
}লক্ষ করুন, datasource-এ url নেই। Prisma 7-এ সেটি থাকে prisma.config.ts-এ, আর সংযোগ হয় driver adapter দিয়ে (পর্ব ৩৯)। এবার migration আর seed. সত্যিকারের run; ডেটাবেসের নাম আর port বদলে সাধারণ করা হয়েছে, আউটপুট ছাঁটা:
~/bazarghar$ npx prisma migrate dev --name init
Loaded Prisma config from prisma.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "bazarghar", schema "public" at "127.0.0.1:5432"
Applying migration `20261101174952_init`
The following migration(s) have been created and applied from new schema changes:
prisma\migrations/
└─ 20261101174952_init/
└─ migration.sql
Your database is now in sync with your schema.
~/bazarghar$ npx prisma generate
✔ Generated Prisma Client (7.10.1) to .\generated\prisma in 142ms
~/bazarghar$ npx prisma db seed
Running seed command `tsx prisma/seed.ts` ...
seed: {
users: 1, addresses: 1, categories: 4,
tags: 2, products: 2, inventory: 2,
carts: 1, cartItems: 1, orders: 1,
orderItems: 2, payments: 2, reviews: 1
}
The seed command has been executed.
এই run-এ দুটি জিনিস চোখে পড়ল। migrate dev নিজে থেকে client generate করেনি, seed-ও চালায়নি। দুটোই আলাদা কমান্ডে চালাতে হয়েছে। বাকি তিনটি migration ছিল user_soft_delete, index ও partial unique, আর হাতে লেখা CHECK. শেষে একটি একদম নতুন, ফাঁকা database-এ prisma migrate deploy চালিয়ে ৪টি migration ক্রমে বসানো হয়েছে, seed-ও একই সংখ্যা দিয়েছে। Migration-এর নিরাপদ পথ নিয়ে বিস্তারিত আছে পর্ব ৪০-এ।
একটি সৎ টীকা: শেষের দুটি migration এই লেখার পরিবেশে migrate dev দিয়ে বানানো যায়নি, কারণ unique index বদলানোর warning-এ সে উত্তর চায়, আর এখানে terminal interactive ছিল না। তাই prisma migrate diff দিয়ে SQL বের করে ফাইলে রেখে migrate deploy করা হয়েছে। আপনার নিজের terminal-এ migrate dev-ই ব্যবহার করুন।
পুরো শিকলটি একসাথে পড়লে এই ফল আসে (সত্যিকারের run, তেলের দাম বদলের পরও অর্ডার অপরিবর্তিত, ছাঁটা):
{ "id": 1, "status": "PAID", "shipCity": "রাজশাহী", "totalPaisa": 79000,
"user": { "name": "রহিমা খাতুন" },
"items": [
{ "productName": "সয়াবিন তেল ১ লিটার", "unitPricePaisa": 18500, "quantity": 2 },
{ "productName": "মিনিকেট চাল ৫ কেজি", "unitPricePaisa": 42000, "quantity": 1 } ],
"payments": [
{ "method": "card", "status": "FAILED" },
{ "method": "mobile-wallet", "status": "SUCCESS" } ] }
Denormalization — জেনেবুঝে একই তথ্য দুবার
Normalization বলে, একই তথ্য এক জায়গায় রাখুন (DBMS কোর্স)। অথচ schema-য় totalPaisa আর avgRating দুটোই হিসাব করে বের করা যায়। এটিই denormalization. চায়ের দোকানের খাতায় প্রতি পাতার নিচে মোট লিখে রাখার মতো, যাতে প্রতিবার যোগ করতে না হয়। কিন্তু একটি বিষয় স্পষ্ট করা দরকার: snapshot আর denormalization এক নয়।
| কলাম | ধরন | বদলাতে হয়? | ভুল হলে |
|---|---|---|---|
unitPricePaisa | Snapshot: সেই মুহূর্তের সত্য | কখনো না | ইতিহাস নষ্ট |
totalPaisa | Derived, কিন্তু লেখার পর স্থির | অর্ডার বদলালে | লাইনের যোগফলের সাথে মিলবে না |
avgRating, reviewCount | Denormalized: পড়ার সুবিধা | প্রতি রিভিউতে | পুরোনো সংখ্যা দেখাবে, আবার হিসাব করা যায় |
Denormalization-এর নিয়ম তিনটি। আগে মেপে দেখুন সত্যিই ধীর কি না। একটি উৎস ঠিক করুন, এখানে Review, আর avgRating শুধু তার ছায়া। আর একই transaction-এ দুটো বদলান, অথবা একটি নিয়মিত কাজ রাখুন যা আবার হিসাব করে মিলিয়ে দেবে। ২ লাখ পণ্যের তালিকায় প্রতি কার্ডে রিভিউ গড় হিসাব করার বদলে একটি কলাম পড়া সস্তা, কিন্তু ৫০০ পণ্যের দোকানে এই জটিলতা হয়তো লাগবেই না। গভীর আলোচনা আছে DBMS কোর্সের denormalization পর্বে।
পরীক্ষা ও ইন্টারভিউতে: (১) Implicit আর explicit many-to-many-র পার্থক্য? লিংকে বাড়তি ডেটা বা আলাদা নিয়ম লাগলে explicit. (২) PostgreSQL কি foreign key-তে index বানায়? না, শুধু primary key আর unique-এ বানায়। (৩) Order item-এ দাম আবার কেন রাখবেন? কারণ অর্ডার ইতিহাস, পণ্যের দাম বদলায়। (৪) RESTRICT আর NO ACTION-এর পার্থক্য? RESTRICT-এর যাচাই পেছানো যায় না, NO ACTION deferrable হলে পেছানো যায়। (৫) Soft delete-এর বড় সমস্যা? সব query-তে ফিল্টার লাগে, unique ভাঙে, আর Cascade চলে না।
১০ · AI-এর যুগে — ডেটা মডেল যাচাই
AI এক মিনিটে সুন্দর schema লিখে দেয়। Schema কোডের চেয়েও বিপজ্জনক জায়গা, কারণ ভুল কোড বদলানো যায়, কিন্তু ভুল ডেটা নিয়ে এক বছর চললে migration হয়ে যায় কঠিন প্রকল্প। AI-এর লেখা schema-য় সাধারণত যা পাবেন:
| AI-এর ভুল | কেন ক্ষতিকর | যেভাবে ধরবেন |
|---|---|---|
price Float | টাকায় দশমিকের ভুল | পয়সায় Int চান |
Order শুধু Product-কে নির্দেশ করে | দাম বদলালে পুরোনো রসিদ বদলায় | Snapshot কলাম আছে কি না দেখুন |
সবখানে onDelete: Cascade | একজন user মুছলে অর্ডার ইতিহাস শেষ | প্রতিটি সম্পর্কে কারণ জিজ্ঞেস করুন |
@@index নেই | পরে ধীর, কারণ খুঁজে পাওয়া কঠিন | উপরের audit query চালান |
| পুরোনো config | datasource-এ url, provider = "prisma-client-js": Prisma 7-এ এই পথ বদলেছে | নিজের version-এর docs দেখুন, prisma validate চালান |
| Prisma 8-এর API | নতুন docs দেখে লেখা কোড 7-এ চলবে না | npx prisma --version মিলিয়ে নিন |
AI-কে schema লিখতে বলার চেয়ে নিজের schema পর্যালোচনা করতে বলা বেশি কাজের। নিচের prompt-এ version, টাকা রাখার নিয়ম আর উত্তরের গঠন আগে থেকে ঠিক করা আছে:
AI agent দিয়ে কাজ করলে আরেকটি বিষয় জেনে রাখুন। Prisma 7.10.1-এ migrate reset agent থেকে চালানোর চেষ্টা করলে সে নিজেই থেমে যায়, আর ব্যবহারকারীর স্পষ্ট সম্মতি চায়। এই পর্ব লেখার সময় আমাদের run-এ ঠিক এটিই ঘটেছে, আর আমরা সেই পাহারা এড়াইনি, নতুন ফাঁকা database-এ কাজ করেছি। সতর্কবার্তাটি পাশ কাটানোর পরামর্শ AI দিলে মানবেন না, আর production-এর connection string কখনো AI-কে দেবেন না।
AI-এর লেখা schema হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১ডেটার নিয়ম database-এ রাখবেন, না application-এ? কেউ কেউ foreign key-ই ব্যবহার করেন না কেন?
পেশাদারদের মধ্যে এটি সত্যিই বিতর্কের বিষয়। ১৯৭০ সালে E. F. Codd যে relational model প্রস্তাব করেন, তার মূল ভাবনাই ছিল ডেটার নিয়ম ডেটার কাছে থাকবে। Foreign key থাকলে একটি ভুল script, একজন admin-এর হাতে লেখা SQL বা দ্বিতীয় একটি সার্ভিস, কেউই অনাথ সারি বানাতে পারে না।
উল্টো পক্ষের যুক্তিও বাস্তব। বিশাল MySQL ব্যবস্থায় অনলাইনে schema বদলানোর টুলগুলোর সাথে foreign key ঝামেলা করে, এই নিয়ে Shlomi Noach-এর মতো প্রকৌশলীরা লিখেছেন। Foreign key প্রতিটি লেখায় বাড়তি যাচাই যোগ করে, আর data কয়েকটি shard-এ ভাগ হলে এক database-এর foreign key অন্য shard-কে দেখতে পায় না। এই ধরনের পরিবেশের জন্যই Prisma-য় relationMode = "prisma" আছে, যেখানে database-এ foreign key থাকে না, Prisma Client নিয়মটি অনুকরণ করে।
তবে এই অনুকরণের সীমা আছে। Prisma-র docs নিজেই সতর্ক করে যে এতে database-স্তরের নিশ্চয়তা নেই। আপনার Prisma-র বাইরে দিয়ে কেউ লিখলে পাহারা নেই। আর এই মোডে index নিজে না দিলে query ধীর হয়।
বাজারঘরের মতো একটি PostgreSQL database-এ চলা অ্যাপে এই খরচ প্রায় সবসময় মূল্যবান। প্রশ্নটি তাই “foreign key ভালো না খারাপ” নয়, প্রশ্নটি হলো আপনি কি সেই আকারে পৌঁছেছেন, যেখানে এর দাম ফলের চেয়ে বেশি? বেশিরভাগ প্রজেক্ট কখনো পৌঁছায় না। শুরুতে নিয়ম রেখে পরে সরানো সহজ, কিন্তু নিয়ম ছাড়া জমা নোংরা ডেটা পরে পরিষ্কার করা খুব কঠিন।
মূল উপলব্ধি: Application-এর যাচাই ব্যবহারকারীকে ভালো বার্তা দেয়, database-এর নিয়ম ডেটাকে রক্ষা করে। বড় কোম্পানির সিদ্ধান্ত নকল করার আগে দেখুন তাদের সমস্যাটি আপনার আছে কি না।
প্র ০২“কোনো কিছুই মুছবেন না, শুধু চিহ্ন দিন” — এই নীতি নিরাপদ শোনায়। তাহলে সমস্যা কোথায়?
Soft delete-এর আকর্ষণ সহজ: ভুল হলে ফেরানো যায়। Rails-এর paranoia বা acts_as_paranoid-এর মতো gem, আর Laravel-এর built-in SoftDeletes এই ধারণাকে খুব জনপ্রিয় করেছে। কিন্তু অভিজ্ঞ অনেক প্রকৌশলী বলেন, এর দাম ধীরে ধীরে আসে।
প্রথম দাম জটিলতা। প্রতিটি query, report আর join-এ একটি শর্ত যোগ হয়, আর একটি জায়গায় ভুললেই মোছা তথ্য ব্যবহারকারীর সামনে আসে। এই পর্বেই আমরা দেখেছি, include ফিল্টার করে না, আর findUnique মোছা account ফেরত দিতে পারে। দ্বিতীয় দাম foreign key-র মূল্য কমে যাওয়া। Database জানে না সারিটি “মৃত”, তাই সে মৃত পণ্যকেও জীবিত অর্ডারের সাথে জুড়তে দেয়। তৃতীয় দাম আকার। মোছা সারি table আর index-এ থেকে যায়।
চতুর্থ দামটি আইনি হতে পারে। ইউরোপের GDPR-এর (২০১৮ থেকে কার্যকর) Article 17-এ erasure-এর অধিকারের কথা আছে। যে দেশের ব্যবহারকারী, তার নিয়ম আপনার আইনি পরামর্শকের সাথে যাচাই করতে হবে, তবে নীতিগতভাবে “চিহ্ন দেওয়া” আর “মুছে ফেলা” এক নয়। ব্যক্তিগত তথ্য ডেটাবেসে থেকেই গেলে সেটি ফাঁস হতে পারে।
বিকল্প হিসেবে আসল DELETE-এর আগে সারিটি JSON-সহ একটি archive table-এ কপি করা যায়। মূল table পরিষ্কার থাকে, foreign key অর্থপূর্ণ থাকে, ফেরানোও সম্ভব, যদিও কিছুটা হাতে করতে হয়। PostgreSQL-এ view দিয়েও জীবিত সারি আলাদা করা যায়।
মূল উপলব্ধি: Soft delete হলো একটি নির্দিষ্ট সমস্যার সমাধান: যে সারিকে অন্য ইতিহাস নির্দেশ করে, তাকে সরানো। এটিকে সব table-এর ডিফল্ট বানালে সুবিধার চেয়ে ঝুঁকি বাড়ে।
প্র ০৩Snapshot ছাড়া কি অন্য কোনো পথ আছে? পুরো পণ্যের ইতিহাসই রেখে দিলে কেমন হয়?
Snapshot-এর ধারণাটি নতুন নয়। হিসাবরক্ষকেরা কয়েক শতাব্দী ধরে জানেন, খাতায় লেখা হিসাব মোছা হয় না, ভুল হলে নতুন সংশোধনী লাইন লেখা হয়। ১৪৯৪ সালে Luca Pacioli-র বইয়ে double-entry হিসাবের যে বর্ণনা ছাপা হয়, তার মূল চেতনাও এই। সফটওয়্যারে একই ভাবনা ফিরে আসে append-only ledger আর event sourcing হিসেবে, যা Martin Fowler ২০০৫ সালের দিকে একটি পরিচিত লেখায় ব্যাখ্যা করেন।
দাম ধরে রাখার তিনটি সাধারণ পথ আছে। প্রথমত, কলামে কপি: বাজারঘর যা করেছে। পড়া সহজ আর দ্রুত, শুধু যে কলাম কপি করেননি তা হারাবে। দ্বিতীয়ত, version table: ProductVersion-এ প্রতিটি বদল আলাদা সারি, আর OrderItem নির্দিষ্ট version-কে নির্দেশ করে। পণ্যের সব তথ্য বেঁচে থাকে, কিন্তু প্রতিটি পড়ায় বাড়তি join, আর “পণ্য বদলানো” মানে নতুন সারি লেখা। তৃতীয়ত, JSON snapshot: পুরো পণ্যের অবস্থা একটি JSON কলামে। নমনীয়, তবে পুরোনো JSON-এর গঠন নতুন কোডের সাথে না মিলতে পারে।
Standard SQL-এও ২০১১ সালের সংস্করণে temporal table-এর ধারণা এসেছে, যেখানে database নিজেই ইতিহাস রাখে, তবে সব database এটি সমানভাবে দেয় না।
বাছাইয়ের প্রশ্নটি হলো: পরে কী জিজ্ঞেস করা হবে? শুধু “ক্রেতা কত দিয়েছেন” জানতে হলে কলাম কপি যথেষ্ট। “সেদিন পণ্যের বিবরণে কী লেখা ছিল” নিয়ে অভিযোগ মেটাতে হলে পুরো snapshot লাগে। শুধু লক্ষ করুন, ঠিকানা, করের হার আর ছাড়ও একই ধরনের তথ্য, এগুলোও সময়ের সাথে বদলায়। কোনটি কপি হবে, সেই তালিকাটিই আসল নকশা।
মূল উপলব্ধি: “বর্তমান সত্য” আর “ঘটে যাওয়া ঘটনা” দুই রকম ডেটা। প্রথমটি বদলানো যায়, দ্বিতীয়টি শুধু যোগ করা যায়। অর্ডার দ্বিতীয় দলে।
প্র ০৪Rails আর Laravel-এ ownerType + ownerId এত জনপ্রিয় হলে একে “ঝুঁকিপূর্ণ” বলা কি বাড়াবাড়ি নয়?
না, তবে পুরো গল্পটি দুই দিকেরই। Rails-এর polymorphic: true, Laravel-এর morphTo আর Django-র ContentTypes ব্যবস্থা এই নকশাকে খুব সহজ করে দেয়। লাইক, মন্তব্য, notification-এর মতো জিনিস অনেক ধরনের সারির সাথে জোড়া থাকে, আর একটি table-এ সব রাখা লোভনীয়।
Bill Karwin তাঁর SQL Antipatterns (২০১০) বইয়ে “Polymorphic Associations”-কে একটি antipattern হিসেবে আলোচনা করেন। মূল সমস্যা গঠনগত: একটি foreign key একটি table-কেই নির্দেশ করতে পারে। ownerId-এর মান কোন table-এর, তা নির্ভর করে পাশের একটি লেখার ওপর, যা database-এর কাছে শুধু text. তাই এখানে Cascade চলে না, Restrict চলে না, আর পণ্য মুছলে ছবির সারিগুলো অনাথ হয়। এই পর্বের run-এ অস্তিত্বহীন ('Product', 9999) ঢুকে গেছে, কোনো আপত্তি ছাড়াই।
JOIN-ও অস্বস্তিকর। সব মালিকের ছবি পড়তে হলে প্রতিটি ধরনের জন্য আলাদা LEFT JOIN বা query লিখতে হয়। ORM এটি আড়াল করে, তবে সেই সাথে N+1-এর ঝুঁকিও লুকিয়ে ফেলে (পর্ব ৪২)। Prisma-তে সরাসরি polymorphic relation আছে বলে আমরা পাইনি, এটি নিয়ে দীর্ঘদিন ধরে community-তে অনুরোধ চলছে, তাই নিজের version-এ দেখে নিন।
তবু কোথায় এটি গ্রহণযোগ্য? Activity log, audit trail বা analytics-এর মতো জায়গায়, যেখানে সারিগুলো শুধু লেখা হয়, মালিক মুছে গেলেও ইতিহাস রাখতে হয়, আর সামান্য অনাথ সারি ক্ষতি করে না। উল্টো দিকে টাকা, stock বা অধিকারের সাথে জড়িত কিছুতে নয়। মাঝামাঝি পথ হলো exclusive arc: মালিকের ধরন কম হলে দারুণ, আট-দশটি হলে কলামের সারি অগোছালো হয়। সেখানে সাধারণ parent table ভালো।
মূল উপলব্ধি: জনপ্রিয় হওয়া মানে ত্রুটিহীন হওয়া নয়। প্রতিটি নকশার দাম লিখে নিন: এখানে আপনি লেখার সুবিধার বিনিময়ে database-এর পাহারা ছেড়ে দিচ্ছেন। ডেটাটি কতটা গুরুত্বপূর্ণ, সেটিই ঠিক করবে দামটি ন্যায্য কি না।
প্র ০৫Category গাছের জন্য parentId কি সবসময় যথেষ্ট? বড় গাছে অন্য কী পদ্ধতি আছে?
Relational database টেবিল-ভিত্তিক, আর গাছ ধরে হাঁটা তার স্বাভাবিক কাজ ছিল না। Recursive query প্রথম standard SQL-এ আসে SQL:1999-এ, PostgreSQL-এ WITH RECURSIVE এসেছে ৮.৪ সংস্করণে (২০০৯), আর MySQL-এ ৮.০-তে (২০১৮)। তার আগের যুগে মানুষ গাছ রাখার নানা চতুর কৌশল বানিয়েছিলেন।
- Adjacency list (
parentId): লেখা সবচেয়ে সহজ, একটি নোড সরাতে একটি সারি বদলালেই হয়। পুরো উপগাছ পড়তে recursive CTE লাগে। - Materialized path: প্রতিটি সারিতে
/1/2/3/-এর মতো পুরো পথ। “এই category-র নিচের সব” একটিLIKE '/1/2/%'দিয়ে পাওয়া যায়। কিন্তু একটি নোড সরালে নিচের সবার পথ বদলাতে হয়। PostgreSQL-এরltreeextension এই ধারণার একটি শক্ত রূপ। - Nested set: Joe Celko-র লেখায় জনপ্রিয় হওয়া পদ্ধতি। প্রতিটি নোডে left আর right সংখ্যা। পড়া খুব দ্রুত, কিন্তু একটি নোড ঢোকাতে অর্ধেক table বদলাতে হতে পারে।
- Closure table: আলাদা table-এ সব পূর্বপুরুষ-উত্তরপুরুষের জোড়া। পড়া আর FK দুটোই ভালো, তবে সারির সংখ্যা বেশি।
বাজারঘরের মতো দোকানে category হয়তো কয়েকশো, গভীরতা ৩–৪, আর বদলায় কম। সেখানে parentId-এর ওপর index আর recursive CTE যথেষ্ট, সাথে ফলটি cache করা যায় (পর্ব ৪৪)। আগেভাগে nested set নেওয়া মানে এমন সমস্যার দাম দেওয়া, যা আপনার নেই। একটি বিপদ অবশ্য মনে রাখবেন: parentId-এ চক্র তৈরি হতে পারে (A-র parent B, B-র parent A), foreign key এটি আটকায় না। সেক্ষেত্রে recursive query থামবে না, তাই লেখার সময় যাচাই বা query-তে গভীরতার সীমা রাখুন।
মূল উপলব্ধি: সব নকশাই পড়া আর লেখার মধ্যে একটি বিনিময়। আপনার ডেটা কতবার বদলায় আর কতবার পড়া হয়, সেই অনুপাত জেনেই বাছুন।
অনুশীলন
আগে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।
-
হিসাব:
OrderItem-এ ৯ লাখ সারি,orderId-তে index নেই। ধরুন, ৩ লাখ সারিতে এক অর্ডার Cascade-এ মুছতে ৮.৪ ms লাগে, আর সময় সারির সংখ্যার সমানুপাতিক। (ক) ১,০০০ অর্ডার মুছতে কত সময়? (খ) মোট কতগুলো সারি পরীক্ষা হবে? (গ) রহিমার অর্ডারে ২টি ৪২০০০ পয়সার আর ৩টি ১৮৫০০ পয়সার লাইন আছে, মোট টাকা কত?(ক) সারি ৩ গুণ, তাই প্রতি অর্ডারে ৮.৪ × ৩ = ২৫.২ ms. ১,০০০ অর্ডারে ২৫.২ × ১,০০০ = ২৫,২০০ ms, অর্থাৎ প্রায় ২৫ সেকেন্ড.
(খ) ১,০০০ × ৯,০০,০০০ = ৯০ কোটি সারি।
(গ) ২ × 42000 + ৩ × 18500 = 84000 + 55500 = 139500 পয়সা = ৳১,৩৯৫.০০. পয়সায় হিসাব করলে কোনো দশমিকের ভুল আসে না। শেষে শুধু ১০০ দিয়ে ভাগ করে দেখান।
-
নকশা ও সমালোচনা: AI এই model দিল। কমপক্ষে পাঁচটি ত্রুটি বের করুন:
model Order { id Int @id; userId Int; user User @relation(fields: [userId], references: [id], onDelete: Cascade); productId Int; product Product @relation(fields: [productId], references: [id], onDelete: Cascade); price Float; address String? }- এক অর্ডারে একটিই পণ্য:
OrderItemনেই, অথচ অর্ডার আর পণ্য many-to-many. quantityনেই, আরpriceএকটি মাত্র।Float: টাকায় ব্যবহার করা ঠিক নয়। পূর্ণসংখ্যা পয়সায়Intনিন।- User আর Product-এ Cascade: ব্যবহারকারী বা পণ্য মুছলে অর্ডারের ইতিহাস শেষ। দুটোই Restrict হওয়া উচিত।
@@indexনেই:userIdআরproductId-তে PostgreSQL নিজে index বানাবে না।id-তে@defaultনেই, আর ঠিকানা optional, অথচ পাঠাতে হলে ঠিকানা লাগবে। নাম ও শহরের snapshot-ও নেই।
- এক অর্ডারে একটিই পণ্য:
-
ফল অনুমান করুন:
users-এ 1, 2.addresses: 10 → user 1, 20 → user 2 (CASCADE).orders: 100 → user 1 (RESTRICT).reviews: 7 → user 2 (SET NULL). Foreign key চালু অবস্থায় পরপর চললDELETE FROM users WHERE id=1, তারপরDELETE FROM users WHERE id=2. শেষে চারটি table-এ কী থাকবে?প্রথম DELETE ব্যর্থ, কারণ অর্ডার 100 আছে। একটি statement ব্যর্থ হলে তার কোনো অংশই থাকে না, তাই ঠিকানা 10 মোছে না। দ্বিতীয় DELETE সফল। SQLite-এ চালিয়ে পাওয়া ফল:
FOREIGN KEY constraint failed users [(1,)] addresses [(10, 1)] orders [(100, 1)] reviews [(7, None)] -
কমান্ড ও model: বাজারঘরে wishlist যোগ করতে হবে। ব্যবহারকারী পণ্য পছন্দ করে রাখবেন, আর দেখাতে হবে কবে রেখেছেন। Implicit না explicit? Model লিখুন, আর কোন কমান্ডে যাচাই করবেন বলুন।
“কবে রেখেছেন” লিংকের নিজের তথ্য, তাই explicit. Composite
@@id-এর প্রথম কলামuserId, তাই আলাদা index লাগে শুধুproductId-তে।UserআরProduct-এwishlistItems WishlistItem[]যোগ করতে হবে।model WishlistItem { userId Int productId Int addedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade) @@id([userId, productId]) @@index([productId]) }যাচাই:
npx prisma validate, তারপরnpx prisma migrate dev --name wishlist, তারপর SQL ফাইলটি পড়া, আর শেষেnpx prisma generate.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমPrisma-র nested write-এ একটি অর্ডার লেখার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- ব্যবহারকারী আর ঠিকানা আছে কি না যাচাই
Ordertable-এ সারি ঢোকানো- পণ্য আছে কি না যাচাই
- নতুন
orderId-সহOrderItemঢোকানো COMMITকরে সব পাকা করা
Prisma-য় Order আর Product-এর সম্পর্কে পরিমাণ আর কেনার সময়ের দাম রাখতে হবে। কোন নকশা ঠিক?
- দুই দিকে list লিখে implicit many-to-many
Order-এ একটিproductIdকলামOrderItemনামে explicit model- শুধু
Producttable-এরpriceব্যবহার করা
Prisma schema-য় userId Int বাধ্যতামূলক কলামের @relation-এ কোনো onDelete লেখা নেই। PostgreSQL migration-এ কী বসবে?
ON DELETE CASCADEON DELETE RESTRICTON DELETE SET NULL- কোনো foreign key-ই তৈরি হবে না
PostgreSQL-এ OrderItem.orderId foreign key আছে কিন্তু @@index নেই। ৩ লাখ সারির table-এ অনেক Order মুছলে কী ঘটার কথা?
- PostgreSQL foreign key-তে নিজেই index বানিয়েছে, তাই দ্রুত হবে
- Migration ব্যর্থ হবে
- Parent মোছায় child table-এর কোনো ভূমিকা নেই
- প্রতিটি মোছা অর্ডারে child table-এ পুরো scan হতে পারে, তাই খুব ধীর হবে
একটি পণ্য ১৮৫ টাকায় অর্ডার হয়েছিল, পরে দাম হলো ২১০ টাকা। পুরোনো রসিদে ১৮৫ টাকা দেখাতে কোন নকশা ঠিক?
OrderItem-এunitPricePaisaকপি করে রাখা- দাম বদলানোর আগে পণ্যের সারি সরিয়ে রাখা
- প্রতিবার
Product-এর সাথে JOIN করে দাম পড়া onDelete: Cascadeব্যবহার করা
User-এ soft delete আছে, আর email-এ সাধারণ @unique. মোছা account-এর email দিয়ে আবার signup করলে কী হবে?
- সফল হবে, কারণ পুরোনো সারি মোছা বলে চিহ্নিত
- পুরোনো সারি Cascade-এ মুছে যাবে
- Unique নিয়ম ভাঙবে, Prisma
P2002দেবে - পুরোনো account নিজে থেকে ফিরে আসবে
Image table পণ্য বা রিভিউ, যেকোনো একটির হতে পারে। Database যেন অস্তিত্বহীন মালিক আটকায়, এজন্য কোন নকশা ভালো?
ownerTypeআরownerIdকলামproductId,reviewIdদুই nullable FK, সাথে “ঠিক একটি” CHECK- মালিকের তথ্য একটি JSON কলামে
- কোনো নিয়ম না রেখে application কোডে যাচাই
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Foreign key “অনেক” দিকে বসে;
@uniqueযোগ করলে সেটি one-to-one হয়। - লিংকে নিজের ডেটা, আলাদা নিয়ম বা পরিচয় লাগলে explicit join table, নইলে implicit.
onDeleteব্যবসায়িক সিদ্ধান্ত: একা অর্থহীন হলে Cascade, ইতিহাস হলে Restrict, সংযোগ কাটলে SetNull.- PostgreSQL foreign key-তে index বানায় না:
@@indexদিন, আর audit query চালান। - অর্ডার ইতিহাস: দাম আর ঠিকানার snapshot রাখুন; soft delete আর denormalization-এর দাম জেনে তবেই নিন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪২ · N+1 সমস্যা — include, select আর query মাপাপরবর্তী পর্বসম্পর্ক পড়তে গিয়ে query কেন বেড়ে যায়, আর কীভাবে মাপবেন।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ।
- ER আর cardinalityতত্ত্বসম্পর্কের ধরন আর তা আঁকার নিয়ম।
- Normalizationতত্ত্বকেন ডেটা ভাগ করা হয়, আর 1NF থেকে 3NF.
- Index নকশাগভীরে যানকোন কলামে, কোন ক্রমে index দেবেন।
- ORM আর migrationপাশের ট্র্যাকDBMS কোর্সের দিক থেকে ORM-এর আলোচনা।