SQL: تصميم قواعد البيانات والتطبيع
درس 3 من 5
3SQL: تصميم قواعد البيانات والتطبيع (Database Design & Normalization)
الصورة الكبيرةكيف تحوّل وصفًا لمجال حقيقي إلى جداول سليمة: تحدد الكيانات والعلاقات، وتكتشف التكرار وشذوذ التعديل، وتفككه بالتطبيع حتى الصيغة الثالثة، وتحمي النتيجة بالقيود.
ماذا تشاهد في الرسم: في الجدول الأول اسم سارة ومدينتها مكتوبان مرتين. التطبيع ينقل العميل إلى جدوله ويترك في الطلب رقمه فقط، فيصير التعديل مرة واحدة.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
تصميم قاعدة البيانات مثل ترتيب مستودع. لو كتبت اسم المورّد وهاتفه على كل صندوق، فحين يغيّر رقمه ستعدّل مئة صندوق وتنسى بعضها. التطبيع أن تكتب المعلومة في مكان واحد فقط وتشير إليها من بقية الأماكن، فيصير التعديل مرة واحدة.
تبدأ بالكيانات والسمات والعلاقات (ER)، ثم تختار المفاتيح. التكرار يسبّب شذوذ الإدراج والتعديل والحذف. الاعتماد الوظيفي يكشف ما يحدده ماذا، والتطبيع يفكك الجداول: 1NF قيم ذرية، و2NF لا اعتماد جزئي على المفتاح، و3NF لا اعتماد متعدٍّ. ثم تحمي النتيجة بالقيود.
الأساسيات المطلوبة
ثلاثة أشياء يجب أن تكون ثابتة قبل أن تصمّم:
المفاتيح والعلاقات (درس SQL: الربط)
المفتاح الأساسي يعرّف الصف، والأجنبي يربط بالأب، والعلاقة واحد-لواحد أو واحد-لمتعدد أو متعدد-لمتعدد (بجدول وسيط). التصميم هو أساسًا قرار: أين أضع كل مفتاح؟
القيود (درس SQL: عمليات CRUD)
NOT NULL و UNIQUE و CHECK و DEFAULT هي الطريقة التي «تكتب» بها قواعد المجال داخل القاعدة نفسها بدل الاعتماد على انضباط من يكتب التطبيق.
JOIN (درس SQL: الربط)
التطبيع يفتّت البيانات إلى جداول أصغر، والربط يعيد تجميعها. إن لم تكن مرتاحًا في ربط ثلاثة جداول أو أكثر فاجعل الجزء السابق أمامك وأنت تقرأ هذا.
المفاهيم الأساسية
التصميم الجيد يجعل الخطأ صعب الحدوث: كل معلومة تُخزَّن في مكان واحد فقط، والقواعد مكتوبة كقيود، فلا يستطيع أحد أن يُدخل بيانات متناقضة حتى لو أراد. والتطبيع هو الأسلوب المنهجي لبلوغ ذلك.
1الكيان والخاصية والعلاقة
الكيان (Entity)
«شيء» له وجود مستقل ونريد تتبّعه: زبون، مشروب، طلب. يصبح عادةً جدولًا واحدًا.
الخاصية (Attribute)
معلومة تصف الكيان: اسم الزبون، سعر المشروب. تصبح عمودًا. إن كانت للخاصية خصائص هي الأخرى (المدينة لها منطقة) فربما هي كيان بحد ذاتها.
العلاقة (Relationship)
كيف يرتبط كيانان، مع العدد (cardinality): زبون واحد → طلبات كثيرة. تُنفَّذ بمفتاح أجنبي أو بجدول وسيط.
2المفاتيح
| المصطلح | المعنى | مثال |
|---|---|---|
| مفتاح أساسي (Primary Key) | المفتاح المختار لتعريف الصف؛ فريد وغير فارغ. | drinks.id |
| مفتاح مرشّح (Candidate Key) | أي عمود أو مجموعة أعمدة يصلح أن يكون مفتاحًا (فريد وأدنى ما يمكن). تختار واحدًا ليصبح الأساسي، وتحمي البقية بـ UNIQUE. | customers.phone UNIQUE |
| مفتاح اصطناعي (Surrogate Key) | رقم يولّده النظام ولا معنى له خارجه (id). ثابت لا يتغيّر حين يتغيّر رقم الهاتف أو الاسم. | customers.id |
| مفتاح طبيعي (Natural Key) | قيمة حقيقية من المجال (هاتف، بريد، رقم هوية). مفهومة لكنها قد تتغيّر أو تتكرر أو تُدخَل خطأ. | phone |
| مفتاح مركّب (Composite Key) | مفتاح من عدة أعمدة معًا، شائع في الجداول الوسيطة. | (order_id, drink_id) |
3لماذا نطبّع؟ الشذوذ (Anomalies)
جدول واحد ضخم يخزّن كل شيء (كل سطر فيه الزبون والمشروب والباريستا) يكرّر المعلومة، والتكرار يولّد ثلاث مشكلات:
شذوذ التعديل
معلومة واحدة (مدينة نورة) مكتوبة في ثلاثة صفوف؛ إن عدّلت صفًّا ونسيت الباقي صار عندك نسختان متناقضتان.
شذوذ الإدراج
لا يمكنك تسجيل مشروب جديد لم يُبَع بعد، لأن الصف يحتاج طلبًا؛ فتضطر لاختلاق طلب وهمي.
شذوذ الحذف
حذف آخر طلب لسلطان يمحو معه كل لا نعرفه عنه وعن «سبانش لاتيه» لأن وجودهما كان معلّقًا بذلك الصف.
drinks = 'Flat White, Cortado') أو إنشاء أعمدة متكررة (drink1, drink2, drink3). الأول يمنع البحث والفحص الدقيقين، والثاني يضع سقفًا مصطنعًا للعدد. الحل الصحيح دائمًا: جدول وسيط وصف لكل قيمة.
4الاعتماد الوظيفي (Functional Dependency)
نقول إن A → B (A يحدّد B) إذا كانت قيمة A تعيّن قيمة B بلا لبس: drink_id → drink_name, price. هذه الفكرة هي مفتاح التطبيع كله: اسأل في كل عمود: ما الذي يحدّده؟ فإن حدّدته قيمة ليست مفتاح الجدول كاملًا فالعمود في المكان الخطأ.
5الصيغ الطبيعية
| الصيغة | القاعدة | علامة الانتهاك | العلاج |
|---|---|---|---|
| 1NF | كل خلية قيمة واحدة ذرّية (لا قوائم ولا أعمدة مكرّرة مثل drink1, drink2)، وكل صف مميّز بمفتاح. | نص مثل 'Flat White, Cortado' في خلية | صف مستقل لكل قيمة |
| 2NF | 1NF و لا يعتمد أي عمود غير مفتاحي على جزء من مفتاح مركّب. | drink_name تتحدد بـ drink_id وحده داخل مفتاح (order_id, drink_id) | انقل المعتمد على جزء المفتاح إلى جدول خاص به |
| 3NF | 2NF و لا يعتمد عمود غير مفتاحي على عمود غير مفتاحي آخر (لا اعتماد متعدٍّ transitive). | customer_city تتحدد بـ customer_phone وهو ليس مفتاح الجدول | انقل المجموعة المعتمدة إلى جدول مفتاحه العمود المحدِّد |
| BCNF | صيغة أشد قليلًا من 3NF: كل محدِّد (طرف أيسر في اعتماد وظيفي) يجب أن يكون مفتاحًا مرشحًا. نادرًا ما تفترق عن 3NF في تصاميم بسيطة. | يظهر مع مفاتيح مرشحة متداخلة | فكّك حتى يصبح كل محدِّد مفتاحًا |
6قرارات تصميم صغيرة لكنها مهمة
المال أعداد صحيحة
خزّن السعر بأصغر وحدة (هللة) في INTEGER؛ الأعداد العشرية تُراكم أخطاء تقريب في الجمع والمقارنة.
التواريخ نصًّا بصيغة ISO
'2026-10-03' نص يُرتَّب ترتيبًا زمنيًا صحيحًا ويفهمه date() في SQLite؛ لا يوجد نوع تاريخ مستقل في SQLite.
لا تخزّن المشتق
مجموع الفاتورة = مجموع qty × price؛ احسبه بالاستعلام. تخزينه يخلق معلومة ثانية قد تخالف الأولى.
أسماء متسقة
اختر نمطًا واحدًا (snake_case، المفرد أو الجمع للجداول) وطبّقه كله، فالقارئ لا يجب أن يخمّن.
unit_price_halalas في order_items: هذه ليست نسخة زائدة، بل حقيقة مختلفة (سعر لحظة البيع). والعكس صحيح أيضًا: يلجأ البعض لـإلغاء التطبيع (denormalization) عمدًا لتسريع تقارير ضخمة، لكنه تنازل واعٍ يُدفع ثمنه بإبقاء النسخ متطابقة يدويًا.
كيف نصمّم خطوة بخطوة؟
- صِف المجال بجمل: «الزبون يقدّم طلبات، والطلب يحوي مشروبات بكميات، والباريستا يحضّر الطلب».
- استخرج الكيانات والخصائص: الأسماء تصبح جداول، والصفات تصبح أعمدة.
- اختر مفتاحًا لكل كيان (غالبًا
idاصطناعي) وحدّد المفاتيح المرشحة التي تحميها بـUNIQUE. - حدّد العلاقات والأعداد وضع المفتاح الأجنبي في جانب «المتعدد»، وجدولًا وسيطًا للمتعدد-لمتعدد.
- طبّع: اكتب الاعتمادات الوظيفية وافحص 1NF ثم 2NF ثم 3NF.
- أضف القيود:
NOT NULLوCHECKوDEFAULT، وقرّر إجراءON DELETEلكل مفتاح أجنبي. - اختبر بالبيانات: ضع عينة صغيرة واسأل أسئلة حقيقية؛ إن بدا استعلام طبيعي عسيرًا فراجع التصميم.
الرسم 26: مسار التطبيع: كل خطوة تزيل نوعًا محددًا من التكرار.
الرسم 27: المخطط النهائي لمقهانا بعد التطبيع: خمسة جداول، لكل معلومة مكان واحد.
الرسم 28: اختيار إجراء ON DELETE هو سؤال عن معنى العلاقة لا عن الصياغة.
مثال تطبيقي: من سجل مبيعات واحد إلى مخطط سليم
تخيّل أن أحدهم سجّل مبيعات المقهى في جدول واحد، لكل سطر فيه كل شيء:
الخطوة 1: الجدول الأول (غير المطبّع)
CREATE TABLE sales_log (
order_id INTEGER,
drink_id INTEGER,
drink_name TEXT,
drink_category TEXT,
drink_price_halalas INTEGER,
customer_phone TEXT,
customer_name TEXT,
customer_city TEXT,
barista_id INTEGER,
barista_name TEXT,
qty INTEGER,
order_date TEXT,
PRIMARY KEY (order_id, drink_id)
);
| order_id | drink_id | drink_name | customer_name | customer_city | barista_name | qty |
|---|---|---|---|---|---|---|
| 101 | 1 | Flat White | Noura | Riyadh | Majed | 2 |
| 101 | 4 | Cortado | Noura | Riyadh | Majed | 1 |
| 102 | 2 | Cold Brew | Noura | Riyadh | Reem | 1 |
| 103 | 3 | Spanish Latte | Sultan | Jeddah | Reem | 2 |
(عرضنا سبعة أعمدة فقط للاختصار؛ الجدول فيه اثنا عشر عمودًا بما فيها رقم هاتف الزبون ورقم الباريستا وسعر المشروب.)
هل هو في 1NF؟ نعم: كل خلية قيمة واحدة والمفتاح (order_id, drink_id) يميّز كل صف. مثال على ما لا يكون في 1NF:
| order_id | customer | drinks |
|---|---|---|
| 101 | Noura | Flat White, Cortado |
| 102 | Noura | Cold Brew |
الخطوة 2: اكتشف الاعتمادات الوظيفية
| الاعتماد | الحكم |
|---|---|
(order_id, drink_id) → qty | يعتمد على المفتاح كله، مكانه سليم. |
drink_id → drink_name, drink_category, drink_price_halalas | اعتماد على جزء من المفتاح ⇒ انتهاك 2NF. |
order_id → customer_phone, barista_id, order_date | اعتماد على جزء من المفتاح ⇒ انتهاك 2NF. |
customer_phone → customer_name, customer_city | عمود غير مفتاحي يحدّد أعمدة غير مفتاحية ⇒ انتهاك 3NF (اعتماد متعدٍّ). |
barista_id → barista_name | اعتماد متعدٍّ آخر ⇒ انتهاك 3NF. |
الخطوة 3: شاهد الشذوذ بنفسك
التعديل: انتقلت نورة إلى جدة، وعدّلنا صفًّا واحدًا فقط ونسينا البقية:
UPDATE sales_log SET customer_city = 'Jeddah' WHERE order_id = 101 AND drink_id = 1; -- 1 row changed SELECT DISTINCT customer_phone, customer_name, customer_city FROM sales_log ORDER BY 1, 3;
| customer_phone | customer_name | customer_city |
|---|---|---|
| 0501110001 | Noura | Jeddah |
| 0501110001 | Noura | Riyadh |
| 0502220002 | Sultan | Jeddah |
نورة الآن في مدينتين! والتعديل الصحيح كان يحتاج WHERE customer_phone = … ليصيب ثلاثة صفوف. الحذف: إن حذفنا الطلب 103 فقد اختفى «سبانش لاتيه» وسلطان من القاعدة تمامًا (عدّ الصفوف التي تذكرهما فأعطى 0). الإدراج: لا مكان لمشروب جديد لم يُبَع بعد.
الخطوة 4: التفكيك إلى 3NF
كل مجموعة معتمدة على محدّدها تنتقل إلى جدول مفتاحه ذلك المحدّد. 2NF: ننقل أعمدة drink_id إلى drinks وأعمدة order_id إلى orders. 3NF: ننقل أعمدة customer_phone إلى customers وأعمدة barista_id إلى baristas. الناتج:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
phone TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
city TEXT NOT NULL
);
CREATE TABLE baristas (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE drinks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL CHECK (category IN ('hot', 'cold')),
price_halalas INTEGER NOT NULL CHECK (price_halalas > 0)
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
barista_id INTEGER NOT NULL REFERENCES baristas(id),
order_date TEXT NOT NULL
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
drink_id INTEGER NOT NULL REFERENCES drinks(id) ON DELETE RESTRICT,
qty INTEGER NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, drink_id)
);
لاحظ القرارات: phone مفتاح مرشح محميّ بـ UNIQUE بينما id هو الأساسي؛ CHECK يحمي الفئة والسعر والكمية؛ وإجراءات ON DELETE الثلاثة مختلفة عن قصد (الرسم 28).
الخطوة 5: انقل البيانات القديمة
INSERT INTO customers (phone, name, city) SELECT DISTINCT customer_phone, customer_name, customer_city FROM sales_log; INSERT INTO baristas (id, name) SELECT DISTINCT barista_id, barista_name FROM sales_log; INSERT INTO drinks (id, name, category, price_halalas) SELECT DISTINCT drink_id, drink_name, drink_category, drink_price_halalas FROM sales_log; INSERT INTO orders (id, customer_id, barista_id, order_date) SELECT DISTINCT s.order_id, c.id, s.barista_id, s.order_date FROM sales_log s JOIN customers c ON c.phone = s.customer_phone; INSERT INTO order_items (order_id, drink_id, qty) SELECT order_id, drink_id, qty FROM sales_log;
INSERT … SELECT DISTINCT يُنزِل كل معلومة مرة واحدة. ثم نملأ orders بربط sales_log بجدول الزبائن الجديد لنأخذ id الزبون من رقم هاتفه. الجداول الجديدة:
| id | phone | name | city |
|---|---|---|---|
| 1 | 0501110001 | Noura | Riyadh |
| 2 | 0502220002 | Sultan | Jeddah |
| id | name |
|---|---|
| 7 | Majed |
| 9 | Reem |
| id | customer_id | barista_id | order_date |
|---|---|---|---|
| 101 | 1 | 7 | 2026-10-01 |
| 102 | 1 | 9 | 2026-10-03 |
| 103 | 2 | 9 | 2026-10-03 |
الخطوة 6: تحقق أننا لم نفقد شيئًا
نعيد بناء الشكل المسطّح بربط الجداول الخمسة:
SELECT oi.order_id, d.name AS drink, c.name AS customer, c.city, b.name AS barista, oi.qty FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN drinks d ON d.id = oi.drink_id JOIN customers c ON c.id = o.customer_id JOIN baristas b ON b.id = o.barista_id ORDER BY oi.order_id, d.id;
| order_id | drink | customer | city | barista | qty |
|---|---|---|---|---|---|
| 101 | Flat White | Noura | Riyadh | Majed | 2 |
| 101 | Cortado | Noura | Riyadh | Majed | 1 |
| 102 | Cold Brew | Noura | Riyadh | Reem | 1 |
| 103 | Spanish Latte | Sultan | Jeddah | Reem | 2 |
وفي اختبارنا الكامل (كل الأعمدة الاثنا عشر) طابقت نتيجة الربط جدول sales_log الأصلي صفًّا بصف. التفكيك بلا فقد (lossless): ما فككناه يمكن إعادة تجميعه تمامًا.
الخطوة 7: زالت الشذوذات
تغيير مدينة نورة صار UPDATE customers SET city = 'Jeddah' WHERE phone = '0501110001' الذي يغيّر صفًّا واحدًا فقط. وحذف الطلب لا يمحو المشروب ولا الزبون. وتسجيل مشروب جديد صار INSERT INTO drinks … دون أي طلب.
الخطوة 8: اختبر إجراءات ON DELETE
بعد PRAGMA foreign_keys = ON: حذف الزبون 1 (SET NULL) أبقى طلباته بلا زبون:
DELETE FROM customers WHERE id = 1; SELECT id, customer_id FROM orders ORDER BY id;
| id | customer_id |
|---|---|
| 101 | NULL |
| 102 | NULL |
| 103 | 2 |
وحذف الطلب 101 (CASCADE) أزال بنوده تلقائيًا:
DELETE FROM orders WHERE id = 101; SELECT order_id, drink_id FROM order_items ORDER BY 1, 2;
| order_id | drink_id |
|---|---|
| 102 | 2 |
| 103 | 3 |
أما مشروب مستخدم في طلب (RESTRICT) فيرفض المحرّك حذفه:
| المحاولة | النتيجة |
|---|---|
DELETE FROM drinks WHERE id = 2 (مشروب داخل طلب) | FOREIGN KEY constraint failed |
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| كلما زادت الجداول كان التصميم أفضل | الهدف إزالة التكرار والشذوذ لا زيادة العدد. تفكيك بلا سبب يضيف ربطًا بلا فائدة. |
| رقم الهاتف أو البريد أفضل مفتاح أساسي لأنه حقيقي | القيم الحقيقية تتغيّر وتُخطأ كتابتها. الأفضل مفتاح اصطناعي ثابت مع UNIQUE على القيمة الحقيقية. |
| إن كان الجدول في 3NF فهو مثالي | 3NF تزيل أنواعًا محددة من التكرار فقط. تبقى قواعد المجال (قيود) وقرارات مثل سعر لحظة البيع. |
| إلغاء التطبيع خطأ دائمًا | قد يكون قرارًا واعيًا لتسريع قراءة ضخمة، لكن ثمنه إبقاء النسخ متطابقة. يُبدأ دائمًا بتصميم مطبّع. |
| يكفي أن يتحقق التطبيق من البيانات | التطبيقات تتعدد وتتغيّر والأخطاء تحدث. القيود داخل القاعدة هي خط الدفاع الأخير الذي لا يمكن تجاوزه. |
أين يُستخدم التصميم الجيد فعليًا؟
الأنظمة المالية
الأرصدة والمعاملات لا تحتمل نسختين متناقضتين من المعلومة؛ التطبيع والقيود شرطان لا خيار.
السجلات الصحية
بيانات المريض تُشارَك بين أقسام كثيرة، ومكان واحد للمعلومة يعني تحديثًا واحدًا يراه الجميع.
المتاجر الإلكترونية
المنتجات والعملاء والطلبات والبنود هي بالضبط نموذج مقهانا، وسعر لحظة البيع محفوظ في بند الطلب.
الترحيل والتنظيف
تحويل ملف Excel أو CSV مسطّح إلى قاعدة سليمة هو بالضبط ما فعلناه بـ INSERT … SELECT DISTINCT.
جرّب بنفسك
جدول طلبات يكرّر اسم العميل ومدينته في كل صف. ما المشكلة الأساسية؟
المعلومة المكرّرة تتعارض نسخها مع الوقت. التطبيع يضعها في جدول واحد ويشير إليها برقم.
بطاقات مراجعة سريعة
UNIQUE.CASCADE ومتى RESTRICT؟CASCADE حين لا معنى للأبناء بدون الأب (بنود الطلب)؛ RESTRICT حين يجب منع حذف الأب ما دام مستخدمًا (مشروب في طلبات).اختبر فهمك
1. (استرجاع) ما الذي تتطلبه 1NF؟
خلية واحدة = قيمة واحدة ذرّية (لا قوائم ولا أعمدة مكررة)، وكل صف مميّز بمفتاح.
2. (لماذا) لماذا يخلق وجود customer_city في جدول المبيعات خطرًا بينما وجودها في جدول الزبائن لا يخلقه؟
في المبيعات تتكرر مع كل سطر للزبون نفسه فتتفرع النسخ إن عُدّل بعضها. في جدول الزبائن يوجد سطر واحد للزبون فلا يمكن أن تتناقض.
3. (اكتشف الخطأ) جدول order_items(order_id, drink_id, qty, drink_name) مفتاحه (order_id, drink_id). أي صيغة ينتهكها؟ وما الحل؟
2NF: drink_name تعتمد على drink_id وحده (جزء من المفتاح). الحل: احذفها من الجدول واتركها في drinks.
4. (اكتشف الخطأ) جدول orders(id, customer_id, customer_city). أي صيغة ينتهكها؟
3NF: customer_city تعتمد على customer_id (عمود غير مفتاحي) لا على id مباشرة، اعتماد متعدٍّ. مكانها جدول customers.
5. (قرار) عند حذف باريستا من القاعدة، أي إجراء ON DELETE تختار للمفتاح orders.barista_id الذي عُرِّف NOT NULL؟ ولماذا لا يصلح SET NULL؟
SET NULL يتعارض مع NOT NULL فسيفشل الحذف. الاختيار المنطقي RESTRICT (أو NO ACTION): نمنع حذف باريستا له طلبات سابقة، أو نغيّر التصميم ليقبل العمود NULL إن أردنا الاحتفاظ بالطلبات.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
مقالات2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.