অধ্যায় 2 · ডিজাইন ও ডেটার শুদ্ধতা
আসল স্কিমা ডিজাইন, আর নিরাপদে তা বদলানো
- পৃষ্ঠা 6 / 22
- 15 মিনিট পড়া
আগের পাতায় ছিল নিয়ম; এই পাতায় হাতে-কলমে কাজ। এখানে একটা ছোট লার্নিং প্ল্যাটফর্মের ডেটাবেস ডিজাইন করা হবে — শিক্ষার্থী, কোর্স, লেসন, এনরোলমেন্ট আর কুইজের চেষ্টা — এক পাতার চাহিদা থেকে চালানো যায় এমন DDL পর্যন্ত, আর পরীক্ষা করে দেখা হবে কনস্ট্রেইন্টগুলো সত্যিই ডেটা রক্ষা করছে কি না। তারপর আসবে সেই কাজ, যা প্রতিটা আসল প্রজেক্টে একসময় আসেই: অ্যাপ চালু আর ডেটা জমা থাকা অবস্থাতেই স্কিমা বদলানো।
এটা শুধু ব্যাক-এন্ডের মাথাব্যথা নয়। লার্নিং প্ল্যাটফর্মের কুইজের চেষ্টাগুলোই ঠিক সেই ডেটা, যা থেকে নলেজ-ট্রেসিং আর রেকমেন্ডেশন মডেল শেখে — আর যে স্কিমা প্রতিটা চেষ্টা রেখে দেয় (একটা "সেরা স্কোর" ওভাররাইট না করে), সেটাই পরে এসব সম্ভব করে।
যা শিখবেন
- চাহিদাকে এনটিটি, কি আর কনস্ট্রেইন্টে রূপ দেওয়া, আর DDL লেখা
- কম্পোজিট ফরেন কি দিয়ে ব্যবসার নিয়ম নিশ্চিত করা ("শুধু ভর্তি হওয়া শিক্ষার্থীরাই কুইজ দেবে")
- স্কিমার পরিবর্তনগুলোকে মাইগ্রেশন ফাইল হিসেবে ভার্সন করা, আর ছোট্ট একটা Python রানার দিয়ে প্রয়োগ করা
- ঝুঁকিপূর্ণ পরিবর্তন নিরাপদে করা: NULL-যোগ্য কলাম যোগ, ব্যাকফিল, তারপর কনস্ট্রেইন্ট; এক্সপ্যান্ড আর কন্ট্র্যাক্ট
- Alembic, Flyway আর Prisma Migrate আপনার হয়ে কী করে, তা জানা
চাহিদা
শিক্ষার্থীরা একটা ইমেইল আর নাম দিয়ে সাইন আপ করে। প্রতিটা কোর্সের একটা অনন্য কোড (যেমন
SQL-101), একটা শিরোনাম আর ০ থেকে ৩-এর মধ্যে একটা লেভেল থাকে। একটা কোর্স হলো লেসনের একটা সাজানো তালিকা। শিক্ষার্থীরা কোর্সে ভর্তি হয়; একটা এনরোলমেন্টের অবস্থা হয় চলমান, সম্পন্ন, নয়তো বাতিল। কিছু লেসনের শেষে কুইজ থাকে, যা একজন শিক্ষার্থী বহুবার দিতে পারে; প্রতিটা চেষ্টা আমরা স্কোর (০–১০০) আর সময়সহ রেখে দিই। কোনো কোর্সের কুইজ শুধু সেই কোর্সে ভর্তি শিক্ষার্থীরাই দিতে পারে।
একটা একটা করে সিদ্ধান্ত
| চাহিদা | ডিজাইনের সিদ্ধান্ত |
|---|---|
| "অনন্য কোড", "ইমেইল" | সারোগেট প্রাইমারি কি, সাথে code আর email-এ UNIQUE |
| "লেসনের সাজানো তালিকা" | lessons.position, সাথে UNIQUE (course_id, position): এক কোর্সে দুটো লেসন ৩ নয় |
| "শিক্ষার্থীরা কোর্সে ভর্তি হয়" | মেনি-টু-মেনি → জাংশন টেবিল enrolments, কি (student_id, course_id) |
| "চলমান, সম্পন্ন বা বাতিল", "০–১০০", "লেভেল ০ থেকে ৩" | CHECK কনস্ট্রেইন্ট, যাতে খারাপ মান ঢুকতেই না পারে |
| "প্রতিটা চেষ্টা রাখি" | quiz_attempts-এ শুধু নতুন সারি যোগ হয় (append-only), প্রতিটার নিজস্ব id থাকে; সেরা স্কোর হিসাব করা হয়, রাখা হয় না |
| "শুধু ভর্তি শিক্ষার্থীরা" | attempts থেকে enrolments (student_id, course_id)-এ একটা কম্পোজিট ফরেন কি |
STUDENTS ||--o{ ENROLMENTS : has
COURSES ||--o{ ENROLMENTS : has
COURSES ||--|{ LESSONS : "made of"
ENROLMENTS ||--o{ QUIZ_ATTEMPTS : "allows"
LESSONS ||--o{ QUIZ_ATTEMPTS : "quizzed in"DDL
টেবিলগুলো রাখা হচ্ছে shop.db-তেই, শপের টেবিলগুলোর পাশে (নাম মেলে না, তাই কোনো সংঘাতও নেই)। টাইপগুলো এমনভাবে লেখা যে একই DDL SQLite-এ চলে, আর TEXT টাইমস্ট্যাম্পকে TIMESTAMP করে দিলে PostgreSQL আর MySQL-এও চলে:
CREATE TABLE students (
student_id INTEGER PRIMARY KEY,
email VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
joined_on DATE NOT NULL
);
CREATE TABLE courses (
course_id INTEGER PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
level INTEGER NOT NULL CHECK (level BETWEEN 0 AND 3)
);
CREATE TABLE lessons (
lesson_id INTEGER PRIMARY KEY,
course_id INTEGER NOT NULL,
position INTEGER NOT NULL CHECK (position > 0),
title VARCHAR(200) NOT NULL,
has_quiz INTEGER NOT NULL DEFAULT 0 CHECK (has_quiz IN (0, 1)),
UNIQUE (course_id, position),
FOREIGN KEY (course_id) REFERENCES courses (course_id)
);
CREATE TABLE enrolments (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_on DATE NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'dropped')),
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students (student_id),
FOREIGN KEY (course_id) REFERENCES courses (course_id)
);
CREATE TABLE quiz_attempts (
attempt_id INTEGER PRIMARY KEY,
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
lesson_id INTEGER NOT NULL,
score INTEGER NOT NULL CHECK (score BETWEEN 0 AND 100),
attempted_at TEXT NOT NULL,
FOREIGN KEY (lesson_id) REFERENCES lessons (lesson_id),
FOREIGN KEY (student_id, course_id) REFERENCES enrolments (student_id, course_id)
);ফরেন কি লেখা হয়েছে টেবিল কনস্ট্রেইন্ট হিসেবে (টেবিলের শেষে
FOREIGN KEY (…) REFERENCES …), কলামের পাশে নয়। MySQL 8 কলামের পাশে লেখাREFERENCESমেনে নেয়, তারপর চুপচাপ উপেক্ষা করে; টেবিল-স্তরের রূপটা SQLite, PostgreSQL আর MySQL — তিনটাই সমানভাবে মেনে চলে।
কিছু নমুনা ডেটা — দুটো কোর্স, তিনজন শিক্ষার্থী, কয়েকটা চেষ্টা:
INSERT INTO students VALUES
(1, 'nadia@example.com', 'Nadia Rahman', '2026-01-05'),
(2, 'rafiq@example.com', 'Rafiq Islam', '2026-01-20'),
(3, 'mitu@example.com', 'Mitu Das', '2026-02-02');
INSERT INTO courses VALUES (1, 'SQL-101', 'SQL for AI', 0), (2, 'ML-201', 'First ML Models', 1);
INSERT INTO lessons VALUES
(1, 1, 1, 'SELECT basics', 1), (2, 1, 2, 'Joins', 1), (3, 1, 3, 'Window functions', 1),
(4, 2, 1, 'Train/test split', 1), (5, 2, 2, 'Linear regression', 0);
INSERT INTO enrolments VALUES
(1, 1, '2026-01-06', 'completed'), (1, 2, '2026-03-01', 'active'),
(2, 1, '2026-01-21', 'dropped'), (3, 1, '2026-02-03', 'active');
INSERT INTO quiz_attempts VALUES
(1, 1, 1, 1, 60, '2026-01-07 10:00'), (2, 1, 1, 1, 90, '2026-01-08 09:30'),
(3, 1, 1, 2, 85, '2026-01-12 20:15'), (4, 1, 1, 3, 70, '2026-01-20 21:00'),
(5, 2, 1, 1, 40, '2026-01-22 18:00'), (6, 3, 1, 1, 95, '2026-02-04 08:45'),
(7, 3, 1, 2, 55, '2026-02-10 19:10'), (8, 1, 2, 4, 80, '2026-03-02 11:00');কনস্ট্রেইন্টগুলো কি ডেটা রক্ষা করে?
একটা স্কিমা কতটা ভালো, তা বোঝা যায় সে কী কী আটকায় তা দেখে। তিনটা ভুল লেখা (write) চালিয়ে দেখুন:
-- Rafiq কখনো ML-201-এ ভর্তি হননি
INSERT INTO quiz_attempts VALUES (9, 2, 2, 4, 75, '2026-03-05 10:00');Error: FOREIGN KEY constraint failed-- SQL-101-এ দ্বিতীয় একটা লেসন ২
INSERT INTO lessons VALUES (6, 1, 2, 'Joins again', 0);Error: UNIQUE constraint failed: lessons.course_id, lessons.position-- সীমার বাইরের স্কোর
INSERT INTO quiz_attempts VALUES (9, 3, 1, 3, 120, '2026-02-12 19:00');Error: CHECK constraint failed: score BETWEEN 0 AND 100যে তিনটা বাগ নইলে ডেটায় ঢুকে পড়ত, সেগুলো দরজাতেই আটকে গেল। এবার প্ল্যাটফর্মের আসল প্রশ্ন — কাঁচা চেষ্টাগুলো থেকে প্রতিটা এনরোলমেন্টের অগ্রগতি:
SELECT s.name, c.code, e.status,
COUNT(DISTINCT qa.lesson_id) AS quizzes_tried,
(SELECT COUNT(*) FROM lessons AS l
WHERE l.course_id = c.course_id AND l.has_quiz = 1) AS quizzes_total,
COUNT(qa.attempt_id) AS attempts,
ROUND(AVG(qa.score), 1) AS avg_score
FROM enrolments AS e
JOIN students AS s ON s.student_id = e.student_id
JOIN courses AS c ON c.course_id = e.course_id
LEFT JOIN quiz_attempts AS qa
ON qa.student_id = e.student_id AND qa.course_id = e.course_id
GROUP BY s.student_id, s.name, c.course_id, c.code, e.status
ORDER BY s.student_id, c.code;+--------------+---------+-----------+---------------+---------------+----------+-----------+
| name | code | status | quizzes_tried | quizzes_total | attempts | avg_score |
+--------------+---------+-----------+---------------+---------------+----------+-----------+
| Nadia Rahman | ML-201 | active | 1 | 1 | 1 | 80.0 |
| Nadia Rahman | SQL-101 | completed | 3 | 3 | 4 | 76.3 |
| Rafiq Islam | SQL-101 | dropped | 1 | 3 | 1 | 40.0 |
| Mitu Das | SQL-101 | active | 2 | 3 | 2 | 75.0 |
+--------------+---------+-----------+---------------+---------------+----------+-----------+যেহেতু প্রতিটা চেষ্টা রাখা আছে, তাই এমন প্রশ্নও করা যায়, যার উত্তর একটা "সেরা স্কোর" কলাম কখনো দিতে পারত না: দ্বিতীয়বারে শিক্ষার্থীরা কি উন্নতি করেছে? দুই চেষ্টার মাঝে কত সময়? শিক্ষার্থীরা কীভাবে শেখে, তার মডেল বানাতে ঠিক এই ফিচারগুলোই লাগে।
স্কিমা বদলানো: মাইগ্রেশন
লঞ্চের পরদিনই কেউ একটা নতুন কলাম চাইবে। ডেটাবেস মুছে নতুন করে বানানো যায় না: তাতে আসল ডেটা আছে, আর প্রত্যেক ডেভেলপারের ল্যাপটপ, টেস্ট সার্ভার আর প্রোডাকশন — সবগুলোতে শেষে একই স্কিমা থাকতে হবে। সমাধান হলো মাইগ্রেশন (migration): নম্বর দেওয়া SQL ফাইল, প্রতিটায় একটাই পরিবর্তন, ভার্সন কন্ট্রোলে রাখা, আর ক্রম অনুযায়ী প্রয়োগ করা। কোনগুলো আগেই চালানো হয়েছে, ডেটাবেস নিজেই তা লিখে রাখে।
migrations/
0001_initial.sql
0002_add_country.sql
0003_backfill_country.sql
0004_country_not_null.sqlছোট্ট একটা মাইগ্রেশন রানার
আসল টুলগুলো আরও অনেক কিছু করে, কিন্তু মূল অংশটা এক স্ক্রিনেই ধরে যায়। এটা একটা schema_migrations টেবিল রাখে, আগেই প্রয়োগ হওয়া ফাইল বাদ দেয়, আর প্রতিটা নতুন ফাইল একটা ট্রানজ্যাকশনের ভেতরে চালায়, যাতে ব্যর্থ মাইগ্রেশন কিছুই আধাআধি রেখে না যায়। শপকে অক্ষত রাখতে এটা নিজের ফাইল platform.db আর স্কিমার একটা ছোট সংস্করণ ব্যবহার করে:
import sqlite3
from pathlib import Path
def migrate(db_path, folder="migrations"):
con = sqlite3.connect(db_path)
con.execute("CREATE TABLE IF NOT EXISTS schema_migrations ("
"version TEXT PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP)")
con.commit()
done = {row[0] for row in con.execute("SELECT version FROM schema_migrations")}
applied = 0
for path in sorted(Path(folder).glob("*.sql")):
if path.stem in done:
continue
try:
con.executescript("BEGIN;\n" + path.read_text(encoding="utf-8"))
con.execute("INSERT INTO schema_migrations (version) VALUES (?)", (path.stem,))
con.commit()
print("applied", path.stem)
applied += 1
except sqlite3.Error as exc:
con.rollback()
print(f"FAILED {path.stem}: {exc} -- rolled back")
break
con.close()
print(f"{applied} migration(s) applied")
Path("migrations").mkdir(exist_ok=True)
Path("migrations/0001_initial.sql").write_text("""
CREATE TABLE students (
student_id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);
INSERT INTO students (student_id, email, name) VALUES
(1, 'nadia@example.com', 'Nadia Rahman'),
(2, 'rafiq@example.com', 'Rafiq Islam');
""", encoding="utf-8")
migrate("platform.db")
migrate("platform.db")applied 0001_initial
1 migration(s) applied
0 migration(s) appliedদ্বিতীয়বার চালালে কিছুই হয় না: মাইগ্রেশন রানার চালানো আইডেমপোটেন্ট (idempotent) — প্রতিটা ফাইল জীবনে একবারই প্রয়োগ হয় — তাই প্রতিটা পরিবেশে প্রতিবার ডিপ্লয়ের সময় শুধু "migrate" চালালেই চলে।
বাধ্যতামূলক কলাম যোগ করার নিরাপদ উপায়
ধরুন শপ এখন প্রতিটা অর্ডারে একটা বাধ্যতামূলক total_amount চায়, যা তার আইটেমগুলো থেকে হিসাব হবে। সহজ মনে হওয়া এক ধাপের সমাধান — সরাসরি একটা NOT NULL কলাম যোগ করা — যে টেবিলে আগে থেকেই সারি আছে, সেখানে গোলমাল বাধায়, আর একেক ডেটাবেসে একেকভাবে:
-- PostgreSQL: রাজি হয় না, কারণ আগের সারিগুলো NULL হয়ে যেত
ALTER TABLE orders ADD COLUMN total_amount INTEGER NOT NULL;Error: column "total_amount" of relation "orders" contains null values-- MySQL: সফল হয়, আর চুপচাপ প্রতিটা সারিতে 0 বসিয়ে দেয়
ALTER TABLE orders ADD COLUMN total_amount INTEGER NOT NULL;
SELECT order_id, total_amount FROM orders WHERE order_id <= 3 ORDER BY order_id;+----------+--------------+
| order_id | total_amount |
+----------+--------------+
| 1 | 0 |
| 2 | 0 |
| 3 | 0 |
+----------+--------------+MySQL-এর ফলটা আরও খারাপ: টোটালগুলো দেখতে আসল ডেটার মতোই। নিরাপদ প্যাটার্নের তিন ধাপ — NULL-যোগ্য কলাম যোগ, ব্যাকফিল, তারপর কনস্ট্রেইন্ট:
-- PostgreSQL: ১. NULL-যোগ্য কলাম যোগ ২. আসল ডেটা থেকে ব্যাকফিল ৩. কনস্ট্রেইন্ট
ALTER TABLE orders ADD COLUMN total_amount INTEGER;
UPDATE orders AS o
SET total_amount = (SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)
FROM order_items AS oi WHERE oi.order_id = o.order_id)
WHERE total_amount IS NULL;
ALTER TABLE orders ALTER COLUMN total_amount SET NOT NULL;
SELECT order_id, total_amount FROM orders WHERE order_id <= 3 ORDER BY order_id;+----------+--------------+
| order_id | total_amount |
+----------+--------------+
| 1 | 2100 |
| 2 | 4500 |
| 3 | 3000 |
+----------+--------------+বড় টেবিলে ধাপ ২ চালাবেন ব্যাচে ব্যাচে (WHERE order_id BETWEEN …), প্রতিটা ব্যাচ আলাদা করে কমিট করে, যাতে কোনো একটা ট্রানজ্যাকশন লাখ লাখ সারিতে লক ধরে রেখে অন্যদের লেখা বেশিক্ষণ আটকে না রাখে। ধাপ ৩-এ NULL আছে কি না দেখতে তবু পুরো টেবিল একবার পড়তে হয়। নতুন মান যখন একটা ধ্রুবক, তখন PostgreSQL 11+ আর MySQL 8 NOT NULL DEFAULT 'BD' সঙ্গে সঙ্গেই যোগ করতে পারে; প্রতিটা সারির মান যখন হিসাব করতে হয়, তখনই তিন ধাপ জরুরি।
এবার রানারে ফেরা যাক: students-এ একটা বাধ্যতামূলক country লাগবে। একই তিন ধাপ হয়ে যায় তিনটা মাইগ্রেশন ফাইল। SQLite আগে থেকে থাকা কোনো কলামে NOT NULL যোগ করতে পারে না, তাই ধাপ ৩-এ SQLite-এর প্রচলিত পদ্ধতি: নতুন টেবিল বানানো, কপি, পুরোনোটা ড্রপ, নাম বদল — সবই মাইগ্রেশনের ট্রানজ্যাকশনের ভেতরে:
Path("migrations/0002_add_country.sql").write_text(
"ALTER TABLE students ADD COLUMN country TEXT;", encoding="utf-8")
Path("migrations/0003_backfill_country.sql").write_text(
"UPDATE students SET country = 'BD' WHERE country IS NULL;", encoding="utf-8")
Path("migrations/0004_country_not_null.sql").write_text("""
CREATE TABLE students_new (
student_id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
country TEXT NOT NULL DEFAULT 'BD'
);
INSERT INTO students_new (student_id, email, name, country)
SELECT student_id, email, name, country FROM students;
DROP TABLE students;
ALTER TABLE students_new RENAME TO students;
""", encoding="utf-8")
Path("migrations/0005_unique_name.sql").write_text("""
INSERT INTO students (student_id, email, name) VALUES (3, 'nadia2@example.com', 'Nadia Rahman');
CREATE UNIQUE INDEX students_name ON students (name);
""", encoding="utf-8")
migrate("platform.db")
con = sqlite3.connect("platform.db")
print(con.execute("SELECT group_concat(version, ', ') FROM schema_migrations").fetchone()[0])
print(con.execute("SELECT * FROM students ORDER BY student_id").fetchall())
con.close()applied 0002_add_country
applied 0003_backfill_country
applied 0004_country_not_null
FAILED 0005_unique_name: UNIQUE constraint failed: students.name -- rolled back
3 migration(s) applied
0001_initial, 0002_add_country, 0003_backfill_country, 0004_country_not_null
[(1, 'nadia@example.com', 'Nadia Rahman', 'BD'), (2, 'rafiq@example.com', 'Rafiq Islam', 'BD')]মাইগ্রেশন 0005 মাঝপথে ব্যর্থ হয়েছে — ইউনিক ইনডেক্স ডুপ্লিকেট নামে আটকানোর আগেই তার ইনসার্ট চলে গিয়েছিল — আর ট্রানজ্যাকশন তার সবটুকু ফিরিয়ে নিয়েছে: তৃতীয় কোনো শিক্ষার্থী নেই, schema_migrations-এ কোনো সারি নেই, তাই ঠিক করার পর এটা আবার চলবে। এই সব-নয়তো-কিছুই-না আচরণ ডেটাবেসের ওপর নির্ভর করে: SQLite আর PostgreSQL DDL রোলব্যাক করতে পারে। MySQL পারে না: প্রতিটা DDL স্টেটমেন্টের (CREATE, ALTER, DROP …) আগে আর পরে সে নিজে থেকেই খোলা ট্রানজ্যাকশন কমিট করে দেয় (implicit commit), তাই ব্যর্থ হওয়ার আগ পর্যন্ত যা চলেছে সবই থেকে যায় — এমনকি DDL-এর আগে চলা INSERT-ও। MySQL 8-এ একটা একক DDL স্টেটমেন্ট অ্যাটমিক, কিন্তু কয়েকটা মিলে নয়। তাই MySQL মাইগ্রেশনে প্রতি ফাইলে একটাই DDL স্টেটমেন্ট রাখুন।
এক্সপ্যান্ড আর কন্ট্র্যাক্ট: ডাউনটাইম ছাড়া পরিবর্তন
কলামের নাম বা টাইপ বদলালে অ্যাপের যে কপিগুলো এখনো পুরোনো নাম ব্যবহার করছে, সেগুলো সব ভেঙে যায়। কয়েকটা অ্যাপ সার্ভারে একে একে ডিপ্লয় হলে সবসময় একটা মুহূর্ত থাকে, যখন পুরোনো আর নতুন কোড একসাথে চলে। এক্সপ্যান্ড/কন্ট্র্যাক্ট (প্যারালাল চেঞ্জ) প্যাটার্ন দুটোকেই সচল রাখে:
| ধাপ | ডেটাবেস | অ্যাপ্লিকেশন |
|---|---|---|
| ১. এক্সপ্যান্ড | full_name যোগ (NULL-যোগ্য) | পুরোনো কোড name-ই ব্যবহার করে চলে |
| ২. দুই জায়গায় লেখা | — | এমন কোড ডিপ্লয়, যা দুটো কলামেই লেখে |
| ৩. ব্যাকফিল | পুরোনো সারিগুলোতে name থেকে full_name-এ কপি | — |
| ৪. পড়া বদল | full_name-এ NOT NULL যোগ | এমন কোড ডিপ্লয়, যা শুধু full_name পড়ে |
| ৫. কন্ট্র্যাক্ট | name ড্রপ | (এখন আর কেউ এটা ব্যবহার করে না) |
প্রতিটা ধাপ নিজস্ব মাইগ্রেশন আর নিজস্ব ডিপ্লয়, আর প্রতিটা ধাপ আলাদাভাবে ফিরিয়ে নেওয়া যায়। ধীর মনে হয়; কিন্তু টিমগুলো এভাবেই সার্ভিস বন্ধ না করে বড় প্রোডাকশন স্কিমা বদলায়।
মাইগ্রেশন টুল
| টুল | কোথায় ব্যবহার হয় | কীভাবে কাজ করে |
|---|---|---|
| Alembic | Python / SQLAlchemy | upgrade()/downgrade()-সহ Python মাইগ্রেশন স্ক্রিপ্ট; আপনার মডেল আর ডেটাবেসের পার্থক্য দেখে খসড়া নিজেই বানাতে পারে |
| Django migrations | Django | মডেলের পরিবর্তন থেকে makemigrations দিয়ে তৈরি হয়, migrate দিয়ে প্রয়োগ হয় |
| Flyway | যেকোনো (CLI, JVM) | V1__init.sql, V2__add_country.sql নামের সাধারণ SQL ফাইল (মাঝে দুটো আন্ডারস্কোর); আমাদের রানারের মতোই একটা হিস্টরি টেবিল, সাথে প্রতিটা ফাইলের চেকসাম |
| Prisma Migrate | TypeScript / Node | schema.prisma এডিট করুন; migrate dev পার্থক্য থেকে SQL মাইগ্রেশন ফাইল বানায় আর ডেভেলপমেন্ট ডেটাবেসে প্রয়োগ করে; প্রোডাকশনে migrate deploy শুধু প্রয়োগ করে |
alembic revision --autogenerate -m "add country to students" # একটা মাইগ্রেশনের খসড়া
alembic upgrade head # বাকি সব প্রয়োগ
flyway migrate # বাকি V*.sql ফাইল প্রয়োগ
npx prisma migrate dev --name add_country # বানানো আর প্রয়োগ (ডেভেলপমেন্ট)
npx prisma migrate deploy # বাকিগুলো প্রয়োগ (প্রোডাকশন)নিজে থেকে তৈরি মাইগ্রেশন শুধু খসড়া: সবসময় পড়ে দেখুন। শুধু পার্থক্য দেখে নাম বদল আর "পুরোনো কলাম ড্রপ, নতুন কলাম যোগ"-এর তফাত নির্ভরযোগ্যভাবে বোঝা যায় না: Alembic আর Prisma ড্রপ আর অ্যাড লিখে ফেলে (Prisma অন্তত ডেটা হারানোর সতর্কতা দেয়), Django আপনাকে জিজ্ঞেস করে। আর সেই তফাতটাই আপনার ডেটা।
সাধারণ ভুল
- কোথাও একবার চলে গেছে এমন মাইগ্রেশন এডিট করা। ডেটাবেস ভার্সনটাকে সম্পন্ন হিসেবে লিখে রেখেছে, আর কখনো আবার চালাবে না, ফলে পরিবেশগুলো একে অপরের থেকে আলাদা হয়ে যায় (Flyway চেকসাম মেলে না দেখে এগোতেই রাজি হয় না)। তার বদলে নতুন মাইগ্রেশন লিখুন।
- ভরা টেবিলে এক ধাপে
NOT NULLযোগ। PostgreSQL রাজি হয় না; MySQL শূন্য বা ফাঁকা স্ট্রিং বসিয়ে দেয়। NULL-যোগ্য যোগ করুন, ব্যাকফিল করুন, তারপর কনস্ট্রেইন্ট। - এমন হিসাব-করা মান জমিয়ে রাখা, যা আসল ডেটার সাথে অমিল হয়ে যেতে পারে। চেষ্টাগুলোর পাশে একটা
best_scoreকলাম রাখলে চিরকাল সেটা মিলিয়ে রাখতে হবে। পারফরম্যান্স মেপে অন্য প্রয়োজন না দেখা পর্যন্ত কোয়েরি বা ভিউতে হিসাব করুন। - এক MySQL মাইগ্রেশনে কয়েকটা DDL স্টেটমেন্ট। MySQL প্রতিটা DDL-এর আগে-পরে নিজে থেকেই কমিট করে, তাই ব্যর্থ হলে মাইগ্রেশন আধাআধি প্রয়োগ হয়ে থাকে। প্রতি মাইগ্রেশন ফাইলে একটা DDL।
- অ্যাপ চালু থাকা অবস্থায় সরাসরি কলামের নাম বদল। পুরোনো অ্যাপ সার্ভারগুলো সাথে সাথে ভেঙে পড়ে। এক্সপ্যান্ড, দুই জায়গায় লেখা, ব্যাকফিল, বদল, কন্ট্র্যাক্ট।
নিজে চেষ্টা করুন
- সহজ: SQL-101-এ প্রতিটা কুইজ লেসনে প্রত্যেক শিক্ষার্থীর সেরা স্কোর দেখান (শিক্ষার্থীর নাম, লেসনের শিরোনাম, সেরা স্কোর), শিক্ষার্থী আর লেসনের অবস্থান অনুযায়ী সাজিয়ে।
- মাঝারি: মাইগ্রেশন
0005_unique_name.sqlআবার লিখুন, এবার ঠিকভাবে: খারাপ ইনসার্টটা বাদ দিন আরname-এ একটা সাধারণ (ইউনিক নয়) ইনডেক্স যোগ করুন, তারপর রানার চালিয়ে প্রয়োগ হওয়া ভার্সনগুলো প্রিন্ট করুন। - কঠিন:
quiz_attemptsএমন চেষ্টা আটকায় না, যারlesson_idতারcourse_id-এর চেয়ে ভিন্ন কোর্সের। একটা কনস্ট্রেইন্ট দিয়ে এটা কীভাবে আটকাবেন? DDL-এর পরিবর্তনগুলো লিখুন (নতুন টেবিলের নাম ব্যবহার করতে পারেন)।
উত্তর
-- সহজ
SELECT s.name, l.title, MAX(qa.score) AS best_score
FROM quiz_attempts AS qa
JOIN students AS s ON s.student_id = qa.student_id
JOIN lessons AS l ON l.lesson_id = qa.lesson_id
WHERE qa.course_id = 1
GROUP BY s.student_id, s.name, l.lesson_id, l.position, l.title
ORDER BY s.student_id, l.position;# মাঝারি
Path("migrations/0005_unique_name.sql").write_text(
"CREATE INDEX students_name ON students (name);", encoding="utf-8")
migrate("platform.db")
con = sqlite3.connect("platform.db")
print(con.execute("SELECT group_concat(version, ', ') FROM schema_migrations").fetchone()[0])
con.close()-- কঠিন: (lesson_id, course_id)-কে ফরেন কি-র লক্ষ্য হওয়ার যোগ্য করুন, তারপর দুটো কলাম একসাথে রেফার করুন
CREATE TABLE lessons_v2 (
lesson_id INTEGER PRIMARY KEY,
course_id INTEGER NOT NULL,
position INTEGER NOT NULL CHECK (position > 0),
title VARCHAR(200) NOT NULL,
UNIQUE (course_id, position),
UNIQUE (lesson_id, course_id),
FOREIGN KEY (course_id) REFERENCES courses (course_id)
);
CREATE TABLE quiz_attempts_v2 (
attempt_id INTEGER PRIMARY KEY,
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
lesson_id INTEGER NOT NULL,
score INTEGER NOT NULL CHECK (score BETWEEN 0 AND 100),
attempted_at TEXT NOT NULL,
FOREIGN KEY (student_id, course_id) REFERENCES enrolments (student_id, course_id),
FOREIGN KEY (lesson_id, course_id) REFERENCES lessons_v2 (lesson_id, course_id)
);সারসংক্ষেপ
- একটা একটা করে চাহিদা ধরুন: প্রতিটা হয়ে যায় একটা কি, একটা জাংশন টেবিল, একটা
CHECKবা একটা ফরেন কি — তারপর পরীক্ষা করুন ভুল লেখাগুলো আটকানো হচ্ছে কি না। - কম্পোজিট ফরেন কি একাধিক টেবিল জুড়ে থাকা নিয়ম নিশ্চিত করে; কাঁচা ইভেন্ট (প্রতিটা চেষ্টা) রাখুন আর অ্যাগ্রিগেট সেগুলো থেকে হিসাব করুন।
- মাইগ্রেশন হলো নম্বর দেওয়া, ভার্সন কন্ট্রোলে রাখা পরিবর্তনের ফাইল, সাথে একটা হিস্টরি টেবিল; একবার চলে যাওয়া মাইগ্রেশন কখনো এডিট করবেন না।
- আগের টেবিলে বাধ্যতামূলক কলাম: NULL-যোগ্য যোগ, ব্যাকফিল, কনস্ট্রেইন্ট। চালু অবস্থায় নাম বদল: এক্সপ্যান্ড আর কন্ট্র্যাক্ট।
- Alembic, Flyway, Prisma আর Django কোনটা চলেছে আর কোনটা বাকি, সেই হিসাব রাখার কাজটা স্বয়ংক্রিয় করে; প্রতিটা মাইগ্রেশন তবু আপনাকেই পড়তে হবে।
এরপর: ট্রানজ্যাকশন, ACID, আইসোলেশন আর লকিং পাতায় এই পাতা যে ট্রানজ্যাকশনগুলোর ওপর ভরসা করেছে, সেগুলো কাছ থেকে দেখা হবে: BEGIN/COMMIT/ROLLBACK কী নিশ্চয়তা দেয়, দুটো সেশন একই সারি বদলালে কী ভুল হয়, আর লক ও রিট্রাই কীভাবে তা সামলায়।