المفاتيح والعلاقات

المفتاح عمود، أو مجموعة أعمدة، تستخدمه قاعدة البيانات العلائقية لتمييز الصفوف والربط بينها. فالمفتاح الأساسي (Primary key) يميّز كل صف في الجدول تمييزاً فريداً: في الجدول customers تعني القيمة ⁦42⁩ في العمود id عميلاً واحداً، لا عميلين أبداً. والمفتاح الأجنبي (Foreign key) عمود في جدول آخر يخزّن هذه القيمة ليشير إلى ذلك الصف: الطلب الذي فيه customer_id بقيمة ⁦42⁩ يخص العميل ⁦42⁩.

من هذين المفتاحين تُبنى العلاقات، والسلامة المرجعية (Referential integrity) هي القاعدة التي تحفظ تماسكها: كل قيمة في مفتاح أجنبي تشير إلى صف موجود فعلاً.

لماذا يهم

المفاتيح هي الطريقة التي يعثر بها التطبيق على بياناته من جديد. فعميلان بالمعرّف نفسه يعنيان أن موظف الدعم سيفتح الحساب الخاطئ؛ والطلب الذي يشير إلى عميل محذوف يُعطّل سجل الطلبات؛ وقاعدة حذف لم يطلبها أحد تمحو الفواتير مع عميلها. قد تبدو الشاشة سليمة بينما البيانات المخزّنة خاطئة فعلاً، ثم يظهر الإخفاق لاحقاً في تقرير أو ملف تصدير أو خدمة أخرى.

وبالنسبة إلى المختبِر، كل مفتاح أجنبي قاعدةٌ لها اختبار إيجابي (يُقبل المرجع الصحيح) واختبارات سلبية (يُرفض المرجع إلى أب غير موجود، وحذف الأب ينفّذ ما ينص عليه المتطلب).

المفتاح الأساسي: صف واحد وهوية واحدة

المفتاح الأساسي (Primary key أو PK) عمود، أو مجموعة أعمدة، قيمته فريدة في كل صف ولا تكون NULL أبداً. لكل جدول مفتاح أساسي واحد على الأكثر، وترفض قاعدة البيانات الصف الذي يكون مفتاحه NULL أو مستخدماً في صف آخر. والمفتاح الأساسي المكوّن من عدة أعمدة يسمى المفتاح المركّب (Composite key).

المفتاح الطبيعي (Natural key) مفتاح له معنى في العالم الحقيقي أصلاً، مثل رمز المنتج SKU. أما المفتاح البديل (Surrogate key) فلا معنى تجارياً له: عدد صحيح يتزايد تلقائياً، أو معرّف UUID، وهو معرّف بطول 128 بت يستطيع التطبيق توليده دون أن يطلب من قاعدة البيانات الرقم التالي. وكثيراً ما تستخدم الفرق مفتاحاً أساسياً بديلاً، وتضع على المعرّف الطبيعي قيد UNIQUE الذي يرفض أي قيمة يحملها صف آخر من قبل، لأن المعرّفات الحقيقية مثل البريد الإلكتروني تتغير.

  • التفرّد: يُرفض عميل ثانٍ ببريد إلكتروني مستخدم من قبل، برسالة واضحة لا بخطأ في الخادم.
  • الثبات: العميل الذي يغيّر بريده يحتفظ بقيمة id نفسها، فتبقى طلباته مشيرة إليه.
  • التوليد: لا تتصادم المفاتيح عند إنشاء سجلات بالتوازي، ولا بعد استيراد كتب معرّفاته بنفسه. فمثل هذا الاستيراد قد يترك عدّاد قاعدة البيانات متأخراً، فيحصل السجل الجديد التالي على معرّف مستخدم من قبل.
  • الانكشاف: المعرّفات المتسلسلة سهلة التخمين، لذا فتغيير الرقم في رابط مثل /orders/1043 للوصول إلى طلب عميل آخر هو اختبار للتحكم في الوصول. ومعرّف UUID أصعب تخميناً، لكنه لا يغني عن هذا الفحص.

جرّبها: مولّد UUID

المفتاح الأجنبي والسلامة المرجعية

المفتاح الأجنبي (Foreign key أو FK) عمود، أو مجموعة أعمدة، في جدول ابن (Child table) يجب أن تطابق قيمه المفتاح الأساسي، أو مفتاحاً فريداً آخر، في جدول أب (Parent table). ففي orders.customer_id REFERENCES customers (id) يكون orders هو الابن. وتتحقق السلامة المرجعية حين تطابق كل قيمة ليست NULL في المفتاح الأجنبي صفاً موجوداً في الأب؛ والصف الابن الذي يخالف ذلك صف يتيم (Orphan row).

عند التصريح بالمفتاح الأجنبي ترفض قاعدة البيانات طلباً لعميل غير موجود. وحذف أب ما زال له أبناء يُطلق الإجراء المرجعي (Referential action) المكتوب في المخطط. واختيار هذا الإجراء قرار يخص العمل، فأكّده مع مالك المنتج ثم اختبره:

  • NO ACTION (الافتراضي) أو RESTRICT: يُرفض الحذف، ولا يختلفان إلا في توقيت تنفيذ الفحص.
  • CASCADE: يُحذف الأبناء مع الأب.
  • SET NULL: يبقى الأبناء وتصبح قيمة مفتاحهم الأجنبي NULL، لذا يجب أن يقبل العمود NULL.
  • المفتاح الأجنبي الذي قيمته NULL يجتاز الفحص ما لم يكن العمود NOT NULL: وهذا صحيح في علاقة اختيارية، وعيب في علاقة إلزامية.
  • التصريح لا يعني الفرض دائماً: لا يفحص SQLite المفاتيح الأجنبية افتراضياً إلا بعد تنفيذ PRAGMA foreign_keys = ON على كل اتصال، ولا يمتد أي مفتاح أجنبي إلى خدمة أخرى. وهناك لا يحمي السلامة إلا التطبيق.

واحد إلى واحد، وواحد إلى متعدد، ومتعدد إلى متعدد

التعددية (Cardinality) تصف كم صفاً في طرف يرتبط بكم صفاً في الطرف الآخر. والأنواع الثلاثة كلها تُبنى بالمفتاحين أنفسهما؛ ولا يتغير إلا موضع المفتاح الأجنبي والقيد المفروض عليه.

  • واحد إلى متعدد (One-to-many، ⁦1:N⁩): للعميل الواحد طلبات كثيرة. يقع المفتاح الأجنبي في جانب «المتعدد»، أي orders.customer_id، وقد لا يكون للأب أي ابن.
  • واحد إلى واحد (One-to-one، ⁦1:1⁩): للعميل بطاقة ولاء واحدة على الأكثر. والمفتاح الأجنبي هنا عليه قيد UNIQUE أيضاً، فتُرفض بطاقة ثانية للعميل نفسه.
  • متعدد إلى متعدد (Many-to-many، ⁦N:M⁩): الطلب فيه منتجات كثيرة والمنتج يظهر في طلبات كثيرة. فيخزّن جدول وسيط (Junction table) مثل order_items صفاً لكل زوج، مع مفتاح أجنبي إلى كل طرف؛ ويمنع مفتاح أساسي مركّب على الزوج تكراره.
  • لا يستطيع أي مفتاح أجنبي أن يُلزم الأب بأن يكون له أبناء: فقاعدة «لكل طلب سطر واحد على الأقل» مسؤولية التطبيق، والطلب الفارغ عيب كلاسيكي يستحق البحث عنه.
موضع المفتاح الأجنبي والقيد الذي يحدّه هما ما يحددان العلاقة: في جانب «المتعدد» في ⁦1:N⁩، ومع قيد UNIQUE في ⁦1:1⁩، ومرتين في جدول وسيط بمفتاح مركّب في ⁦N:M⁩.

واحد إلى متعدد (⁦1:N⁩)

عميل واحد وطلبات كثيرة

customer_id INTEGER NOT NULL REFERENCES customers (id)

المفتاح الأجنبي:
في جانب «المتعدد»: orders.customer_id
القيد الذي يضع الحد:
لا يوجد: للعميل صفر طلبات أو أكثر
الاختبار السلبي:
طلب للعميل ⁦999⁩ غير الموجود: مرفوض

واحد إلى واحد (⁦1:1⁩)

عميل واحد وبطاقة ولاء واحدة على الأكثر

customer_id INTEGER NOT NULL UNIQUE REFERENCES customers (id)

المفتاح الأجنبي:
loyalty_cards.customer_id، مع قيد UNIQUE
القيد الذي يضع الحد:
قيد UNIQUE على المفتاح الأجنبي: بطاقة واحدة على الأكثر لكل عميل
الاختبار السلبي:
بطاقة ثانية للعميل ⁦42⁩: مرفوضة

متعدد إلى متعدد (⁦N:M⁩)

طلبات كثيرة ومنتجات كثيرة

PRIMARY KEY (order_id, product_id)

المفتاح الأجنبي:
مفتاحان في الجدول الوسيط order_items
القيد الذي يضع الحد:
المفتاح الأساسي المركّب: كل زوج من طلب ومنتج مرة واحدة على الأكثر
الاختبار السلبي:
المنتج نفسه مرتين في طلب واحد: مرفوض أو مدموج

مثال تطبيقي: اختبار مفاتيح متجر صغير

هذا المخطط جزء من أساس الاختبار (Test basis) لمتجر إلكتروني: كل عبارة REFERENCES علاقة، وكل عبارة ON DELETE، أو غيابها، قاعدة. ويؤكد مالك المنتج أن العميل الذي له طلبات لا يُحذف، وأن حذف الطلب يحذف أسطره، وأن المنتج الذي طُلب من قبل لا يُحذف. نفّذ كل إجراء عبر واجهة المستخدم أو الواجهة البرمجية (API)، ثم تحقّق من الصفوف المخزّنة.

  • طلب للعميل الموجود ⁦42⁩: مقبول، ويُخزَّن وفيه customer_id بقيمة ⁦42⁩.
  • طلب للعميل ⁦999⁩ غير الموجود، مُرسَل عبر الواجهة البرمجية: مرفوض برسالة تشرح الخطأ، لا بخطأ في الخادم، ولا يُخزَّن شيء.
  • حذف العميل ⁦42⁩ وله طلبات: مرفوض (RESTRICT).
  • بطاقة ولاء ثانية للعميل ⁦42⁩: مرفوضة بقيد UNIQUE على المفتاح الأجنبي.
  • المنتج نفسه مرتين في طلب واحد: مرفوض بالمفتاح المركّب، أو مدموج في سطر واحد إن نصّ المتطلب على ذلك.
  • حذف طلب: تُحذف صفوفه في order_items معه (CASCADE).
  • حذف منتج سبق طلبه: مرفوض (NO ACTION).
  • حذف عميل له بطاقة ولاء وليست له طلبات: مرفوض أيضاً، لأن الجدول loyalty_cards بلا قاعدة حذف (NO ACTION). لم ينصّ أحد على هذه القاعدة، فاسأل مالك المنتج هل هي مقصودة.
CREATE TABLE customers (
  id          INTEGER PRIMARY KEY,
  email       VARCHAR(254) NOT NULL UNIQUE,
  full_name   VARCHAR(100) NOT NULL
);

CREATE TABLE loyalty_cards (                 -- 1:1 with customers
  card_no     CHAR(10) PRIMARY KEY,
  customer_id INTEGER NOT NULL UNIQUE REFERENCES customers (id)
);

CREATE TABLE orders (                        -- 1:N: one customer, many orders
  id          INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers (id) ON DELETE RESTRICT,
  created_at  TIMESTAMP NOT NULL
);

CREATE TABLE products (
  id          INTEGER PRIMARY KEY,
  sku         VARCHAR(20) NOT NULL UNIQUE,
  name        VARCHAR(100) NOT NULL
);

CREATE TABLE order_items (                   -- N:M junction: orders x products
  order_id    INTEGER NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
  product_id  INTEGER NOT NULL REFERENCES products (id),
  quantity    INTEGER NOT NULL,
  PRIMARY KEY (order_id, product_id)
);

جرّبها: منسّق SQL جرّبها: مولّد جمل SQL INSERT

أخطاء شائعة

  • اختبار العلاقات عبر الشاشات فقط: فالقائمة المنسدلة التي تعرض العملاء الموجودين تُخفي المراجع الخاطئة التي ما زالت الواجهة البرمجية وعمليات الاستيراد والمهام الدفعية قادرة على إرسالها.
  • افتراض وجود مفتاح أجنبي لأن اسم العمود customer_id. فاصطلاح التسمية لا يفرض شيئاً.
  • اعتبار CASCADE صحيحاً دائماً أو خاطئاً دائماً: فالفواتير يجب غالباً أن تبقى بعد حذف عميلها، أما أسطر الطلب المحذوف فلا.
  • إهمال الجدول الوسيط: الأزواج المكررة، وما يحدث للأزواج عند حذف أحد الطرفين.
  • تشغيل اختبارات السلامة المرجعية في بيئة الفرض فيها معطّل، مثل SQLite دون PRAGMA foreign_keys = ON، ثم الإبلاغ بأنها ناجحة.

من منظور ضمان الجودة

ابدأ في جلسات التنقيح (Refinement): لكل مفتاح أجنبي جديد في نموذج البيانات أو في ملف الترحيل (Migration)، أي السكربت الذي يغيّر المخطط، اسأل: هل يجوز أن يكون فارغاً؟ وماذا يحدث للأبناء عند حذف الأب؟ ومن قرّر ذلك؟ تصبح الإجابات شروط اختبار، والسؤال الذي لا يجد جواباً عيبٌ في المتطلبات يُكتشف قبل كتابة أي شيفرة.

أثناء الاختبار نفّذ الإجراءات عبر المنتج وتحقّق في قاعدة البيانات، بما في ذلك المسارات التي تخفيها الواجهة. وبعد الترحيل والاستيراد والمهام الدفعية الفاشلة، افحص العلاقات التي لا تفرضها قاعدة البيانات بحثاً عن الصفوف اليتيمة. واذكر في تقرير العيب الجداول وقيم المفاتيح والسبب والقاعدة المكسورة، مثل: الطلب ⁦1043⁩ يشير إلى العميل ⁦77⁩ غير الموجود، وقد جاء الصف من الاستيراد الليلي.

تمرين عملي

اكتشف الثغرات في مخطط تسجيل الطلاب في الدورات

المطلوب

متطلبات منصة تدريب: يستطيع الطالب التسجيل في دورات كثيرة وللدورة طلاب كثيرون، لكن لا يُسجَّل أحد في الدورة نفسها مرتين؛ ولكل طالب بطاقة واحدة على الأكثر؛ ويُرفض حذف دورة فيها تسجيلات، بينما يؤدي حذف الطالب إلى حذف تسجيلاته.

حدّد أين لا يفرض المخطط أدناه هذه القواعد، واكتب شروط اختبار مع نتائجها المتوقعة.

المادة
CREATE TABLE students (
  id          INTEGER PRIMARY KEY,
  email       VARCHAR(254) NOT NULL UNIQUE
);

CREATE TABLE courses (
  id          INTEGER PRIMARY KEY,
  title       VARCHAR(120) NOT NULL
);

CREATE TABLE student_cards (
  card_no     CHAR(8) PRIMARY KEY,
  student_id  INTEGER NOT NULL REFERENCES students (id)
);

CREATE TABLE enrolments (
  id          INTEGER PRIMARY KEY,
  student_id  INTEGER NOT NULL REFERENCES students (id) ON DELETE CASCADE,
  course_id   INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE
);
الخطوات
  1. اكتب كل مفتاح أجنبي مع جدوله الابن وجدوله الأب وقاعدة الحذف.
  2. سمِّ العلاقة التي يصفها كل متطلب، وبيّن كيف يفرضها المخطط.
  3. اكتب شرط اختبار مع نتيجته المتوقعة لكل قاعدة ولكل ثغرة.
إظهار الإجابة النموذجية
الإجابة النموذجية

المفاتيح الأجنبية: student_cards.student_id يشير إلى students بلا قاعدة حذف (NO ACTION)؛ وenrolments.student_id يشير إلى students بالقاعدة CASCADE؛ وenrolments.course_id يشير إلى courses بالقاعدة CASCADE.

العلاقات: الطلاب والدورات علاقة ⁦N:M⁩ عبر الجدول الوسيط enrolments؛ والطلاب وبطاقات الطلاب علاقة ⁦1:1⁩. ويفرض المخطط قاعدة حذف الطالب فرضاً صحيحاً: ON DELETE CASCADE على enrolments.student_id.

الثغرة الأولى: لا يوجد قيد UNIQUE على (student_id, course_id) في enrolments. الاختبار: سجّل الطالب نفسه في الدورة نفسها مرتين، ومن ذلك باستدعاءين متوازيين للواجهة البرمجية. المتوقع: صف واحد، ورسالة واضحة للمحاولة الثانية. الإصلاح: UNIQUE (student_id, course_id)، أو جعل الزوج هو المفتاح الأساسي.

الثغرة الثانية: العمود student_cards.student_id ليس UNIQUE، فالمخطط يسمح بعلاقة ⁦1:N⁩. الاختبار: أصدر بطاقة ثانية للطالب نفسه. المتوقع: الرفض. الإصلاح: إضافة قيد UNIQUE على student_id.

الثغرة الثالثة: للعمود enrolments.course_id القاعدة ON DELETE CASCADE، فحذف دورة يحذف تسجيلاتها بصمت. الاختبار: احذف دورة فيها تسجيل. المتوقع: الرفض وبقاء الصفين. الإصلاح: ON DELETE RESTRICT، أو حذف العبارة ليُطبَّق الإجراء الافتراضي NO ACTION.

اختبر أيضاً: التسجيل في الدورة ⁦999⁩ غير الموجودة يُرفض؛ وحذف طالب له تسجيلات وليست له بطاقة يحذف الطالب وتسجيلاته، وتبقى الدورات. واسأل مالك المنتج عن الطالب الذي له بطاقة: ليس للجدول student_cards قاعدة حذف، فيُرفض حذف هذا الطالب.

أسئلة شائعة

هل يمكن أن يكون الجدول بلا مفتاح أساسي؟

تسمح بذلك معظم قواعد البيانات، لكن لا شيء عندها يضمن تمييز كل صف: لا يمكن التفريق بين الصفوف المكررة، وقد يصيب التحديث أو الحذف عدة صفوف، ولا تستطيع الجداول الأخرى الإشارة إلى صفوفه بثقة. تعامل مع الجدول الذي بلا مفتاح أساسي على أنه سؤال للفريق، وافحصه بحثاً عن التكرار.

هل العمود الذي عليه قيد UNIQUE هو نفسه المفتاح الأساسي؟

كلاهما يرفض القيم المكررة، لكن للجدول مفتاح أساسي واحد فقط، ولا تكون أعمدته NULL أبداً وفق معيار SQL. ويُستثنى من ذلك SQLite، فهو معروف بأنه يقبل NULL في معظم أعمدة المفتاح الأساسي غير المعرّفة بـNOT NULL، حفاظاً على التوافق مع التطبيقات القديمة. أما قيود UNIQUE فقد يكون منها عدة قيود في الجدول، والعمود الفريد يقبل NULL عادةً، ويختلف عدد قيم NULL المقبولة بين قواعد البيانات. ويمكن للمفتاح الأجنبي أن يشير إلى أيٍّ منهما.

إذا كانت قاعدة البيانات تفرض المفاتيح الأجنبية، فهل أختبر السلامة المرجعية؟

نعم، لكنك لا تختبر محرك قاعدة البيانات. أنت تتحقق من وجود القيود الصحيحة بقواعد الحذف الصحيحة، ومن أنها مفعّلة في كل بيئة، ومن أن التطبيق يرد على الرفض برسالة واضحة لا بخطأ في الخادم. وحيث لا يمكن وجود مفتاح أجنبي، كما بين خدمتين، تكون فحوص التطبيق نفسه هي الحماية الوحيدة.

كيف أجد الصفوف اليتيمة؟

اربط الجدول الابن بجدوله الأب واحتفظ بالصفوف الأبناء التي لا مقابل لها، مثل SELECT o.id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL. ويشرح درس ربط الجداول في SQL (JOINs) هذا النمط خطوة بخطوة.

المراجع

تدرّب بأدوات متقن

تنسيق استعلامات SQL بحالة أحرف متسقة للكلمات المفتاحية ومسافات بادئة وفواصل أسطر (MySQL وPostgreSQL وSQL Server وOracle وSQLite).

قواعد البيانات

فحص جمل SQL بحثاً عن مشكلات الصياغة والأقواس غير المتوازنة والسلاسل النصية غير المغلقة والأنماط الخطرة.

قواعد البيانات

توليد جمل INSERT من تعريف جدول أو صفوف نموذجية أو CSV أو JSON.

قواعد البيانات

توليد معرّفات UUID v4 (عشوائية) وv7 (مرتّبة زمنياً) بالجملة، والتحقق من المعرّفات الموجودة.

أدوات مساعدة

للتعمّق أكثر

تقدّمك في هذا الموضوع

جزء من مسار تعلم ضمان الجودة