تصميم المخطط والتطبيع — SQL الأساسيات
تصميم المخطط والتطبيع
الاستعلامات الجيدة لا تنقذ مخططاً سيئاً. أغلب مشاكل التطبيقات مع البيانات (تكرار، تناقض، تقارير خاطئة) سببها تصميم الجداول لا SQL. هذا الدرس منهجية تتبعها قبل كتابة أي CREATE TABLE.
الخطوة 1: من المتطلبات إلى الكيانات
اكتب جملاً عن التطبيق، ثم استخرج الأسماء (كيانات ← جداول) والعلاقات بينها:
> "العميل يضع طلبات. كل طلب يحتوي عدة منتجات بكميات. المنتج له سعر ومخزون."
العميل ──< الطلب ──< عنصر الطلب >── المنتج
(واحد لمتعدد) (واحد لمتعدد) (متعدد لواحد)
الطلب ↔ المنتج علاقة متعدد لمتعدد ← جدول وسيط order_items يحمل الكمية والسعرالخطوة 2: لماذا لا نضع كل شيء في جدول واحد؟
CREATE TABLE orders_flat (
order_id INTEGER, customer_name TEXT, customer_email TEXT, customer_city TEXT,
product_name TEXT, product_price REAL, quantity INTEGER
);
INSERT INTO orders_flat VALUES
(1, 'سارة', 'sara@example.com', 'بغداد', 'قلم', 1.5, 3),
(1, 'سارة', 'sara@example.com', 'بغداد', 'دفتر', 3.0, 1),
(2, 'سارة', 'sara@exmaple.com', 'البصرة', 'قلم', 1.5, 10); -- خطأ إملائي ومدينة مختلفة!
SELECT DISTINCT customer_email, customer_city FROM orders_flat WHERE customer_name = 'سارة';ثلاث مشاكل كلاسيكية (تسمى شذوذات):
| الشذوذ | المثال |
|---|---|
| التحديث | سارة غيّرت مدينتها: يجب تعديل كل صفوفها؛ نسيان صف = بيانات متناقضة |
| الإدراج | لا يمكن إضافة منتج جديد قبل أن يطلبه أحد |
| الحذف | حذف آخر طلب لمنتج يحذف معلومة سعره نهائياً |
الخطوة 3: الصيغ الطبيعية (عملياً)
| الصيغة | القاعدة | السؤال الذي تطرحه |
|---|---|---|
| 1NF | كل خلية قيمة واحدة؛ لا قوائم داخل عمود | هل يوجد عمود "products" فيه "قلم، دفتر"؟ |
| 2NF | كل عمود يعتمد على المفتاح كله (مهم مع المفاتيح المركبة) | في (order_id, product_id): هل اسم المنتج يعتمد على product_id وحده؟ ← انقله لجدول المنتجات |
| 3NF | لا عمود يعتمد على عمود آخر غير المفتاح | هل المدينة تعتمد على العميل لا على الطلب؟ ← انقلها لجدول العملاء |
الخلاصة العملية: كل معلومة تُخزَّن في مكان واحد، وتُربط بالمعرّفات.
الخطوة 4: المفاتيح والأنواع
- مفتاح بديل (id رقمي أو UUID) للجداول الرئيسية: لا يتغير أبداً. البريد أو رقم الهاتف يتغيران — اجعلهما UNIQUE لا مفتاحاً أساسياً.
- مفتاح مركب طبيعي للجداول الوسيطة: PRIMARY KEY (order_id, product_id) يمنع تكرار المنتج في نفس الطلب.
- المال: NUMERIC(12,2) (أو أعداد صحيحة بالفلس/السنت) — لا REAL/FLOAT: 0.1 + 0.2 لا يساوي 0.3 في الأعداد العشرية الثنائية.
- التواريخ: TIMESTAMPTZ في PostgreSQL (بالمنطقة الزمنية)، و UTC دائماً في التخزين.
- حالات محدودة: CHECK (status IN (...)) أو نوع enum.
- التسمية: أسماء جمع بأحرف صغيرة و snake_case (order_items، created_at)، و <الجدول المفرد>_id للمفاتيح الأجنبية.
الخطوة 5: متى نكسر التطبيع؟
التطبيع للكتابة الصحيحة؛ أحياناً نكرر بيانات عمداً لسبب واضح:
- لقطة تاريخية: order_items.unit_price يحفظ السعر وقت الشراء — ليس تكراراً بل حقيقة مختلفة عن السعر الحالي.
- الأداء: عمود محسوب مسبقاً (عدد التعليقات) لصفحة تُقرأ ملايين المرات — مع آلية تضمن تحديثه (trigger أو معاملة).
اكسر القاعدة بقرار موثّق، لا بالصدفة.
قائمة مراجعة لأي مخطط
- [ ] كل جدول يمثل شيئاً واحداً، ولكل صف مفتاح أساسي.
- [ ] لا قوائم داخل أعمدة، ولا معلومة مكررة في جدولين (إلا لقطات مقصودة).
- [ ] كل علاقة لها مفتاح أجنبي بسلوك ON DELETE مقصود.
- [ ] القيود (NOT NULL، UNIQUE، CHECK) تعبّر عن قواعد العمل.
- [ ] فهارس على المفاتيح الأجنبية والأعمدة المستخدمة في البحث.
تمرين
صمم مخطط "منصة دورات": مستخدمون، دورات، دروس (لكل دورة)، تسجيل المستخدم في الدورة (بتاريخ)، وإكمال الدروس. حدد المفاتيح والقيود وسلوك الحذف، وبرّر كل قرار بإحدى الصيغ الطبيعية.
-- المخطط بعد التطبيع: كل معلومة في مكان واحد
PRAGMA foreign_keys = ON;
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
city TEXT
);
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
price_fils INTEGER NOT NULL CHECK (price_fils >= 0) -- المال كعدد صحيح بأصغر وحدة
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_fils INTEGER NOT NULL, -- لقطة السعر وقت الشراء (تكرار مقصود)
PRIMARY KEY (order_id, product_id)
);
INSERT INTO customers (id, name, email, city) VALUES (1, 'سارة', 'sara@example.com', 'بغداد');
INSERT INTO products (id, name, price_fils) VALUES (1, 'قلم', 1500), (2, 'دفتر', 3000);
INSERT INTO orders (id, customer_id) VALUES (1, 1), (2, 1);
INSERT INTO order_items VALUES (1, 1, 3, 1500), (1, 2, 1, 3000), (2, 1, 10, 1500);
-- تغيير المدينة: صف واحد فقط
UPDATE customers SET city = 'البصرة' WHERE id = 1;
-- تغيير سعر القلم لا يغيّر فواتير الماضي
UPDATE products SET price_fils = 2000 WHERE id = 1;
SELECT o.id AS order_id, c.name, c.city, SUM(oi.quantity * oi.unit_price_fils) / 1000.0 AS total_dinar
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.name, c.city;اختبر فهمك