الربط بين الجداول في SQL

الربط (JOIN) يجمع صفوفاً من جدولين في نتيجة واحدة بإقران الصفوف التي تحقق شرط الربط (Join Condition)، وهو عادةً مفتاح أجنبي يساوي المفتاح الأساسي الذي يشير إليه. فالاستعلام orders JOIN customers ON orders.customer_id = customers.customer_id يضع كل طلب بجانب العميل الذي أنشأه، فيُظهر استعلام واحد هل يخص الطلب 1042 الشخص الصحيح.

ويحدد نوع الربط مصير الصفوف التي لا تجد شريكاً: INNER JOIN يستبعدها، أما LEFT وRIGHT وFULL فتحتفظ بها وتملأ الجانب الناقص بـ NULL. وبهذا الفرق يكشف المختبِر الصفوف اليتيمة (Orphan Rows)، أي الصفوف الفرعية التي لا وجود لصفّها الأب.

لماذا يهم

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

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

كيف يطابق الربط بين الصفوف

تكتب اسمَي الجدولين في FROM … JOIN، ثم شرط الربط بعد ON، وهو عادةً علاقة من النوع الذي تعرّفت إليه في درس المفاتيح والعلاقات: المفتاح الأجنبي orders.customer_id يساوي المفتاح الأساسي الذي يشير إليه، وهو هنا customers.customer_id. وتضم النتيجة كل زوج من الصفوف يتحقق فيه الشرط.

في الجدولين أدناه يشير الطلب 104 إلى العميل 9 غير الموجود، وهذا نوع المرجع المكسور الذي يبحث عنه المختبِر. والحرفان c وo اسمان مستعاران (Aliases)، أي اسمان قصيران للجدولين.

-- customers                    orders
-- customer_id  full_name       order_id  customer_id  total
-- 1            Layla           101       1            250.00
-- 2            Omar            102       1             40.00
-- 3            Priya           103       2            120.00
--                              104       9             75.00

SELECT c.customer_id, c.full_name, o.order_id, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id
ORDER BY o.order_id;
-- 3 rows: customer 1 with orders 101 and 102, customer 2 with order 103

أنواع الربط INNER وLEFT وRIGHT وFULL

تعيد الأنواع الأربعة كلها الأزواج المتطابقة، ولا تختلف إلا في الصفوف غير المطابِقة. والجدول customers، المكتوب أولاً، هو الجدول الأيسر، وorders هو الأيمن.

  • INNER JOIN أو JOIN وحده: الأزواج المتطابقة فقط، أي 3 صفوف. ويختفي العميل 3 الذي لا طلبات له، والطلب 104 الذي لا وجود لعميله، دون أي تحذير.
  • LEFT JOIN (أو LEFT OUTER JOIN): كل صفوف الجدول الأيسر، أي 4 صفوف. ويبقى العميل 3 مع NULL في أعمدة الطلب.
  • RIGHT JOIN (أو RIGHT OUTER JOIN): كل صفوف الجدول الأيمن، أي 4 صفوف. ويبقى الطلب 104 مع NULL في أعمدة العميل.
  • FULL JOIN (أو FULL OUTER JOIN): كل صفوف الجدولين، أي 5 صفوف. يدعمه PostgreSQL وSQL Server وOracle Database، ولا يدعمه MySQL، ولا يدعم SQLite الربطين RIGHT وFULL إلا ابتداءً من الإصدار ⁦3.39.0⁩.
العملاء 1 و2 و3 مربوطون بالطلبات من 101 إلى 104، والطلب 104 يشير إلى العميل 9 غير الموجود: يعيد INNER JOIN ثلاثة صفوف، وكلٌّ من LEFT JOIN وRIGHT JOIN أربعة، وFULL JOIN خمسة.
الصفINNER JOINالمتطابقة فقطLEFT JOIN+ كل العملاءRIGHT JOIN+ كل الطلباتFULL JOIN+ الجانبان
العميل 1 + الطلب 101في النتيجةفي النتيجةفي النتيجةفي النتيجة
العميل 1 + الطلب 102في النتيجةفي النتيجةفي النتيجةفي النتيجة
العميل 2 + الطلب 103في النتيجةفي النتيجةفي النتيجةفي النتيجة
العميل 3لا طلبات لهليس في النتيجةفي النتيجةأعمدة الطلب NULLليس في النتيجةفي النتيجةأعمدة الطلب NULL
الطلب 104العميل 9 غير موجودليس في النتيجةليس في النتيجةفي النتيجةأعمدة العميل NULLفي النتيجةأعمدة العميل NULL
عدد الصفوف المُعادة3445
  • زوج متطابق: تعيده أنواع الربط كلها
  • صف غير مطابِق: لا يبقى إلا في ربط خارجي يحتفظ بجدوله، مع NULL في الجانب الناقص
  • صف يتيم: الطلب يشير إلى عميل غير موجود

الجدولان نفساهما والشرط نفسه o.customer_id = c.customer_id في كل عمود، ولا يتغير إلا نوع الربط. والخلية الفارغة تعني أن الصف ليس في تلك النتيجة.

SELECT c.customer_id, o.order_id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_id;

-- customer_id  order_id  total
-- 1            101       250.00
-- 1            102        40.00
-- 2            103       120.00
-- 3            NULL      NULL     <- kept by LEFT JOIN, dropped by INNER JOIN

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

كتابة شرط الربط

تأتي معظم نتائج الربط الخاطئة من عبارة ON، والشرط الخاطئ يعيد مع ذلك صفوفاً تبدو معقولة، لذا قارن عدد صفوف كل نتيجة بالعدد الذي تتوقعه.

في الربط الخارجي (Outer Join) يهمّ موضع أي ترشيح إضافي. ففي ON يحدد الترشيح فقط ما يُعدّ مطابقة، فتبقى صفوف الجدول الأيسر غير المطابِقة. أما في WHERE فيُطبَّق بعد الربط، فيحذف الصفوف المملوءة بـ NULL ويحوّل الربط الأيسر بصمت إلى ربط داخلي، كما يُظهر الاستعلامان أدناه. ويُستثنى من ذلك اختبار IS NULL، فهو يُبقي الصفوف المملوءة بـ NULL تحديداً، وعليه يقوم فحص الأيتام في القسم التالي.

  • اربط على الأعمدة التي تستخدمها العلاقة: o.customer_id = c.customer_id. فالشرط o.order_id = c.customer_id يعمل أيضاً، لكنه يقرن صفوفاً لا علاقة بينها كلما تساوى الرقمان مصادفةً.
  • استخدم كل أعمدة المفتاح المركّب (Composite Key): فإذا كان معرّف العميل فريداً داخل المستأجر (Tenant) فقط، أي داخل المؤسسة العميلة الواحدة في نظام مشترك متعدد المستأجرين، فاربط على tenant_id وcustomer_id معاً، وإلا اقترن الطلب أيضاً بعملاء مستأجرين آخرين يحملون الرقم نفسه.
  • توقّع التكرار في الربط من واحد إلى متعدد (One-to-Many): يظهر العميل 1 مرتين لأن له طلبين، فعدّ الصفوف يعدّ الطلبات لا العملاء.
  • إغفال الشرط يقرن كل صف بكل صف، وهذا هو الضرب الديكارتي (Cartesian Product) الذي يعيده CROSS JOIN عن قصد: 3 عملاء و4 طلبات تعطي 12 صفاً. ويحدث ذلك مع FROM customers, orders (جدولان تفصل بينهما فاصلة) إذا لم يربط بينهما شرط في WHERE، ويحدث في MySQL مع JOIN بلا ON؛ أما PostgreSQL فيرفض مثل هذا الربط.
  • لا تساوي NULL أي قيمة، ولا حتى NULL أخرى، لذا لا يجد الصف الذي قيمة عمود الربط فيه NULL أي مطابقة أبداً: يحذفه الربط الداخلي، ويحتفظ به الربط الأيسر.
-- Every customer, with their orders over 100.00 if any: 3 rows
SELECT c.customer_id, o.order_id, o.total
FROM customers c
LEFT JOIN orders o
  ON o.customer_id = c.customer_id AND o.total > 100.00;
-- 1 | 101 | 250.00     2 | 103 | 120.00     3 | NULL | NULL

-- The same filter in WHERE also removes customer 3: 2 rows
SELECT c.customer_id, o.order_id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.total > 100.00;
-- 1 | 101 | 250.00     2 | 103 | 120.00

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

الصف اليتيم (Orphan Row) صف فرعي يشير مفتاحه الأجنبي إلى صف أب غير موجود، مثل الطلب 104. ابدأ من الجدول الفرعي، واربطه ربطاً أيسر بالجدول الأب، واحتفظ فقط بالصفوف التي لم يطابقها أب. على قاعدة بيانات سليمة يعيد الاستعلام صفر صفوف، وكل صف يعيده دليل لتقرير خطأ. والشكل نفسه معكوساً يجد آباء ينقصهم أبناء إلزاميون، مثل طلب بلا أصناف.

اختبر IS NULL على المفتاح الأساسي للجدول الأب، فهو لا يكون NULL أبداً عند وجود مطابقة؛ أما العمود الذي يقبل NULL، كرقم هاتف اختياري، فسيُظهر أيضاً طلبات العملاء الذين ليس لهم هاتف. وتظهر كذلك الصفوف الفرعية التي مفتاحها الأجنبي نفسه NULL: وهذا عيب إن كان العمود إلزامياً، وإلا فاستبعدها بـ AND o.customer_id IS NOT NULL.

ويكتب الاستعلام الثاني في كتلة الشيفرة أدناه الفحص نفسه بـ NOT EXISTS. فالاستعلام الذي بين القوسين، ويسمى الاستعلام الفرعي (Subquery)، يُفحص لكل طلب، ويكون NOT EXISTS صحيحاً حين لا يجد أي صف. أما SELECT 1 فتعني فقط أن أعمدة الاستعلام الفرعي لا تهم، وأن المهم هو وجود صف أو عدمه.

قيد المفتاح الأجنبي المفروض يمنع الأيتام، لكنهم يظهرون في الواقع رغم ذلك:

  • لم يُصرَّح بالقيد قط، وهذا شائع في المخططات القديمة.
  • عُطّلت القيود أثناء استيراد جماعي أو ترحيل.
  • لا يفرض SQLite المفاتيح الأجنبية إلا إذا ضُبط PRAGMA foreign_keys = ON للاتصال.
  • يقع الأب والابن في قاعدتي بيانات لخدمتين مختلفتين.
-- Orders whose customer does not exist (expected: 0 rows)
SELECT o.order_id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL;
-- returns order 104 with customer_id 9: an orphan

-- The same check written with NOT EXISTS
SELECT o.order_id, o.customer_id
FROM orders o
WHERE NOT EXISTS (
  SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id
);

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

مثال تطبيقي: طلبات مفقودة من سجل الطلبات

بعد ترحيل من نظام متجر قديم، وصلت تذكرة دعم مفادها أن بعض العملاء لا يرون كل طلباتهم في صفحة «طلباتي»، التي تربط orders بـ customers ربطاً داخلياً. ويشتبه المختبِر في طلبات تشير إلى عملاء غير موجودين.

  • المتوقع: كل طلب مرحَّل يخص عميلاً موجوداً، فيعيد فحص الأيتام صفر صفوف.
  • الفعلي: 13 صفاً، كلها بقيمة customer_id أكبر من 900000، وهو ترقيم النظام القديم. فقد نسخ الترحيل أرقام العملاء القديمة بدلاً من تحويلها إلى المعرّفات الجديدة، فصارت هذه الطلبات لا تخص أي عميل موجود: لا يستطيع الربط الداخلي في الصفحة أن يقرنها بالعميل الذي سجّل دخوله، ولا يظهر أي خطأ.
  • التقرير: الاستعلام، ومعرّفات الطلبات الثلاثة عشر، والنتيجة المتوقعة صفر صفوف، والسبب المرجّح، وسؤال: هل يُضاف قيد مفتاح أجنبي؟
  • اختبار التأكيد (Confirmation Testing) بعد الإصلاح: يعيد الاستعلام نفسه صفر صفوف، ويرى عميل متأثر الطلبات المفقودة في الصفحة.
-- Orphan check after the migration (expected: 0 rows)
SELECT o.order_id, o.customer_id, o.created_at
FROM orders o
LEFT JOIN customers c ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL
ORDER BY o.order_id;

أخطاء شائعة

  • استخدام الربط الداخلي للبحث عن بيانات ناقصة، فهو يحذف بالضبط الصفوف غير المطابِقة التي تبحث عنها.
  • ترشيح الجدول الأيمن في WHERE بعد ربط أيسر، فيتحوّل إلى ربط داخلي. ضع مثل هذه الشروط في ON؛ ولا يبقى في WHERE من شروط الجدول الأيمن إلا اختبار IS NULL في فحص الأيتام.
  • الثقة بنتيجة تبدو معقولة لربط على عمود خاطئ، أو على جزء فقط من مفتاح مركّب، دون التحقق من عدد الصفوف.
  • قراءة صفوف الأب المتكررة في ربط من واحد إلى متعدد على أنها بيانات مكررة.
  • اختبار IS NULL على عمود في الجدول الأب يقبل NULL بدلاً من مفتاحه الأساسي.
  • افتراض أن الأيتام لا يمكن أن يوجدوا لأن نموذج البيانات يُظهر علاقة؛ تحقّق من أن المفتاح الأجنبي مصرَّح به ومفروض.

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

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

ضع في تقرير الخطأ الاستعلام بالضبط، وعدد الصفوف المتوقع (صفر لفحص السلامة)، والصفوف المُعادة. واحفظ الفحوص المفيدة استعلاماتٍ للقراءة فقط بأسماء واضحة ضمن مجموعة مشتركة، ليُعاد تشغيلها بعد الإصلاح وبعد كل ترحيل لاحق.

تمرين عملي

اربط الطلبات بالدفعات ثم اكشف الأيتام

المطلوب

يخزّن متجر الطلبات والدفعات في الجدولين أدناه. على الورق، أو في قاعدة بيانات محلية مزوّدة بهذه الصفوف على SQLite (الإصدار ⁦3.39.0⁩ أو أحدث، من أجل FULL JOIN) أو PostgreSQL، استنتج ما يعيده كل ربط، ثم اكتب فحصَي سلامة.

المادة
orders                     payments
order_id  status           payment_id  order_id  amount
201       PAID             1           201        90.00
202       PAID             2           207        60.00
203       PENDING          3           201        10.00
الخطوات
  1. اكتب نتيجة orders INNER JOIN payments على order_id وعدّ صفوفها.
  2. كرّر ذلك لـ LEFT JOIN من orders ولـ FULL JOIN، وعلّم كل NULL.
  3. اكتب فحص الدفعات اليتيمة ونتيجته المتوقعة على قاعدة بيانات سليمة.
  4. اكتب فحص الطلبات PAID بلا دفعة، واشرح لماذا يجب أن يكون ترشيح الحالة في WHERE لا في ON.
إظهار الإجابة النموذجية
الإجابة النموذجية

INNER JOIN: صفّان، الطلب 201 مع الدفعة 1 ومع الدفعة 3. ويُستبعد الطلبان 202 و203 والدفعة 2.

LEFT JOIN من orders: أربعة صفوف، بإضافة الطلبين 202 و203 وأعمدة الدفعة فيهما NULL. وFULL JOIN: خمسة صفوف، بإضافة الدفعة 2 أيضاً وأعمدة الطلب فيها NULL، إذ إن الطلب 207 غير موجود.

الدفعات اليتيمة: 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; والنتيجة المتوقعة على قاعدة بيانات سليمة: صفر صفوف. وهنا يعيد الاستعلام الدفعة 2، فتبلّغ عنها مع الاستعلام والصف.

الطلبات PAID بلا دفعة: SELECT o.order_id FROM orders o LEFT JOIN payments p ON p.order_id = o.order_id WHERE o.status = 'PAID' AND p.payment_id IS NULL; ويعيد الطلب 202. ترشيح الحالة يختبر الجدول الأيسر، فهو في WHERE يحذف الطلبات التي حالتها ليست PAID، مثل 203، ولا يحذف أي صف لمجرد أن أعمدة الدفعة فيه NULL. ولو نُقل إلى ON لاقتصر أثره على منع الطلب 203 من مطابقة أي دفعة، ولأبقى الربط الأيسر الطلب 203 مع أعمدة دفعة NULL، فيبلّغ الفحص عنه خطأً. أما اختبار p.payment_id IS NULL فيبقى في WHERE لأنه يجب أن يُطبَّق بعد الربط.

أسئلة شائعة

ما الفرق بين JOIN وINNER JOIN؟

لا فرق. فـ JOIN وحده يعني INNER JOIN، وLEFT JOIN اختصار لـ LEFT OUTER JOIN، وكلمة OUTER اختيارية. وكتابة INNER أو LEFT صراحةً توضّح القصد لمن يقرأ الاستعلام بعدك.

لماذا لا أكشف الأيتام بـ NOT IN؟

لأن NOT IN (subquery) لا يعيد أي صف إذا أعاد الاستعلام الفرعي قيمة NULL واحدة، فينجح الفحص بينما الأيتام موجودون. أما LEFT JOIN … IS NULL وNOT EXISTS فيعطيان الإجابة الصحيحة مع وجود NULL.

هل أحتاج إلى RIGHT JOIN أصلاً؟

ليس بالضرورة: يمكن إعادة كتابة أي RIGHT JOIN في صورة LEFT JOIN بتبديل موضعي الجدولين، وتفضّل معظم الفرق LEFT JOIN لأن الجدول الذي تبدأ منه يُقرأ أولاً. لكنك تحتاج إلى فهمه حين تقرأ استعلامات الآخرين.

هل يمكن ربط أكثر من جدولين؟

نعم. كل JOIN … ON إضافي يضيف جدولاً إلى النتيجة المبنية حتى الآن، مثل ربط orders بـ customers ثم بـ order_items. وتحقّق من عدد الصفوف بعد كل ربط تضيفه، لأن الربط من واحد إلى متعدد يضاعف الصفوف.

المراجع

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

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

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

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

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

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

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

للتعمّق أكثر

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

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