الترحيلات (Migrations) وتطوّر المخطط — SQL الأساسيات
الترحيلات (Migrations) وتطوّر المخطط
المخطط يتغير مع كل ميزة: عمود جديد، جدول جديد، فهرس. على جهازك يمكنك حذف القاعدة وإعادة إنشائها، لكن في الإنتاج هناك بيانات مستخدمين حقيقية. الترحيلات هي الطريقة المنضبطة لتطوير المخطط.
الفكرة
- كل تغيير في ملف SQL مستقل مرقّم زمنياً: 20261001090000_add_phone_to_users.sql.
- الملفات تُطبَّق بالترتيب، وكل ملف مرة واحدة.
- جدول في قاعدة البيانات نفسها يسجّل ما طُبِّق (schema_migrations).
- الملفات تُرفع إلى Git مع الكود، فيكون المخطط قابلاً للمراجعة والتكرار على أي جهاز وبيئة.
migrations/
├── 20260901000000_create_users.sql
├── 20260915000000_create_orders.sql
└── 20261001090000_add_phone_to_users.sqlالمثال الكامل ينفّذ مشغّل ترحيلات صغيراً كاملاً بـ Python و SQLite لتفهم ما تفعله الأدوات.
القاعدة الذهبية
لا تعدّل ملف ترحيل طُبِّق في أي بيئة مشتركة. الملف القديم سجلّ تاريخي؛ إن أخطأت، اكتب ترحيلاً جديداً يصحح. تعديل القديم يجعل الإنتاج والتطوير مختلفين بصمت.
تغييرات آمنة وغير آمنة
| التغيير | الخطر | الطريقة الآمنة |
|---|---|---|
| إضافة جدول | آمن | — |
| إضافة عمود يقبل NULL | آمن | — |
| إضافة عمود NOT NULL بلا افتراضي | يفشل إن وُجدت صفوف | أضفه nullable ← املأ القيم ← ثم SET NOT NULL |
| إضافة فهرس على جدول كبير | يقفل الكتابة أثناء البناء (PostgreSQL) | CREATE INDEX CONCURRENTLY (خارج معاملة) |
| حذف عمود/جدول | فقدان بيانات + الكود القديم ينكسر | انتظر حتى لا يستخدمه أي كود، خذ نسخة احتياطية، ثم احذف |
| إعادة تسمية عمود | الكود القديم ينكسر فوراً | توسيع ثم تقليص (أدناه) |
| تغيير النوع | قد يفشل أو يقطع البيانات | عمود جديد + نسخ + تبديل |
نمط التوسيع ثم التقليص (Expand / Contract)
لإعادة تسمية fullname إلى name دون توقف الخدمة:
1) توسيع: أضف name، والكود يكتب في الاثنين ويقرأ من fullname
2) ترحيل: انسخ القيم القديمة: UPDATE users SET name = fullname WHERE name IS NULL
3) تبديل: الكود يقرأ من name
4) تقليص: بعد التأكد، ترحيل يحذف fullnameأثناء النشر يعمل الكود القديم والجديد معاً لدقائق؛ كل خطوة يجب أن تكون متوافقة مع الاثنين.
الترحيلات داخل معاملة
في PostgreSQL يمكن لأغلب أوامر DDL أن تكون داخل معاملة: إن فشل جزء، لا يُطبَّق شيء — ميزة كبيرة. (MySQL يثبّت DDL ضمنياً فلا يتراجع.) بعض الأوامر مثل CREATE INDEX CONCURRENTLY لا تعمل داخل معاملة.
الرجوع (Down)
بعض الأدوات تطلب ملف "down" يعكس كل ترحيل. مفيد في التطوير، لكن في الإنتاج الرجوع غالباً ترحيل أمامي جديد: لا يمكن "إرجاع" عمود حذفته وبياناته.
البذر (Seed) مقابل الترحيل
- الترحيل: بنية + بيانات مرجعية لازمة لعمل التطبيق (قائمة الحالات، الأدوار).
- البذر: بيانات تجريبية للتطوير — لا تُشغَّل على الإنتاج.
الأدوات
| الأداة | ملاحظات |
|---|---|
| Supabase CLI | supabase migration new ثم supabase db push |
| Prisma Migrate / Drizzle Kit | تولّد الترحيلات من تعريف المخطط في الكود — راجع SQL المولّد دائماً |
| node-pg-migrate، Knex، Flyway، Alembic (Python) | ترحيلات صريحة |
قبل تطبيق ترحيل على الإنتاج
- [ ] جُرِّب على نسخة من بيانات حقيقية (أو مخطط مطابق).
- [ ] متوافق مع الكود الحالي المنشور (لا يكسره قبل نشر الكود الجديد).
- [ ] نسخة احتياطية حديثة.
- [ ] الأقفال: هل يقفل جدولاً كبيراً لوقت طويل؟
- [ ] يمكن إعادة تشغيله بأمان (IF NOT EXISTS) إن انقطع.
تمرين
أضف إلى المثال الكامل ترحيلاً رابعاً يجعل phone إلزامياً بالطريقة الآمنة: املأ القيم الفارغة بقيمة مؤقتة ثم أعد بناء الجدول (SQLite لا يدعم ALTER COLUMN SET NOT NULL). شغّل المشغّل مرتين وتأكد أن الترحيل لا يُطبَّق مرتين.
# مشغّل ترحيلات مصغّر: يوضح ما تفعله أدوات مثل Supabase CLI و Prisma Migrate
import sqlite3
# في مشروع حقيقي: ملفات migrations/*.sql تُقرأ بترتيب أسمائها
MIGRATIONS = [
("20260901000000_create_users", """
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
fullname TEXT NOT NULL
);
"""),
("20260915000000_add_phone", """
ALTER TABLE users ADD COLUMN phone TEXT; -- عمود يقبل NULL: آمن
"""),
("20261001000000_expand_name", """
ALTER TABLE users ADD COLUMN name TEXT; -- توسيع: عمود جديد بجانب القديم
UPDATE users SET name = fullname WHERE name IS NULL;
"""),
]
def migrate(conn):
conn.execute("""CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)""")
applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations")}
for version, sql in MIGRATIONS:
if version in applied:
continue
try:
conn.execute("BEGIN")
# executescript يثبّت ضمنياً؛ ننفذ العبارات واحدة واحدة داخل المعاملة
for statement in filter(None, (s.strip() for s in sql.split(";"))):
conn.execute(statement)
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
conn.execute("COMMIT")
print("طُبِّق:", version)
except Exception as err:
conn.execute("ROLLBACK")
raise RuntimeError(f"فشل {version}: {err}") from err
conn = sqlite3.connect(":memory:", isolation_level=None) # نتحكم بالمعاملات بأنفسنا
# بيانات "إنتاج" قبل الترحيلات اللاحقة
migrate(conn)
conn.execute("INSERT INTO users (email, fullname) VALUES ('sara@example.com', 'سارة أحمد')")
MIGRATIONS.append(("20261005000000_backfill_check", "UPDATE users SET name = fullname WHERE name IS NULL"))
migrate(conn) # يطبّق الجديد فقط
migrate(conn) # لا شيء: كل الترحيلات مسجّلة
print(conn.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall())
print(conn.execute("SELECT email, fullname, name, phone FROM users").fetchall())اختبر فهمك