SQL للمختبِرين

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

كُتب كل فحص سلامة بحيث تعيد قاعدة البيانات السليمة صفر صفوف، وأي صف يعود هو دليل لتقرير خطأ؛ أما استعلامات البحث عن تغيير واحد وملخّصات الأعداد والمجاميع فتعيد قيماً تقارنها بالنتيجة المتوقعة. تستخدم الاستعلامات صياغة تعمل على PostgreSQL وMySQL 8 وSQLite ما لم تذكر ملاحظة غير ذلك، وتتناول الأقسام الأخيرة فروق اللهجات وتحويل الاستعلامات العابرة إلى مجموعة فحوص قابلة لإعادة الاستخدام.

قبل أن تشغّل أي شيء: الممارسة الآمنة

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

  • استخدم حساباً للقراءة فقط (Read-only) للتحقق. إن كان حسابك يستطيع الكتابة، تتحول زلّة واحدة إلى توقف أو فقدان لبيانات اختبار الفريق كله.
  • لا تشغّل UPDATE أو DELETE على بيئة مشتركة (Staging أو UAT أو قاعدة اختبار مشتركة) دون موافقة مالكها. أعلن ذلك، واحفظ العبارة بنصها في التذكرة.
  • قبل أي UPDATE أو DELETE، شغّل عبارة WHERE نفسها مع SELECT COUNT(*) وتحقق من العدد. ثم نفّذ التغيير داخل معاملة وقارن عدد الصفوف المتأثرة قبل COMMIT.
  • أثناء استكشاف جدول كبير، أضف LIMIT (أو ما يعادله في لهجتك) ورشّح على أعمدة مفهرسة مثل المفتاح الأساسي أو نطاق تواريخ.
  • لا تتصفح البيانات الشخصية في الإنتاج للتسهيل. وإن كان الوصول إلى الإنتاج جزءاً من دورك، فالتزم بقواعد الوصول والتسجيل المصاحبة له.
-- On your own test database only
BEGIN;

SELECT COUNT(*) AS will_change
FROM orders
WHERE status = 'PENDING' AND created_at < '2026-01-01';
-- expect: 3

UPDATE orders
SET status = 'CANCELLED'
WHERE status = 'PENDING' AND created_at < '2026-01-01';
-- the client reports rows affected: must also be 3

ROLLBACK;  -- COMMIT only when every number matches

المخطط المستخدم في الأمثلة

تستخدم الأمثلة أربعة جداول. العميل يضع طلبات؛ ولكل طلب سطر صنف واحد أو أكثر وصفر دفعات أو أكثر. يصرّح المخطط بالمفاتيح الأساسية وبريد فريد وأعمدة NOT NULL وقيود CHECK لقيم الحالة والمبالغ ومفاتيح أجنبية. ويستخدم صيغ القيود على مستوى الجدول التي يقبلها PostgreSQL وMySQL 8 وSQLite جميعاً؛ وتذكّر أن MySQL لا يفرض CHECK إلا من 8.0.16، وأن SQLite لا يفرض المفاتيح الأجنبية إلا مع PRAGMA foreign_keys = ON.

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

CREATE TABLE customers (
  customer_id  INTEGER       NOT NULL,
  email        VARCHAR(254)  NOT NULL,
  full_name    VARCHAR(200)  NOT NULL,
  created_at   TIMESTAMP     NOT NULL,
  CONSTRAINT pk_customers PRIMARY KEY (customer_id),
  CONSTRAINT uq_customers_email UNIQUE (email)
);

CREATE TABLE orders (
  order_id     INTEGER       NOT NULL,
  customer_id  INTEGER       NOT NULL,
  status       VARCHAR(20)   NOT NULL,
  total_amount NUMERIC(12,2) NOT NULL,
  created_at   TIMESTAMP     NOT NULL,
  CONSTRAINT pk_orders PRIMARY KEY (order_id),
  CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (customer_id),
  CONSTRAINT ck_orders_status CHECK (status IN ('PENDING', 'PAID', 'SHIPPED', 'CANCELLED')),
  CONSTRAINT ck_orders_total CHECK (total_amount >= 0)
);

CREATE TABLE order_items (
  order_item_id INTEGER       NOT NULL,
  order_id      INTEGER       NOT NULL,
  product_sku   VARCHAR(40)   NOT NULL,
  quantity      INTEGER       NOT NULL,
  unit_price    NUMERIC(12,2) NOT NULL,
  CONSTRAINT pk_order_items PRIMARY KEY (order_item_id),
  CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders (order_id),
  CONSTRAINT ck_items_quantity CHECK (quantity > 0),
  CONSTRAINT ck_items_price CHECK (unit_price >= 0)
);

CREATE TABLE payments (
  payment_id   INTEGER       NOT NULL,
  order_id     INTEGER       NOT NULL,
  amount       NUMERIC(12,2) NOT NULL,
  status       VARCHAR(20)   NOT NULL,
  paid_at      TIMESTAMP     NULL,
  CONSTRAINT pk_payments PRIMARY KEY (payment_id),
  CONSTRAINT fk_payments_order FOREIGN KEY (order_id) REFERENCES orders (order_id),
  CONSTRAINT ck_payments_status CHECK (status IN ('CAPTURED', 'FAILED', 'REFUNDED'))
);

-- Business rules the checks below verify:
-- R1 orders.total_amount = SUM(quantity * unit_price) of its items (no tax or discount here)
-- R2 a PAID or SHIPPED order has CAPTURED payments that add up to total_amount
-- R3 a CAPTURED payment has paid_at, and paid_at is not earlier than the order's created_at

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

تحقق من تغيير واحد: ابحث بالمفتاح

بعد إجراء في الواجهة أو API، اعثر على الصفوف المتأثرة بالمفتاح الذي أعطاك إياه التطبيق — معرّف الطلب في استجابة API أو المعرّف في رابط الصفحة — لا بـ«آخر صف»، فقد يكون مختبِر آخر أنشأ صفاً قبلك بثانية. اختر أعمدة صريحة لا SELECT *، ليُظهر الناتج الحقول التي تفحصها بالضبط ولا ينكسر حين يُضاف عمود.

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

-- The API returned order_id 1042
SELECT o.order_id, o.status, o.total_amount, o.created_at, c.email
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_id = 1042;

SELECT order_item_id, product_sku, quantity, unit_price
FROM order_items
WHERE order_id = 1042
ORDER BY order_item_id;

-- After a hard delete of order 1042: nothing left behind anywhere
SELECT
  (SELECT COUNT(*) FROM orders      WHERE order_id = 1042) AS orders_left,
  (SELECT COUNT(*) FROM order_items WHERE order_id = 1042) AS items_left,
  (SELECT COUNT(*) FROM payments    WHERE order_id = 1042) AS payments_left;

مطابقة الأعداد والمجاميع

المطابقة (Reconciliation) تقارن رقمين يجب أن يتفقا. داخل قاعدة واحدة يكون ذلك رأس الطلب مقابل أسطره (R1) أو الطلبات مقابل الدفعات (R2)؛ وبين الأنظمة يكون العدد والمجموع نفسيهما محسوبين في مكانين — قاعدة الطلبات وخدمة الفوترة، أو القاعدة وتقرير مالي. يعيد GROUP BY مع HAVING المجموعات المختلفة فقط، فالنتيجة الفارغة نجاح.

تفصيلتان تمنعان الإنذارات الكاذبة. قارن المبالغ في أنواع عشرية دقيقة مثل NUMERIC أو DECIMAL فقط؛ وإن كانت المبالغ مخزّنة كفاصلة عائمة فتلك ملاحظة بحد ذاتها. واستخدم COALESCE حين قد لا يكون لأحد الطرفين صفوف، لأن SUM على صفر صفوف يعطي NULL، والمقارنة مع NULL لا تكون صحيحة أبداً، فيختفي طلب لا دفعات له إطلاقاً.

-- R1: order header total vs the sum of its lines
SELECT o.order_id, o.total_amount,
       SUM(oi.quantity * oi.unit_price) AS lines_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> SUM(oi.quantity * oi.unit_price);

-- R2: paid orders vs captured payments
SELECT o.order_id, o.total_amount,
       COALESCE(SUM(p.amount), 0) AS captured
FROM orders o
LEFT JOIN payments p
  ON p.order_id = o.order_id AND p.status = 'CAPTURED'
WHERE o.status IN ('PAID', 'SHIPPED')
GROUP BY o.order_id, o.total_amount
HAVING COALESCE(SUM(p.amount), 0) <> o.total_amount;

-- Count and sum per status for one month, to compare with a report or another service
SELECT status, COUNT(*) AS order_count, SUM(total_amount) AS amount_sum
FROM orders
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY status
ORDER BY status;

جرّبها: منسّق SQL

كشف الصفوف اليتيمة بـ LEFT JOIN … IS NULL

الصف اليتيم (Orphan) صف فرعي لا وجود لأبيه. يحتفظ LEFT JOIN بكل صف فرعي ويملأ أعمدة الأب بـ NULL حين لا يطابقه أب، فالترشيح على أن مفتاح الأب IS NULL يسرد الأيتام بالضبط. والشكل نفسه في الاتجاه المعاكس يجد آباء بلا الأبناء الذين يجب أن يكونوا لهم، مثل طلب بلا أصناف.

يحتفظ LEFT JOIN بكل صف صنف ويملأ أعمدة الطلب بـ NULL حين لا يطابقه طلب، فيعيد الترشيح على IS NULL الصف اليتيم بالضبط. نموذج مفاهيمي
صف الصنفoi.order_idمن order_itemso.order_idبعد LEFT JOINالنتيجةبعد WHERE … IS NULL
110421042الطلب موجوديُستبعد
210421042الطلب موجوديُستبعد
31099NULLلا يوجد طلب 1099يُعاد: الصف اليتيمدليل لتقرير الخطأفشل

صفوف توضيحية: في الجدول orders الطلب 1042 ولا يوجد فيه الطلب 1099. وعلى قاعدة بيانات سليمة يعيد الاستعلام صفر صفوف.

مع مفاتيح أجنبية مصرّح بها ومفروضة لا يمكن إنشاء أيتام — لكنهم يظهرون حين تُعطَّل القيود لتحميل جماعي، أو حين يعمل SQLite دون إعداد المفاتيح الأجنبية، أو حين تمتد العلاقة بين قاعدتي خدمتين، أو حين لم يُنشأ القيد قط. تجنّب NOT IN مع استعلام فرعي في هذا الفحص: إن أعاد الاستعلام الفرعي قيمة NULL واحدة، لا يعيد NOT IN أي صف وينجح الفحص بصمت. أما NOT EXISTS وLEFT JOIN … IS NULL فلا يعانيان هذه المشكلة.

-- Items whose order does not exist
SELECT oi.order_item_id, oi.order_id
FROM order_items oi
LEFT JOIN orders o ON o.order_id = oi.order_id
WHERE o.order_id IS NULL;

-- Payments whose order does not exist
SELECT p.payment_id, p.order_id
FROM payments p
LEFT JOIN orders o ON o.order_id = p.order_id
WHERE o.order_id IS NULL;

-- The reverse: orders that have no items at all
SELECT o.order_id, o.status, o.created_at
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.order_id
WHERE oi.order_item_id IS NULL;

-- The same check written with NOT EXISTS (safe when NULLs are present)
SELECT o.order_id
FROM orders o
WHERE NOT EXISTS (
  SELECT 1 FROM order_items oi WHERE oi.order_id = o.order_id
);

كشف التكرار بـ GROUP BY … HAVING COUNT(*) > 1

جمّع حسب القيمة التي يجب أن تكون فريدة واحتفظ بالمجموعات التي تتكرر أكثر من مرة. وحّد القيم أولاً حين يعدّ العمل الصيغ المختلفة شيئاً واحداً: LOWER وTRIM يلتقطان رسائل البريد التي تختلف في حالة الأحرف أو المسافات فقط. قيد UNIQUE على العمود الخام لا يمنع ذلك — مع أن ترتيبات المقارنة (Collations) الافتراضية غير الحساسة لحالة الأحرف في MySQL تعدّ «Layla.H@example.com» و«layla.h@example.com» متساويين، بينما يقارن PostgreSQL النصوص بحساسية لحالة الأحرف.

التكرار كذلك عَرَض لغياب عدم تكرار الأثر (Idempotency): زر نُقر مرتين أو طلب أُعيد فأنشأ طلبين أو حصّل دفعة واحدة مرتين. وإن كان تصميمك يسمح بتقسيم الدفع، فقارن مجموع المحصَّل بإجمالي الطلب (R2) بدل عدّ الصفوف.

-- Emails that differ only in letter case or surrounding spaces
SELECT LOWER(TRIM(email)) AS email_key, COUNT(*) AS copies
FROM customers
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1;

-- List the duplicate rows themselves, grouped together
SELECT c.customer_id, c.email, c.created_at
FROM customers c
JOIN (
  SELECT LOWER(TRIM(email)) AS email_key
  FROM customers
  GROUP BY LOWER(TRIM(email))
  HAVING COUNT(*) > 1
) d ON d.email_key = LOWER(TRIM(c.email))
ORDER BY d.email_key, c.customer_id;

-- An order charged more than once
SELECT order_id, COUNT(*) AS captured_payments, SUM(amount) AS captured_total
FROM payments
WHERE status = 'CAPTURED'
GROUP BY order_id
HAVING COUNT(*) > 1;

فحوص NULL ونطاقات التواريخ

تعني NULL «غير معروف»، وأي مقارنة معها — بما في ذلك column = NULL — ليست صحيحة ولا خاطئة، فتسقط هذه الصفوف من النتيجة بصمت. افحص دائماً بـ IS NULL أو IS NOT NULL. وتذكّر أن COUNT(column) يتخطى قيم NULL بينما يعدّ COUNT(*) الصفوف، وأن النص الفارغ ليس NULL في PostgreSQL ولا MySQL ولا SQLite (بينما يعامله Oracle Database على أنه NULL).

في التواريخ استخدم نطاقات نصف مفتوحة: created_at >= اليوم الأول AND created_at < اليوم الأول من الفترة التالية. BETWEEN بتاريخ نهاية 2026-09-30 يفوّت كل ما بعد منتصف ليل يوم 30 حين يحمل العمود وقتاً. وتأكد أن طرفي المقارنة يستخدمان المنطقة الزمنية نفسها؛ فمقارنة CURRENT_TIMESTAMP في منطقة بقيم مخزّنة في منطقة أخرى تنتج صفوف «مستقبلية» كاذبة أو تخفي صفوفاً حقيقية.

-- Required text that is NULL or blank
SELECT customer_id, email
FROM customers
WHERE full_name IS NULL OR TRIM(full_name) = '';

-- R3: captured payments without a payment time
SELECT payment_id, order_id
FROM payments
WHERE status = 'CAPTURED' AND paid_at IS NULL;

-- R3: payments recorded before their order existed
SELECT p.payment_id, o.order_id, o.created_at, p.paid_at
FROM payments p
JOIN orders o ON o.order_id = p.order_id
WHERE p.paid_at < o.created_at;

-- Timestamps in the future (clock or time-zone defects)
SELECT order_id, created_at
FROM orders
WHERE created_at > CURRENT_TIMESTAMP;

-- One calendar month as a half-open range
SELECT COUNT(*) AS september_orders
FROM orders
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01';

جرّبها: مدقّق استعلامات SQL

فروق اللهجات التي تكسر الاستعلامات المحمولة

يعرّف معيار SQL الدولي ISO/IEC 9075 اللغة؛ وكل محرك ينفّذه بدرجة مختلفة ويضيف صياغته الخاصة. تتجنب الفحوص أعلاه هذه الفروق، لكن هذه أكثرها مصادفةً للمختبِرين حين ينسخون استعلاماً من مشروع إلى آخر:

  • حدّ الصفوف — LIMIT 50 في PostgreSQL وMySQL وSQLite؛ وSELECT TOP (50) في SQL Server؛ والصيغة القياسية FETCH FIRST 50 ROWS ONLY في PostgreSQL وفي Oracle Database 12c وما بعده.
  • دمج النصوص — || في PostgreSQL وSQLite وOracle؛ وفي MySQL تعني || عملية OR منطقية افتراضياً، فاستخدم CONCAT() المتوفرة في PostgreSQL وMySQL وSQL Server.
  • حساب التواريخ — CURRENT_DATE - INTERVAL '7 days' في PostgreSQL، وCURRENT_DATE - INTERVAL 7 DAY في MySQL، وdate('now', '-7 days') في SQLite، وDATEADD(day, -7, CAST(GETDATE() AS date)) في SQL Server. التواريخ الحرفية بصيغة ISO مثل '2026-09-01' هي الخيار الأكثر قابلية للنقل.
  • اقتطاع الطابع الزمني إلى يوم — CAST(created_at AS DATE) في PostgreSQL وMySQL وSQL Server؛ وdate(created_at) في SQLite.
  • مقارنة النصوص — يقارن PostgreSQL النصوص بحساسية لحالة الأحرف؛ وترتيبات المقارنة الافتراضية في MySQL غير حساسة لها؛ والمساواة = في SQLite حساسة لها، بينما LIKE فيه غير حساس لحالة الأحرف في حروف ASCII فقط.
  • اقتباس المعرّفات — "علامات تنصيص مزدوجة" في المعيار وPostgreSQL وSQLite؛ و`backticks` (علامة الاقتباس المعكوسة) في MySQL؛ و[أقواس معقوفة] في SQL Server.
  • SELECT دون FROM — يقبله PostgreSQL وMySQL وSQLite وSQL Server؛ ويحتاج Oracle Database تقليدياً إلى FROM dual.

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

من استعلامات عابرة إلى مجموعة فحوص قابلة لإعادة الاستخدام

حين يجد استعلام عيباً، احتفظ به. مجلد من الفحوص المسمّاة، يعيد كل منها صفر صفوف حين تصمد القاعدة، يصبح مجموعة اختبار انحدار (Regression) للبيانات: شغّلها بعد النشر وبعد الترحيلات وعلى جدول زمني في Staging، واربطها بالاختبارات الآلية بحيث يتبع كل استدعاء API التحقق المطابق في قاعدة البيانات.

كثيراً ما تبدأ المقارنات بين الأنظمة بقائمة معرّفات مصدَّرة من نظام آخر. حوّل القائمة إلى جدول مشتق أو عبارة IN واربطها ببياناتك كما في المثال أدناه؛ ويُنتج مولّدا VALUES وIN في أدوات متقن صياغة القائمة من القيم الملصقة.

  • فحص واحد لكل قاعدة، باسم القاعدة — مثلاً orders_total_matches_items.
  • صفر صفوف يعني نجاحاً؛ وأي صفوف هي الدليل الذي ترفقه بتقرير الخطأ مع الاستعلام.
  • للقراءة فقط: الفحص لا يعدّل البيانات أبداً.
  • محدود: رشّح بالتاريخ أو الدفعة كي يبقى الفحص سريعاً على الجداول الكبيرة.
  • محفوظ في نظام الإصدارات بجانب الترحيلات ويُراجع كالشيفرة.
-- Ids exported from the invoicing service that the order database does not have.
-- PostgreSQL and SQLite syntax; MySQL 8.0.19+ writes VALUES ROW(1042), ROW(1043), ...
WITH exported (order_id) AS (
  VALUES (1042), (1043), (1044), (1051)
)
SELECT e.order_id
FROM exported e
LEFT JOIN orders o ON o.order_id = e.order_id
WHERE o.order_id IS NULL;

جرّبها: مولّد قوائم SQL VALUES جرّبها: مولّد عبارات SQL IN جرّبها: محوّل CSV إلى SQL جرّبها: محوّل JSON إلى SQL

الأسئلة الشائعة

ما قدر SQL الذي يحتاجه المختبِر؟

SELECT مع WHERE، والربط الداخلي واليساري، وGROUP BY مع HAVING، وCOUNT وSUM وCOALESCE، والاستعلامات الفرعية مع EXISTS، وBEGIN/COMMIT/ROLLBACK. هذا يغطي تقريباً كل استعلام تحقق في هذه الصفحة. دوال النوافذ وضبط الاستعلامات مفيدة لاحقاً لا أولاً.

لماذا LEFT JOIN … IS NULL بدل NOT IN؟

لأن NOT IN لا يعيد أي صف حين يحتوي استعلامه الفرعي على NULL، فقد ينجح فحص أيتام مكتوب بـ NOT IN مع وجود أيتام فعلاً. أما LEFT JOIN … IS NULL وNOT EXISTS فيتصرفان بشكل صحيح مع NULL.

هل يمكنني تشغيل استعلامات التحقق على الإنتاج؟

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

لماذا يختلف العدد عندي عن التقرير؟

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

هل أستخدم SELECT * في الفحوص؟

ليس في الفحوص المحفوظة. اسرد الأعمدة التي تتحقق منها كي يكون الناتج مقروءاً في تقرير الخطأ ولا يتغير الفحص حين يضيف أحدهم عموداً. أما لإلقاء نظرة سريعة على صف واحد فلا بأس بـ SELECT *.

كيف تندمج هذه الاستعلامات في الاختبارات الآلية؟

يستدعي الاختبار API، ثم ينفّذ الاستعلام المطابق عبر اتصال إطار الاختبار بقاعدة البيانات ويتحقق من النتيجة — الصف المحدد لتغيير واحد، وصفر صفوف لقاعدة سلامة. شغّلها على قاعدة مزروعة حديثاً في CI كي تكون القيم المتوقعة معروفة.

المراجع

  • PostgreSQL Documentation: SQL SyntaxThe PostgreSQL Global Development Groupالبنية المعجمية لـ SQL في PostgreSQL: المعرّفات والاقتباس والثوابت النصية والتهريب.
  • SQL As Understood By SQLiteSQLiteقواعد SQL التي يقبلها SQLite، عبارة تلو الأخرى، مع مخططات نحوية.
  • ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)ISO/IEC · 2023الجزء الإطاري من معيار SQL الدولي، الذي تنفّذه لهجات PostgreSQL وMySQL وSQLite وغيرها بدرجات متفاوتة.
  • Constraints — PostgreSQL DocumentationPostgreSQL Global Development Groupقيود التحقق وعدم القيمة الفارغة والتفرّد والمفتاح الأساسي والمفتاح الأجنبي والإقصاء — القواعد التي تفرضها قاعدة البيانات ويتحقق منها المختبِر.
  • Transactions — PostgreSQL DocumentationPostgreSQL Global Development Groupما المعاملة: عدة خطوات تُطبَّق كلها أو لا شيء عبر BEGIN وCOMMIT وROLLBACK، ولا يراها الآخرون قبل التثبيت.
  • OWASP Cheat Sheet Series: SQL Injection PreventionOWASP Foundationالاستعلامات ذات المعاملات، والإجراءات المخزنة المستخدمة بأمان، والتحقق بقائمة السماح، وأقل الصلاحيات — الدفاعات التي ينبغي أن يؤكدها اختبار قاعدة البيانات.

تمرين عملي

تحقق من تجربة ترحيل بفحوص SQL

المطلوب

تختبر متجراً إلكترونياً للأجهزة المنزلية (مثال خيالي) ينقل بيانات طلباته من منصة قديمة إلى منصة جديدة. في التجربة الأولى للترحيل (Migration rehearsal) أمس، حمّلت أداة الترحيل حزمة واحدة إلى قاعدة بيانات Staging المشتركة — الطلبات من 5001 إلى 5008 من المنصة القديمة مع عملائها وأصنافها ودفعاتها — وكان فحص المفاتيح الأجنبية معطَّلاً أثناء التحميل الجماعي. ويريد الفريق قبل التجربة الثانية مجموعة فحوص للبيانات تعمل بعد كل تحميل. كتب زميلك حمزة مسودة من خمسة فحوص واقترح تنظيفاً للبيانات. تعرض المادة أدناه ما يختلف عن مخطط الدليل، وتقرير المنصة القديمة، والصفوف المحمَّلة، والمسودة، واقتراح التنظيف.

اعمل على الورق بأنماط الدليل. الحزمة صغيرة عمداً، فتستطيع تنفيذ كل استعلام يدوياً على الصفوف المعروضة. اكتب كل ملاحظة بحيث يمكن نقلها إلى تقرير خطأ مع استعلامها والصفوف التي أعادها. وتساعدك أداة تحويل المناطق الزمنية في أحد الفحوص. خصّص لذلك ⁦45–60⁩ دقيقة.

المادة
متجر إلكتروني للأجهزة المنزلية (مثال خيالي) - ترحيل بيانات الطلبات إلى منصة جديدة
جرت التجربة الأولى للترحيل أمس على Staging، وهي بيئة يتشاركها فريق الجودة كله.
حمّلت أداة الترحيل حزمة واحدة: الطلبات من 5001 إلى 5008 من المنصة القديمة مع عملائها
وأصنافها ودفعاتها، والدفعات غير المطابَقة من الفترة نفسها. وكان فحص المفاتيح الأجنبية
معطَّلاً أثناء التحميل الجماعي. وحسابات فريق الجودة على Staging تستطيع القراءة والكتابة.

المنصة الجديدة: PostgreSQL بمخطط الدليل وقواعده من R1 إلى R3، ومعها:
  - لا يقبل orders.status إلا PENDING وPAID وSHIPPED وCANCELLED (قيد CHECK)
  - قد يكون payments.order_id قيمة NULL: دفعة أكدتها بوابة الدفع ولم يستطع المتجر
    مطابقتها مع طلب («غير مطابَقة»)، ويطابقها فريق المالية يدوياً
  - R4: عميل واحد لكل بريد إلكتروني؛ والعناوين التي تختلف في حالة الأحرف أو في
    المسافات المحيطة فقط تعود إلى العميل نفسه
  - تُخزَّن الطوابع الزمنية بتوقيت UTC
المنصة القديمة: تقاريرها بتوقيت المتجر Asia/Riyadh (UTC+3، بلا توقيت صيفي).
ويشمل إجمالي الطلب رسوم التوصيل إن فُرضت، ولا سطر صنف لهذه الرسوم.

تقرير المنصة القديمة «الطلبات حسب الحالة»، سبتمبر 2026، بتوقيت المتجر
status     order_count  amount_sum
CANCELLED            1       45.00
PAID                 3      510.00
REFUNDED             1       75.00
SHIPPED              2      449.00
المجموع: 7 طلبات بمبلغ 1,079.00

الصفوف بعد التجربة الأولى (الأعمدة التي تستخدمها الفحوص فقط، والأوقات بتوقيت UTC)

customers
customer_id  email                   full_name
101          'layla.h@example.com'   'Layla Hassan'
102          'omar.k@example.com'    'Omar Khalil'
103          'Layla.H@example.com '  'Layla Hassan'
104          'sara.n@example.com'    'Sara Nasser'

orders
order_id  customer_id  status     total_amount  created_at
5001      101          SHIPPED          250.00  2026-08-31 22:10:00
5002      102          PAID             120.00  2026-09-05 09:00:00
5003      103          PAID             310.00  2026-09-12 14:30:00
5004      104          PAID              80.00  2026-09-18 11:00:00
5005      101          CANCELLED         45.00  2026-09-22 16:45:00
5006      102          SHIPPED          199.00  2026-09-30 18:20:00
5008      104          PENDING           60.00  2026-09-30 22:40:00

order_items
order_item_id  order_id  product_sku  quantity  unit_price
1              5001      KTL-200             1      250.00
2              5002      CUP-010             4       30.00
3              5003      FAN-300             1      260.00
4              5003      FLT-020             1       40.00
5              5004      FLT-020             2       40.00
6              5005      MUG-001             3       15.00
7              5006      TST-150             1      199.00
8              5007      IRN-075             1       75.00
9              5008      CUP-010             2       30.00

payments
payment_id  order_id  amount  status    paid_at
71          5001      250.00  CAPTURED  2026-08-31 22:11:00
72          5002      120.00  CAPTURED  NULL
73          5003      310.00  CAPTURED  2026-09-12 14:31:00
74          NULL       80.00  CAPTURED  2026-09-18 11:02:00
75          5005       45.00  FAILED    NULL
76          5006      199.00  CAPTURED  2026-09-30 18:21:00
77          5006      199.00  CAPTURED  2026-09-30 18:21:30
78          5007       75.00  REFUNDED  2026-09-26 12:00:00

مسودة مجموعة الفحوص من حمزة (فريق الجودة): يعيد كل فحص صفر صفوف حين تصمد قاعدته،
ويعيد Q5 أرقاماً تُقارَن بالتقرير.

-- Q1 orders_total_matches_items (R1)
SELECT o.order_id, o.total_amount,
       SUM(oi.quantity * oi.unit_price) AS lines_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> SUM(oi.quantity * oi.unit_price);

-- Q2 paid_orders_have_payment (R2)
SELECT order_id, status, total_amount
FROM orders
WHERE status IN ('PAID', 'SHIPPED')
  AND order_id NOT IN (SELECT order_id FROM payments WHERE status = 'CAPTURED');

-- Q3 customer_email_unique (R4)
SELECT email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;

-- Q4 captured_payment_has_time (R3)
SELECT payment_id, order_id
FROM payments
WHERE status = 'CAPTURED' AND paid_at = NULL;

-- Q5 september_matches_old_report
SELECT status, COUNT(*) AS order_count, SUM(total_amount) AS amount_sum
FROM orders
WHERE created_at BETWEEN '2026-09-01' AND '2026-09-30'
GROUP BY status
ORDER BY status;

حمزة في محادثة الفريق: «يُظهر Q1 أن الطلب 5003 منحرف بمقدار 10.00، ورأيت صفوفاً مهملة
لطلب 5007 غير موجود. سأنظّف Staging لتصبح المجموعة خضراء قبل مراجعة الغد:»
UPDATE orders SET total_amount = 300.00 WHERE order_id = 5003;
DELETE FROM order_items WHERE order_id = 5007;
DELETE FROM payments WHERE order_id = 5007;
الخطوات
  1. راجع الفحوص من Q1 إلى Q5 مقابل القاعدة التي يُفترض أن يتحقق منها كل فحص. قرّر هل الفحص صحيح، أم قد ينجح بصمت والبيانات خاطئة، أم قد يطلق إنذاراً كاذباً والبيانات سليمة، واكتب الاستعلام المصحَّح.
  2. جرى التحميل وفحص المفاتيح الأجنبية معطَّل. اكتب الفحوص التي ما زالت تنقص المجموعة لهذا الوضع، وفحصاً لطلب حُصّلت دفعته مرتين.
  3. نفّذ الفحوص المصحَّحة والجديدة يدوياً على الصفوف المحمَّلة، واكتب الصفوف التي يعيدها كل فحص.
  4. طابِق شهر سبتمبر مع تقرير المنصة القديمة: اذكر مرشّح التاريخ الذي استخدمته، وفسّر كل فرق، وحدّد أي فرق هو العيب.
  5. ردّ على اقتراح حمزة للتنظيف. ثم اكتب الملاحظات التي تبلّغ عنها قبل التجربة الثانية، مع دليل كل منها والسؤال الذي تطرحه.
إظهار الإجابة النموذجية
الإجابة النموذجية

Q1 صحيح. إنه فحص R1 من الدليل: يجمّع حسب الطلب ويحتفظ فقط بالطلبات التي يخالف رأسها مجموع أسطرها، فالنتيجة الفارغة نجاح. أبقِه، ولاحظ ما لا يراه: الربط الداخلي يُسقط الطلب الذي لا أصناف له إطلاقاً، ولهذا تحتاج المجموعة أيضاً إلى فحص الأيتام المعاكس أدناه. وعلى هذه الحزمة يعيد صفاً واحداً: الطلب 5003، وtotal_amount يساوي 310.00، وlines_total يساوي 300.00 (⁦260.00 + 40.00⁩).

Q2 ينجح بصمت. فهنا يمكن أن تكون قيمة payments.order_id هي NULL، والدفعة غير المطابَقة 74 تُدخل NULL في الاستعلام الفرعي؛ والشرط order_id NOT IN (…, NULL) لا يكون صحيحاً أبداً، فيعيد Q2 صفر صفوف أياً كانت البيانات. ثم إنه يتحقق فقط من وجود دفعة محصَّلة، بينما تشترط R2 أن يساوي مجموع الدفعات المحصَّلة إجمالي الطلب. استبدل به فحص المطابقة R2 من الدليل: LEFT JOIN payments p ON p.order_id = o.order_id AND p.status = 'CAPTURED' وWHERE o.status IN ('PAID', 'SHIPPED') مع التجميع حسب الطلب وHAVING COALESCE(SUM(p.amount), 0) <> o.total_amount. يعيد صفين: الطلب 5004 (الإجمالي 80.00 والمحصَّل 0) والطلب 5006 (الإجمالي 199.00 والمحصَّل 398.00). ودون COALESCE كان الطلب 5004 سيختفي أيضاً، لأن SUM على صفر صفوف يعطي NULL.

Q3 ينجح بصمت: لا يمكن أن يعيد صفاً أبداً. فقيد UNIQUE يرفض أصلاً بريدين متطابقين، وPostgreSQL يقارن النصوص بحساسية لحالة الأحرف، فتقع الصيغ التي تعنيها R4 في مجموعات منفصلة. جمّع حسب القيمة الموحَّدة بدلاً من ذلك: SELECT LOWER(TRIM(email)) AS email_key, COUNT(*) AS copies FROM customers GROUP BY LOWER(TRIM(email)) HAVING COUNT(*) > 1. يعيد layla.h@example.com بنسختين، واستعلام الدليل الذي يسرد الصفوف نفسها يُظهر العميلين 101 و103 ('Layla.H@example.com ': حرفان كبيران ومسافة في آخره).

Q4 ينجح بصمت: الشرط paid_at = NULL لا يكون صحيحاً أبداً. ومع paid_at IS NULL يعيد الدفعة 72 للطلب 5002؛ أما الدفعة 75 فلا وقت لها أيضاً، لكنها FAILED، وهذا ما تسمح به R3. ويغطي Q4 النصف الأول من R3 فقط، فأضف فحص الدليل للدفعة المسجَّلة قبل طلبها (JOIN orders o ON o.order_id = p.order_id WHERE p.paid_at < o.created_at). يعيد صفر صفوف على هذه الحزمة: كل وقت دفع يأتي بعد طلبه بدقيقة إلى دقيقتين، ووقت الدفعة 72 الذي قيمته NULL يسقط من المقارنة ببساطة، ولهذا يلزم فحص IS NULL بجانبه.

Q5 يطلق إنذاراً كاذباً. فالشرط BETWEEN '2026-09-01' AND '2026-09-30' ينتهي عند 2026-09-30 00:00:00، ويقطع الشهر بتوقيت UTC بينما يعدّ التقرير أيام المتجر بتوقيته. ونتيجته: CANCELLED طلب واحد بمبلغ 45.00، وPAID ثلاثة طلبات بمبلغ 510.00، أي 4 طلبات مقابل 7، فتبدو كأن ثلاثة طلبات مفقودة. والشهر نصف المفتوح بتوقيت UTC (>= '2026-09-01' AND < '2026-10-01') ما زال خاطئاً لهذه المقارنة: يُسقط الطلب 5001 (الساعة 22:10 من 31 أغسطس بتوقيت UTC هي 01:10 من 1 سبتمبر بتوقيت المتجر) ويُدخل الطلب 5008 (الساعة 22:40 من 30 سبتمبر بتوقيت UTC هي 01:40 من 1 أكتوبر بتوقيت المتجر). اكتب شهر المتجر بتوقيت UTC: created_at >= '2026-08-31 21:00:00' AND created_at < '2026-09-30 21:00:00'. النتيجة: CANCELLED طلب واحد بمبلغ 45.00، وPAID ثلاثة بمبلغ 510.00، وSHIPPED طلبان بمبلغ 449.00، أي 6 طلبات بمبلغ 1,004.00. وكل حالة تطابق التقرير إلا REFUNDED (طلب واحد بمبلغ 75.00) التي لا صف لها في القاعدة الجديدة: طلب واحد مفقود. وهذا الفحص يعيد أرقاماً لا مخالفات، فاحفظه ومعه الأرقام المتوقعة من التقرير.

فحوص تُضاف لأن التحميل جرى دون مفاتيح أجنبية، يحمل كل منها اسم قاعدته ويبقى للقراءة فقط مثل غيره. items_have_order (LEFT JOIN orders o ON o.order_id = oi.order_id WHERE o.order_id IS NULL) يعيد الصنف 8 الذي لا وجود لطلبه 5007. وpayments_have_order مكتوباً كما في الدليل يعيد الدفعة 78 (الطلب 5007) والدفعة 74 أيضاً، لأن order_id الذي قيمته NULL لا يطابق أي أب. والدفعات غير المطابَقة مسموحة في التصميم، فأضف AND p.order_id IS NOT NULL ليعيد الدفعة 78 وحدها، وتُظهر قائمة منفصلة لفريق المالية (WHERE order_id IS NULL) الدفعة 74. وorders_have_items مكتوباً بـ NOT EXISTS يعيد صفر صفوف: نجاح. وpayment_captured_once (WHERE status = 'CAPTURED' AND order_id IS NOT NULL GROUP BY order_id HAVING COUNT(*) > 1) يعيد الطلب 5006 بدفعتين محصَّلتين ومجموع 398.00. وشرط IS NOT NULL لازم هنا أيضاً: فـ GROUP BY يضع كل قيم NULL في مجموعة واحدة، فتبدو دفعتان غير مطابَقتين كأنهما خصم مزدوج. وحين تُحمَّل البيانات كاملة، قيّد كل فحص بالحزمة كي يبقى سريعاً.

تروي المطابقة وفحوص الأيتام قصة واحدة: الطلب 5007 مفقود، بينما حُمّل صنفه 8 ودفعته 78 من دونه. والمادة تشير إلى السبب: الطلب 5007 بحالة REFUNDED في المنصة القديمة، لكن قيد CHECK الجديد على orders.status لا يسمح إلا بـ PENDING وPAID وSHIPPED وCANCELLED، فالأرجح أن صف الطلب رُفض بينما حُمّل أبناؤه لأن فحص المفاتيح الأجنبية كان معطَّلاً. أكّد ذلك من سجل أداة الترحيل. هذه أخطر الملاحظات، فهي طلب ضاع في الترحيل، وتحتاج إلى قرار من مالك المنتج في طريقة ترحيل الطلبات المستردّة، وإلى إصلاح في الترحيل، وإلى تشغيل الفحوص نفسها بعد التجربة التالية.

يجب ألا يُنفَّذ تنظيف حمزة. فإجمالي الطلب 5003 ليس خطأً في الترحيل: رقم PAID في التقرير القديم، 510.00، لا يستقيم إلا إذا كان إجمالي هذا الطلب 310.00، والدفعة 73 تُظهر أن العميل دفع 310.00، وإجماليات المنصة القديمة تشمل رسوم التوصيل التي لا سطر صنف لها. وتعديل الإجمالي إلى 300.00 يمحو المبلغ المحصَّل فعلاً، ويجعل فحص R2 يفشل للطلب 5003 بدلاً من ذلك، ويُفسد مطابقة سبتمبر (PAID بمبلغ 500.00 مقابل 510.00). وحذف صفوف الطلب 5007 يُتلف الدليل على أن طلباً ضاع، والتحميل التالي سيعيد إنشاءها، لأن العيوب في الترحيل لا في Staging. وStaging بيئة مشتركة: لا يُشغَّل عليها UPDATE أو DELETE دون موافقة مالكها. وإن اتُّفق يوماً على تعديل بيانات، فشغّل عبارة WHERE أولاً مع SELECT COUNT(*) (صف واحد لكل عبارة هنا)، ثم نفّذ التعديل داخل BEGIN … ROLLBACK وقارن عدد الصفوف المتأثرة قبل أي COMMIT، واحفظ العبارات بنصها في التذكرة. واطلب أيضاً حساباً للقراءة فقط (Read-only) للتحقق، لأن حسابات فريق الجودة تستطيع الكتابة.

الملاحظات التي تبلّغ عنها قبل التجربة الثانية، كل منها مع استعلامه والصفوف التي أعادها. (1) الطلب 5007 لم يُرحَّل، والصنف 8 والدفعة 78 يتيمان؛ والسبب المرجَّح حالة REFUNDED. (2) الطلب 5003: الإجمالي 310.00 مقابل أسطر مجموعها 300.00؛ والأرجح أن الفرق 10.00 هو رسوم التوصيل التي لا مكان لها في النموذج الجديد، فاسأل مالك المنتج هل تصبح سطر صنف أم عموداً مستقلاً للرسوم، وتُعدَّل R1 وفقاً لذلك. (3) الطلب 5004 بحالة PAID دون دفعة محصَّلة، بينما للدفعة غير المطابَقة 74 المبلغ نفسه، 80.00، بعد الطلب بدقيقتين؛ فتحقق في المنصة القديمة هل كانت الدفعة 74 للطلب 5004: إن كانت كذلك فقد أضاع الترحيل الربط، وإلا فبيانات المصدر خاطئة ويقرر فريق المالية. (4) للطلب 5006 تحصيلان بمبلغ 199.00 بينهما 30 ثانية؛ فقارنهما بدفعات المنصة القديمة وبسجل بوابة الدفع: إن وُجد التحصيلان هناك فقد خُصم المبلغ من العميل مرتين فعلاً، فيلزم استرداد لدى المالية والإبلاغ عن غياب عدم تكرار الأثر (Idempotency) هناك؛ وإن وُجد تحصيل واحد فالترحيل هو الذي أنشأ الصف الزائد. (5) العميلان 101 و103 شخص واحد وفق R4، وطلباته موزعة على سجلين؛ فاسأل هل كان في المنصة القديمة حساب واحد أم حسابان، واتفقوا على قاعدة الدمج قبل الإنتاج. (6) الدفعة 72 محصَّلة دون paid_at؛ فقارنها بالمنصة القديمة لترى هل أضاع ربط الحقول (Mapping) الوقت. أما الفحوص التي أعادت صفر صفوف، أي الطلبات بلا أصناف والدفعات المسجَّلة قبل طلبها، فتُسجَّل نجاحاً.

إن بدت استعلاماتك مختلفة فقارنها بالقاعدة لا بالصياغة: NOT EXISTS وLEFT JOIN … IS NULL متكافئان في فحوص الأيتام، ولو سمح المتجر بتقسيم الدفع لاستغنيت عن فحص التحصيل المزدوج واعتمدت على R2 الذي يقارن المجاميع. المهم أن يعيد كل فحص صفر صفوف على البيانات السليمة، وألا ينجح بصمت بسبب NULL، وأن يقارن المتماثلات (المنطقة الزمنية نفسها ونطاق نصف مفتوح)، وأن تُرسَل كل ملاحظة مع استعلامها والصفوف التي أعادها.

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

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

الأدوات المذكورة في هذا الدليل

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

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

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

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

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

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

تحويل قائمة قيم (واحدة في كل سطر أو مفصولة بفواصل) إلى عبارة SQL IN (...) مقتبسة.

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

تحويل البيانات الجدولية إلى قائمة SQL VALUES أو جدول مشتق مبني على VALUES.

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

تحويل بيانات CSV إلى جمل CREATE TABLE وINSERT مع استنتاج أنواع الأعمدة.

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

تحويل مصفوفة JSON من الكائنات إلى جمل SQL INSERT (مع CREATE TABLE اختيارياً).

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

كيف تساعدك متقن سوفت

أدلة أخرى

أدلة أخرى
قواعد البيانات

اختبار قواعد البيانات لمهندسي الجودة

كيف تختبر قاعدة البيانات: التحقق من عمليات CRUD عبر الواجهة وAPI، والقيود، والسلامة المرجعية، والاتساق، وبيانات الاختبار، والأداء والأمن.

وقت القراءة 13 دقائق
قواعد البيانات

اختبار المعاملات وترحيلات قواعد البيانات

كيف تختبر معاملات قواعد البيانات وترحيلاتها: الذرّية، ومستويات العزل وظواهرها، والتزامن، وترحيل المخطط والبيانات بأمان.

وقت القراءة 10 دقائق
اختبار واجهات API

ما هو اختبار API؟ وكيف تختبر REST API

شرح اختبار API: ما هو، وما الذي تفحصه في كل طلب واستجابة، وطريقة خطوة بخطوة لاختبار REST API، والأخطاء الشائعة، وأدوات مجانية من متصفحك.

وقت القراءة 6 دقائق
تحليل الأخطاء

كيف تكتب تقرير خطأ (Bug Report)

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

وقت القراءة 4 دقائق
أدوات مساعدة

ما هو UUID؟ الفرق بين v4 وv7 وبين UUID وGUID وULID

شرح UUID: صيغة الـ 128 بت، والفرق بين v4 وv7، ولماذا v7 أفضل لمفاتيح قواعد البيانات، ومقارنة UUID بـ GUID وULID، واحتمال التصادم، والتوليد والتحقق.

وقت القراءة 4 دقائق
JSON والبيانات

ما هو JSON؟

شرح JSON بلغة مبسّطة: ما هو، وأنواع القيم الست، وقواعد الصياغة التي يخطئ فيها الكثيرون، وكيفية تنسيقه والتحقق منه، ومقارنته بـ XML وYAML.

وقت القراءة 4 دقائق