القيود وسلامة البيانات

القيد (Constraint) قاعدة مُعلَنة في مخطط قاعدة البيانات (Schema). تفحصها قاعدة البيانات عند كل إدراج وتحديث وحذف، وترفض أي جملة تكسرها أياً كان التطبيق الذي أرسلها. ويشترك معيار SQL ومحركات قواعد البيانات العلائقية الكبرى في مجموعة أساسية: NOT NULL وUNIQUE وPRIMARY KEY وFOREIGN KEY وCHECK، وتُدرَّس معها عادةً القيمة الافتراضية DEFAULT.

مثال: مع CHECK (quantity BETWEEN 1 AND 50) على أسطر الطلبات، ترفض قاعدة البيانات نفسها أي سطر كميته ⁦0⁩، سواء جاء من نموذج الويب أو من استيراد ملف CSV أو من سكربت كتبه مطوّر. وسلامة البيانات (Data Integrity) هي الهدف: بيانات كاملة وصالحة ومتسقة، والقيود هي الجزء الذي تستطيع قاعدة البيانات أن تضمنه منها.

لماذا يهم

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

والبيانات الخاطئة تنتشر إلى التقارير والفواتير والأنظمة الأخرى قبل أن ينتبه أحد، وإصلاحها يحتاج إلى تصحيح للبيانات لا إلى تصحيح للشيفرة فقط. وبالنسبة إلى المختبِر، كل قيد متطلب دقيق: جملة يجب أن تُرفض وأخرى يجب أن تُقبل، وغياب القيد يمكن إثباته بجملة واحدة.

ستة قيود وما يرفضه كل منها

تعرّفت إلى المفتاحين الأساسي والأجنبي في درس المفاتيح والعلاقات؛ وهما هنا قيدان من ستة. والجدول order_lines في نهاية هذا القسم يعلن القيود الستة كلها، وبقية الدرس تختبره.

  • NOT NULL: يجب أن يحمل العمود قيمة. والنص الفارغ '' قيمة، لذا يقبل NOT NULL وحده رمز SKU فارغاً (وOracle هو الاستثناء المعروف: يخزّن '' على أنه NULL).
  • UNIQUE: لا يجوز أن يتشارك صفّان القيمة نفسها أو التركيبة نفسها: فالقيد UNIQUE (order_id, sku) يسمح بكل رمز SKU مرة واحدة في كل طلب. وNULL لا يساوي NULL، لذا تقبل PostgreSQL وMySQL وSQLite عدة قيم NULL في عمود UNIQUE، بينما يقبل SQL Server قيمة واحدة.
  • PRIMARY KEY: يساوي UNIQUE مع NOT NULL، ولا يكون للجدول إلا مفتاح أساسي واحد.
  • FOREIGN KEY: يجب أن توجد القيمة في المفتاح المُشار إليه، أو أن تكون NULL. ومن دون عبارة ON DELETE يُرفض حذف أب ما زال له أبناء؛ وتغيّر ON DELETE CASCADE أو SET NULL هذا السلوك، والمتطلب هو الذي يحدد الصحيح منها.
  • CHECK: شرط يجب أن يحققه كل صف. ولا يفشل إلا حين يكون الشرط خاطئاً (false)؛ والمقارنة مع NULL نتيجتها «غير معروفة»، لذا يُمرّر CHECK (quantity > 0) القيمة NULL ما لم يكن العمود NOT NULL أيضاً.
  • DEFAULT: القيمة التي تُخزَّن حين يُغفل الإدراج العمود. وهي لا ترفض شيئاً، والإدراج الذي يرسل NULL صراحةً لا يحصل على القيمة الافتراضية.
-- orders(id) already exists
CREATE TABLE order_lines (
  id        INTEGER      PRIMARY KEY,
  order_id  INTEGER      NOT NULL REFERENCES orders (id),
  sku       VARCHAR(20)  NOT NULL,
  quantity  INTEGER      NOT NULL CHECK (quantity BETWEEN 1 AND 50),
  status    VARCHAR(10)  NOT NULL DEFAULT 'open'
            CHECK (status IN ('open', 'shipped', 'returned')),
  UNIQUE (order_id, sku)
);

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

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

  • سلامة الكيان (Entity integrity): كل صف يمكن تمييزه. يحميها PRIMARY KEY، وUNIQUE على المفاتيح الطبيعية مثل البريد الإلكتروني.
  • السلامة المرجعية (Referential integrity): كل مرجع يشير إلى صف موجود. يحميها FOREIGN KEY، ومخالفاتها هي الصفوف اليتيمة (Orphan rows).
  • سلامة النطاق (Domain integrity): كل قيمة صالحة لعمودها. يحميها نوع البيانات وطولها وNOT NULL وCHECK وDEFAULT.
  • السلامة المعرّفة من المستخدم (User-defined integrity) أو سلامة قواعد العمل: قواعد خاصة بالعمل، مثل «لا يكون للطبيب موعدان متداخلان». القاعدة التي تخص صفاً واحداً يمكن أن تكون CHECK؛ أما القاعدة التي تمتد عبر صفوف أو جداول فتحتاج عادةً إلى مُشغِّل (Trigger) أو شيفرة في التطبيق، وهذا موضوع درس الإجراءات المخزنة والمشغِّلات.

كيف تختبر قيداً

اختبر على طبقتين. في قاعدة البيانات أثبت أن القاعدة موجودة ومفروضة فعلاً. وعبر المنتج أثبت أن الرفض يتحول إلى رسالة واضحة: فالقاعدة المُعلَنة التي يصل خطؤها إلى المستخدم في صفحة خطأ 500 ما زالت عيباً.

ابدأ من المخطط الذي يعمل فعلاً، لا من وثيقة التصميم: اقرأ ملفات الترحيل (Migrations)، أو اسأل قاعدة البيانات باستعلامات information_schema في نهاية هذا القسم، وهي العروض (Views) القياسية التي تصف فيها قاعدة البيانات جداولها وأعمدتها وقيودها (توفّرها PostgreSQL وMySQL وSQL Server). ثم لكل قاعدة:

  • قاعدة واحدة واختباران، كما في تحليل القيم الحدّية: القيمة التي يجب أن تُرفض، وأقرب قيمة يجب أن تُقبل. ففي BETWEEN 1 AND 50 تُرفض ⁦0⁩ و⁦51⁩ وتُقبل ⁦1⁩ و⁦50⁩؛ وفي VARCHAR(20) تُرفض ⁦21⁩ حرفاً وتُقبل ⁦20⁩.
  • اكسر عموداً واحداً في كل مرة وتأكد من القاعدة التي رفضت. فالصف الذي يكسر قاعدتين يرفضه أول قيد يفحصه المحرك. ويعيد PostgreSQL رمز SQLSTATE، وهو رمز الخطأ القياسي المكوّن من خمسة محارف: 23502 لقيد NOT NULL، و23505 لقيدي UNIQUE وPRIMARY KEY، و23503 لقيد FOREIGN KEY، و23514 لقيد CHECK، وكلها من الفئة ⁦23⁩: مخالفة قيد سلامة (Integrity constraint violation).
  • كرّر كل مخالفة عبر واجهة المستخدم وعبر API: توقّع رسالة واضحة أو رمز الحالة من فئة 4xx الذي يحدده عقد API، مثل 409 أو 422، ولا تقبل أبداً استجابة 500 تكشف أسماء الجداول أو القيود.
  • اختبر المسارات التي تتجاوز تحقق التطبيق: الاستيراد والمهام الدفعية وأدوات الإدارة وطلبين متطابقين في اللحظة نفسها. فالشيفرة التي تسأل «هل هذا البريد موجود؟» قبل الإدراج قد تسمح بمرور الطلبين المتسابقين؛ ولا يوقف الثاني إلا قيد UNIQUE.
  • اعرف محركك: لا يفرض MySQL قيود CHECK إلا بدءاً من الإصدار ⁦8.0.16⁩، وقبل الإصدار ⁦9.0⁩ يقرأ عبارة REFERENCES المكتوبة داخل تعريف العمود ثم يتجاهلها، كما في order_lines، لذا يجب أن يُعلَن المفتاح هناك منفصلاً: FOREIGN KEY (order_id) REFERENCES orders (id). ولا يفرض SQLite المفاتيح الأجنبية إلا بعد PRAGMA foreign_keys = ON، ويتجاهل الطول في VARCHAR(20).
  • وحيث لا يوجد قيد لقاعدة ما، افحص البيانات المخزنة باستعلامات التكرار والصفوف اليتيمة من دليل SQL للمختبِرين.
SELECT constraint_name, constraint_type
FROM information_schema.table_constraints
WHERE table_name = 'order_lines';

SELECT column_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'order_lines';

جرّبها: مولّد القيم الحدّية جرّبها: عدّاد طول السلاسل النصية

مثال تطبيقي: سكربت لاختبار القيود

الطلب ⁦7⁩ موجود في orders والطلب ⁦9999⁩ غير موجود. تُدرج T0 صفاً صالحاً، وكل جملة بعدها تكسر قاعدة واحدة بالضبط. وتذكر التعليقات النتيجة المتوقعة ورمز الخطأ في PostgreSQL.

نفّذ الجمل واحدة واحدة في قاعدة بيانات اختبار يمكن التخلص منها. ففي PostgreSQL تُلغي الجملة الفاشلة المعاملة (Transaction) التي تحتويها، لذا لو وُضعت الجمل داخل كتلة BEGIN واحدة لفشلت كل جملة بعد T1 لسبب خاطئ.

-- Disposable test database, autocommit: run one statement at a time.
-- T0 valid row -> accepted; status gets the DEFAULT 'open'
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (1, 7, 'A-100', 2);
-- T1 PRIMARY KEY: id 1 again -> rejected, 23505
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (1, 7, 'B-200', 2);
-- T2 UNIQUE (order_id, sku): A-100 on order 7 again -> rejected, 23505
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (2, 7, 'A-100', 1);
-- T3 FOREIGN KEY: order 9999 does not exist -> rejected, 23503
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (3, 9999, 'C-300', 1);
-- T4 NOT NULL: no SKU -> rejected, 23502
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (4, 7, NULL, 1);
-- T5 CHECK quantity: 0 -> rejected, 23514 (also 51; 1 and 50 are accepted)
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (5, 7, 'D-400', 0);
-- T6 CHECK status: 'lost' is not in the list -> rejected, 23514
INSERT INTO order_lines (id, order_id, sku, quantity, status)
VALUES (6, 7, 'E-500', 1, 'lost');
-- T7 explicit NULL skips the DEFAULT -> rejected by NOT NULL, 23502
INSERT INTO order_lines (id, order_id, sku, quantity, status)
VALUES (7, 7, 'F-600', 1, NULL);
مصفوفة اختبار القيود للجدول order_lines: لكل قاعدة من القواعد الست، التغيير الذي يجب أن ترفضه قاعدة البيانات، وأقرب قيمة يجب أن تقبلها، والثغرة التي تفحصها بعد ذلك.
القيديجب أن يُرفضأقرب قيمة يجب أن تُقبلثم افحص
NOT NULLskuNULLT4 · الخطأ 23502'A-100'النص الفارغ والمسافات تمر من NOT NULL: هل يجب أن تمر؟
UNIQUE(order_id, sku)A-100 مرتين في الطلب 7T2 · الخطأ 23505A-100 في طلب آخرالسطر نفسه مرتين عبر API، وفي اللحظة نفسها أيضاً: الاستجابة 409 أو 422، ويبقى صف واحد
PRIMARY KEYidالمعرّف 1 في id مرة ثانيةT1 · الخطأ 23505معرّف id جديدإرسال id بقيمة NULL مرفوض أيضاً: فالمفتاح الأساسي يتضمن NOT NULL
FOREIGN KEYorder_idالطلب 9999 غير الموجودT3 · الخطأ 23503الطلب 7حذف الطلب 7 وله أسطر: مرفوض إلا مع ON DELETE CASCADE
CHECKquantity0 / 51T5 · الخطأ 235141 / 50يُمرّر CHECK القيمة NULL؛ ويوقفها هنا NOT NULL
DEFAULTstatus 'open'إرسال status بقيمة NULLT7 · يرفضه NOT NULLإغفال status ← openهل تُغفل API الحقل أم ترسل null؟
  • ليس قيداً بالمعنى الدقيق: DEFAULT يضع قيمة ولا يرفض شيئاً

النتائج ورموز الأخطاء المتوقعة كما يعيدها PostgreSQL. وتختلف المحركات في تفاصيل مثل قيم NULL في عمود UNIQUE ومقارنة حالة الأحرف.

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

قراءة النتائج: الثغرات خلف الاختبارات الناجحة

ثماني نتائج مطابقة للتوقع تثبت أن القواعد المُعلَنة تعمل. أما الملاحظات التي تستحق الإبلاغ عنها فتأتي عادةً مما تفوّته القواعد:

  • يُقبل VALUES (8, 7, '', 1) لأن رمز SKU الفارغ ليس NULL. اسأل هل يحتاج المخطط إلى CHECK (sku <> '') أم يجب أن ترفضه API.
  • تخفي T7 فخاً في API: الخدمة التي تحوّل حقل JSON الغائب إلى NULL صريح تُفشل كل طلب لا يحمل الحالة. أرسل الطلب مرة مع إغفال الحقل ومرة مع "status": null.
  • يجب أن يُرفض DELETE FROM orders WHERE id = 7 ما دامت له أسطر، إلا إذا نص المتطلب على أن الأسطر تُحذف مع طلبها (ON DELETE CASCADE).
  • أرسل سطر T2 مرتين عبر API: توقّع 409 أو 422 برسالة يفهمها المستخدم، وأن يبقى في الجدول صف واحد.
  • قبول قيد CHECK للحالة 'Open' بحرف O كبير يعتمد على قواعد المقارنة (Collation) في العمود: يرفضها PostgreSQL، بينما تقبلها قواعد المقارنة الافتراضية في MySQL وتخزّنها. فاختبرها على المحرك الذي يعمل عليه منتجك في الإنتاج.

أخطاء شائعة

  • اختبار القواعد عبر واجهة المستخدم وحدها، فتحقق النموذج يخفي هل تفرض قاعدة البيانات شيئاً أصلاً.
  • معاملة NOT NULL على أنه «حقل إلزامي»: فهو يرفض NULL لكنه يقبل النص الفارغ.
  • عدّ أي خطأ نجاحاً للاختبار. إذا كسر الصف قاعدتين أو كان في الجملة خطأ صياغة، فالرفض لا يثبت شيئاً عن القاعدة المختبَرة.
  • نسيان أن CHECK وعمود المفتاح الأجنبي الذي يقبل NULL كليهما يُمرّران NULL.
  • الوثوق بوثيقة التصميم بدلاً من قاعدة البيانات العاملة، حيث قد يكون القيد غائباً أو معطّلاً أو غير مدعوم في إصدار المحرك.
  • قبول استجابة 500 أو خطأ قاعدة بيانات خام لأن «البيانات الخاطئة لم تُحفظ». البيانات سليمة، لكن معالجة الخطأ عيب.

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

يبدأ اختبار القيود في جلسات التهذيب (Refinement) ومراجعة الشيفرة. لكل قاعدة في قصة المستخدم، مثل «البريد يجب أن يكون فريداً»، اسأل أين تُفرض: في الواجهة أم في API أم في قاعدة البيانات أم فيها كلها، وماذا يجب أن يرى المستخدم حين تُخالَف. واقرأ ملفات الترحيل في طلبات الدمج (Pull requests): فغياب NOT NULL أو مفتاح أجنبي أرخص إصلاحاً قبل أن تُكتب البيانات. وإضافة قيد UNIQUE أو CHECK إلى جدول يخالفه أصلاً تفشل عادةً، فاسأل كيف ستُنظَّف الصفوف الموجودة.

نفّذ اختبارات المخالفة في قاعدة بيانات اختبار مسموح لك بتعديلها فقط، لا في بيئة الإنتاج أبداً. واحفظها جدولاً صغيراً تقوده البيانات: القيد، والجملة، والخطأ المتوقع، واستجابة API المتوقعة، وأعد تشغيله بعد كل تغيير في المخطط. وفي تقرير العيب اذكر الجملة أو الطلب بالضبط، والنتيجة المتوقعة والفعلية، والمحرك وإصداره.

تمرين عملي

صمّم اختبارات القيود لجدول مواعيد عيادة

المطلوب

تخزّن عيادة مواعيدها في الجدول أدناه. لكل قيد، اكتب تغييراً واحداً على صف صالح يجب أن ترفضه قاعدة البيانات، وأقرب تغيير يجب أن تقبله، مع النتيجة المتوقعة لكل منهما.

ثم سمِّ القواعد التي يتوقعها موظف الاستقبال ولا يفرضها أي قيد، واذكر كيف ستختبرها.

المادة
-- patients 12 and 13 and doctors 3 and 4 exist
CREATE TABLE appointments (
  id          INTEGER      PRIMARY KEY,
  patient_id  INTEGER      NOT NULL REFERENCES patients (id),
  doctor_id   INTEGER      NOT NULL REFERENCES doctors (id),
  starts_at   TIMESTAMP    NOT NULL,
  minutes     INTEGER      NOT NULL DEFAULT 15 CHECK (minutes BETWEEN 10 AND 60),
  status      VARCHAR(12)  NOT NULL DEFAULT 'booked'
              CHECK (status IN ('booked', 'attended', 'no_show', 'cancelled')),
  UNIQUE (doctor_id, starts_at)
);
الخطوات
  1. اسرد القيود عموداً عموداً، بما فيها قيد UNIQUE على عمودين والقيمتان الافتراضيتان.
  2. اكتب صفاً أساسياً صالحاً، ثم اكسر قيداً واحداً في كل مرة.
  3. أضف أقرب قيمة مقبولة لكل قيد، ونتيجة إغفال minutes ونتيجة إرساله بقيمة NULL.
  4. دوّن قواعد العمل التي يتركها المخطط بلا حماية.
إظهار الإجابة النموذجية
الإجابة النموذجية

الصف الأساسي، مقبول: المريض ⁦12⁩، والطبيب ⁦3⁩، و2026-11-02 10:00، و⁦30⁩ دقيقة؛ والحالة مُغفَلة فتُخزَّن booked.

PRIMARY KEY: المعرّف id نفسه مرة أخرى ← مرفوض؛ ومعرّف جديد ← مقبول. FOREIGN KEY: المريض ⁦999⁩ أو الطبيب ⁦999⁩ ← مرفوض؛ والمريض ⁦13⁩ مع الطبيب ⁦4⁩ ← مقبول. NOT NULL: starts_at بقيمة NULL ← مرفوض.

CHECK: قيمتا minutes ⁦9⁩ و⁦61⁩ ← مرفوضتان، و⁦10⁩ و⁦60⁩ ← مقبولتان؛ والحالة done ← مرفوضة، وno_show ← مقبولة. DEFAULT: إغفال minutes ← تُخزَّن ⁦15⁩؛ وإرسال minutes أو status بقيمة NULL ← يرفضه NOT NULL.

UNIQUE (doctor_id، starts_at): الطبيب ⁦3⁩ في ⁦10:00⁩ مرة أخرى ← مرفوض؛ والطبيب ⁦4⁩ في ⁦10:00⁩ مع المريض ⁦13⁩، أو الطبيب ⁦3⁩ في ⁦10:30⁩ ← مقبول.

قواعد لا يفرضها شيء: المواعيد المتداخلة (الطبيب ⁦3⁩ في ⁦10:00⁩ لمدة ⁦30⁩ دقيقة ثم مرة أخرى في ⁦10:15⁩، ويُقبل الموعدان لأن UNIQUE لا يقارن إلا أوقات البدء المتطابقة)، وحجز مريض واحد عند طبيبين في الوقت نفسه. اختبرها عبر شاشة الحجز وعبر API، وافحص الصفوف المخزنة باستعلام ربط ذاتي (Self-join)، أي استعلام يربط الجدول appointments بنفسه ليجد أزواج المواعيد المتداخلة للطبيب نفسه. واسأل أين يجب أن تُفرض القاعدة: في شيفرة التطبيق، أو في مُشغِّل، أو في قيد استبعاد (Exclusion constraint) في PostgreSQL، وهو قيد يستطيع أن يرفض صفاً يتداخل نطاقه الزمني مع صف آخر للطبيب نفسه.

أسئلة شائعة

إذا كان التطبيق يتحقق من المدخلات أصلاً، فلماذا أختبر قيود قاعدة البيانات؟

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

هل DEFAULT قيد فعلاً؟

ليس بالمعنى الدقيق. ففي معيار SQL وفي توثيق PostgreSQL تنتمي القيمة الافتراضية إلى تعريف العمود لا إلى القيود، لأنها لا ترفض شيئاً أبداً؛ أما SQL Server فيسمّيها قيد DEFAULT. وتُدرَّس معها لأنها تشكّل البيانات المخزنة، وتحتاج إلى اختبارين خاصين بها: إغفال العمود، وإرسال العمود بقيمة NULL.

هل أستطيع اختبار القيود دون وصول إلى قاعدة البيانات؟

جزئياً. فعبر API تستطيع إرسال قيم مكررة وحقول غائبة وقيم خارج المدى ومراجع غير موجودة وفحص الاستجابات، لكنك لا تعرف أي طبقة رفضت البيانات، ولا تجد الصفوف الخاطئة المخزنة من قبل. اطلب المخطط وصلاحية قراءة على قاعدة بيانات اختبار، أو اعمل مع مطوّر على اختبارات مستوى قاعدة البيانات.

هل يجب أن تصبح كل قاعدة عمل قيداً في قاعدة البيانات؟

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

المراجع

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

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

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

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

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

ولّد القيم الحدّية (min-1 وmin وmin+1 والقيمة الاسمية وmax-1 وmax وmax+1) للقيود الرقمية وقيود الطول.

تصميم الاختبارات

للتعمّق أكثر

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

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