EduFlow
SQL للمحلّلين
⁦0%⁩

الوحدة 2 · التجميع والـ JOIN

الـ INNER JOIN والـ LEFT JOIN

17 دقراءة 4 دتمرين SQL

الدرس في لمحة

اربط الجداول بـ INNER JOIN وLEFT JOIN على المفاتيح الصحيحة، وحدّد الجدول لكل عمود يتكرّر اسمه، واحتفظ بالصفوف غير المطابقة بوضع شروط الجدول الأيمن في عبارة ON.

  1. 1تتبّع المفاتيح
  2. 2INNER مقابل LEFT
  3. 3ON مقابل WHERE في الـ LEFT JOIN
  4. 4اختيار الـ JOIN، والجدول الذي تبدأ منه

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

يسأل كريم من المالية: «كم كان إيرادنا في 2025 حسب فئة المنتج؟». الإيراد موجود في order_items، والسنة في orders، واسم الفئة في categories، على بُعد خطوتين عبر products. لا يوجد جدول واحد يجيب عن السؤال. الـ JOIN يجمعها معًا، وضبطه يدور في معظمه حول معرفة الصفوف التي يُبقيها كل JOIN.

تتبّع المفاتيح

كل JOIN يحتاج إلى شرط يقول أيّ الصفوف تنتمي إلى بعضها، وهو في الغالب مفتاح: order_items.order_id يشير إلى orders.order_id، وorder_items.product_id إلى products.product_id، وهكذا. في Cartwheel يُحسب الإيراد من كل الطلبات ما عدا الملغاة.

SQL
SELECT
  c.name AS category,
  COUNT(DISTINCT o.order_id) AS orders,
  ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products    AS p  ON p.product_id = oi.product_id
JOIN categories  AS c  ON c.category_id = p.category_id
WHERE o.status <> 'cancelled'
  AND o.order_date >= '2025-01-01'
  AND o.order_date <  '2026-01-01'
GROUP BY c.name
ORDER BY revenue DESC;

تتصدّر Desks & Chairs بنحو 77,000. لكن صفّين يبدوان غريبين: “Kitchen” و"Outdoor" فئتان في المستوى الأعلى، ويُفترض أن تقع المنتجات في فئات فرعية. احتفظ بهذه الملاحظة؛ إنها اكتشاف في جودة البيانات ستتعقّبه في القسم 4.

عادتان تجعلان الـ queries التي فيها JOIN مقروءة وآمنة. الأسماء المستعارة القصيرة (o، oi، p، c) تُبقي الأسطر قصيرة. ونسبة كل عمود إلى اسمه المستعار تزيل التخمين، وهذا مهم هنا: order_items.unit_price هو السعر المدفوع يوم الطلب، وproducts.unit_price هو سعر القائمة اليوم. كتابة unit_price بلا نسبة في هذا الـ query تعطي خطأ “ambiguous column name”، واختيار العمود الخطأ يغيّر الإيراد بصمت.

وCOUNT(DISTINCT o.order_id) مقصود أيضًا. بعد ربط العناصر، يظهر كل طلب مرة لكل سطر عنصر، فيعدّ COUNT(*) السطور. وهذا التكرار هو موضوع الدرس التالي.

INNER مقابل LEFT

JOIN تعني INNER JOIN: لا يبقى الصف إلا إذا وجد تطابقًا في الجانب الآخر. هذا صحيح للإيراد، حيث لا معنى لعنصر بلا طلب. لكنه خاطئ حين يكون السؤال عن كل ما في جانب واحد، بما في ذلك ما ليس له تطابق.

تطلب جوليا عدد التذاكر لكل موظف في العمليات، «بما في ذلك من لا يتولّون التذاكر، حتى أرى من يمكنه المساعدة في موسم الذروة»:

SQL
SELECT
  e.employee_id,
  e.first_name,
  e.title,
  COUNT(t.ticket_id) AS tickets
FROM employees AS e
LEFT JOIN support_tickets AS t
  ON t.agent_id = e.employee_id
WHERE e.department = 'Operations'
GROUP BY e.employee_id
ORDER BY tickets DESC;

ثمانية صفوف. لدى كل واحد من الوكلاء الأربعة نحو 200 تذكرة؛ ولدى مارك وسمير وبن وآدم صفر. يُبقي الـ LEFT JOIN كل صف من الجدول الأيسر، وهو هنا employees، وحيث لا تطابقه أي تذكرة تكون أعمدة التذاكر NULL. يتخطّى COUNT(t.ticket_id) هذه القيم، ولهذا يظهر لغير الوكلاء 0 لا 1.

على اليسار الموظفون جوليا وتوم ومارك؛ وعلى اليمين التذكرتان t1 وt2 اللتان تولّتهما جوليا وt3 التي تولّاها توم. نتيجة الـ inner join فيها ثلاثة صفوف. ونتيجة الـ left join فيها الصفوف الثلاثة نفسها إضافةً إلى مارك بتذكرة قيمتها NULL. employees 6 Julia 8 Tom 2 Mark tickets t1 agent 6 t2 agent 6 t3 agent 8 INNER Julia t1 Julia t2 Tom t3 LEFT Julia t1 Julia t2 Tom t3 Mark NULL
الـ INNER JOIN يُبقي المتطابقات؛ والـ LEFT JOIN يُبقي أيضًا صفوف اليسار غير المطابقة

ON مقابل WHERE في الـ LEFT JOIN

الآن تضيّق جوليا الطلب: «القائمة نفسها، لكن اعدد التذاكر العاجلة فقط». التعديل البديهي يضيف AND t.priority = 'urgent' إلى عبارة WHERE، فيختفي غير الوكلاء الأربعة. صفوفهم فيها t.priority قيمته NULL، فيكون الشرط NULL، ويحذفهم WHERE. الشرط على الجدول الأيمن في WHERE يعيد الـ LEFT JOIN إلى INNER JOIN.

مكان هذا الشرط هو عبارة ON، حيث يحدّد أي التذاكر تتطابق، لا أي الصفوف تبقى:

SQL
SELECT
  e.first_name,
  COUNT(t.ticket_id) AS urgent_tickets
FROM employees AS e
LEFT JOIN support_tickets AS t
  ON t.agent_id = e.employee_id
 AND t.priority = 'urgent'
WHERE e.department = 'Operations'
GROUP BY e.employee_id
ORDER BY urgent_tickets DESC;

ثمانية صفوف من جديد، والأصفار في أماكنها. القاعدة: الشروط على الجدول الأيسر في WHERE؛ والشروط على الجدول الأيمن في ON.

اختيار الـ JOIN، والجدول الذي تبدأ منه

طريقة موثوقة للقرار: ابدأ FROM بالجدول الذي تريد صفوفه في الإجابة، أي الجدول الذي يطابق مستوى التفصيل الذي كتبته. ثم اسأل عن كل جدول آخر: «هل هذه المعلومة إلزامية أم اختيارية؟». المعلومة الإلزامية تأخذ inner join، لأن الصف الذي يفتقدها لا مكان له في الإجابة. والمعلومة الاختيارية تأخذ LEFT JOIN، حتى تظهر التطابقات المفقودة كقيم NULL يمكنك عدّها أو تسميتها أو تحويلها إلى أصفار.

في قائمة جوليا تدور الإجابة حول الموظفين، فيأتي employees أولًا، والتذاكر اختيارية. وفي إيراد كريم تدور الإجابة حول المبيعات، فكل جدول في السلسلة إلزامي.

يدعم SQLite أيضًا RIGHT JOIN وFULL OUTER JOIN (منذ الإصدار 3.39)، وPostgreSQL يدعمهما منذ عقود. نادرًا ما ستحتاج إليهما: الـ right join هو left join مع تبديل الجدولين، ومعظم الفرق تكتب كل شيء بـ LEFT JOIN حتى تُقرأ الـ queries بالطريقة نفسها، من الأعلى إلى الأسفل، من الجدول الرئيسي نحو الخارج.

أخيرًا، افحص عدد الصفوف بعد كل JOIN تضيفه. إذا كان في employees بعد تصفيته على العمليات 8 صفوف، فيجب أن يعيد الـ LEFT JOIN مع GROUP BY ثمانية أيضًا. الرقم الذي يكبر أو يصغر على غير المتوقع يخبرك أن JOIN ما يفعل شيئًا لم تقصده.

كان في إيراد الفئات فخ آخر لم يُصبنا، لأن الإيراد كان في الجدول الأكثر تفصيلًا. حين يقع الرقم في الجانب الأقل تفصيلًا من الـ JOIN، تتضخّم المجاميع. وهذا هو موضوعنا التالي.

أهم النقاط

  • الـ INNER JOIN لا يُبقي إلا الصفوف المتطابقة في الجانبين؛ والـ LEFT JOIN يُبقي كل صف من الجدول الأيسر ويملأ أعمدة الجانب الأيمن غير المطابقة بـ NULL.
  • أعطِ كل جدول اسمًا مستعارًا قصيرًا وانسب إليه كل عمود، خاصةً الأسماء الموجودة في أكثر من جدول مثل unit_price.
  • الشرط على الجدول الأيمن في LEFT JOIN مكانه عبارة ON؛ أما في WHERE فيحذف صفوف NULL ويعيد الـ JOIN إلى inner join.
  • بعد LEFT JOIN اعدد عمودًا من الجدول الأيمن لا *، حتى تُحسب الصفوف غير المطابقة 0 لا 1.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

في orders يوجد 2,066 صفًا وفي order_items يوجد 3,991. لكل طلب عنصر واحد على الأقل. كم صفًا يعيد orders o JOIN order_items oi ON oi.order_id = o.order_id؟

السؤال 2 من 4

يُفترض أن يعرض هذا كل عميل في المملكة المتحدة مع عدد طلباته من الـ marketplace، بما فيها الصفر. لماذا يختفي العملاء الذين ليس لديهم طلبات من الـ marketplace؟

SQL
SELECT c.customer_id, COUNT(o.order_id) AS marketplace_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.country = 'United Kingdom'
  AND o.channel = 'marketplace'
GROUP BY c.customer_id;
السؤال 3 من 4

يفشل SELECT unit_price FROM order_items oi JOIN products p ON p.product_id = oi.product_id. لماذا؟

السؤال 4 من 4

بعد employees e LEFT JOIN support_tickets t ON t.agent_id = e.employee_id، أي تعبير يعطي 0 تذاكر للموظفين الذين لم يتولّوا أي تذكرة؟

تدريب

تمرين SQL

كل عملاء الأردن، بما فيهم الأصفار

تخطّط هدى لرسالة بريد لاستعادة العملاء في الأردن، وتحتاج إلى كل عميل في الأردن مع عدد الطلبات غير الملغاة التي قدّمها في 2025، مع إظهار 0 لمن لم يطلب.

أعِد customer_id وfirst_name وorders_2025. ترتيب الصفوف لا يهم. في الأردن 32 عميلًا، فتوقّع 32 صفًا؛ والكود المبدئي يعيد أقل من ذلك.

الجداول
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Every Jordan customer with their 2025 orders (zero included)
SELECT
  c.customer_id,
  c.first_name,
  COUNT(o.order_id) AS orders_2025
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
WHERE c.country = 'Jordan'
  AND o.status <> 'cancelled'
  AND o.order_date >= '2025-01-01'
  AND o.order_date < '2026-01-01'
GROUP BY c.customer_id;جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.