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

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

GROUP BY وHAVING ومستوى تفصيل النتيجة

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

الدرس في لمحة

اضغط الصفوف في صف واحد لكل مجموعة بـ GROUP BY، وصفِّ المجموعات بـ HAVING، وعُدّ بشروط باستخدام FILTER، وحدّد مستوى تفصيل كل نتيجة قبل أن تثق بها.

  1. 1صف واحد لكل ماذا؟
  2. 2WHERE مقابل HAVING
  3. 3عدّ جزء من الصفوف داخل كل مجموعة
  4. 4التحقق من مستوى التفصيل

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

يعود مارك من العمليات بالسؤال الذي لم تستطع الإجابة عنه في الدرس الماضي: «ما متوسط وقت الحل لدينا، حسب الأولوية؟». تعرف كيف تحسب ساعات تذكرة واحدة. أما الآن فتحتاج إلى صف واحد لكل أولوية، يلخّص كلٌّ منها عشرات التذاكر. هذا ما يفعله GROUP BY، والفكرة التي تحته، أي مستوى تفصيل النتيجة، هي أنفع مفهوم منفرد في SQL التحليلي.

صف واحد لكل ماذا؟

لكل جدول ولكل نتيجة مستوى تفصيل (grain): الشيء الذي يمثّله الصف الواحد. في support_tickets صف واحد لكل تذكرة. ومارك يريد صفًا واحدًا لكل أولوية. اكتب ذلك أولًا، ثم اجعل الـ query يطابقه:

SQL
SELECT
  priority,
  COUNT(*)                AS tickets,
  COUNT(closed_at)        AS closed,
  ROUND(AVG((julianday(closed_at) - julianday(opened_at)) * 24), 1) AS avg_hours,
  ROUND(MAX((julianday(closed_at) - julianday(opened_at)) * 24), 1) AS max_hours
FROM support_tickets
GROUP BY priority
ORDER BY avg_hours;

أربعة صفوف، صف لكل أولوية، وهذا يطابق مستوى التفصيل الذي حدّدته. التذاكر العاجلة تُغلق في 6.5 ساعات في المتوسط، والمرتفعة في يوم واحد أو نحوه، والمنخفضة والعادية في أكثر من ثلاثة أيام. لاحظ كيف يتخطّى COUNT(closed_at) وAVG التذاكر المفتوحة بصمت، كما وعدت الدروس السابقة تمامًا. والعمود max_hours مهم أيضًا: قد يبدو المتوسط جيدًا بينما انتظر عميل واحد أسبوعًا كاملًا.

ترتيب التقييم المنطقي: FROM، ثم يصفّي WHERE الصفوف، ويكوّن GROUP BY المجموعات، ويصفّي HAVING المجموعات، ويحسب SELECT أعمدة النتيجة، ويرتّب ORDER BY، ويقصّ LIMIT. FROM WHERE GROUP BY HAVING SELECT ORDER BY LIMIT يصفّي الصفوف يكوّن المجموعات يصفّي المجموعات يحسب
الترتيب المنطقي الذي يقيّم به SQL الـ query

يشرح هذا المخطط معظم أخطاء GROUP BY. يعمل WHERE قبل أن توجد المجموعات، فلا يستطيع أن يختبر COUNT(*)؛ هذه مهمة HAVING. ويعمل ORDER BY بعد SELECT، فيستطيع دائمًا استخدام الاسم المستعار لعمود. يقبل SQLite وPostgreSQL أيضًا الاسم المستعار في GROUP BY من باب التسهيل، ولهذا يعمل GROUP BY month أدناه، لكن الترتيب المنطقي يبقى كما في المخطط.

WHERE مقابل HAVING

يريد كريم من المالية أن يعرف أي المنتجات باعت 100 وحدة على الأقل بسعرها الكامل، دون أي خصم. عدد الوحدات لكل منتج قيمة تجميعية، فيذهب الحدّ إلى HAVING. أما «دون خصم» فصفة لكل سطر طلب على حدة، أي أنه شرط على الصفوف ومكانه WHERE:

SQL
SELECT
  product_id,
  SUM(quantity) AS units,
  COUNT(DISTINCT order_id) AS orders
FROM order_items
WHERE discount = 0
GROUP BY product_id
HAVING SUM(quantity) >= 100
ORDER BY units DESC
LIMIT 5;

اقرأه بترتيب المخطط: أسقِط السطور المخفّضة، وجمّع الباقي حسب المنتج، وأبقِ المنتجات التي لها 100 وحدة أو أكثر، ثم احسب ورتّب واقصّ. يعدّ COUNT(DISTINCT order_id) الطلبات لا السطور، لأن كل منتج في بيانات Cartwheel يظهر مرة واحدة على الأكثر في كل طلب، فلا يختلف عدد السطور عن عدد الطلبات إلا حين تعدّ عبر منتجات مختلفة.

عدّ جزء من الصفوف داخل كل مجموعة

تسأل هدى من التسويق عن نسبة طلبات كل شهر التي جاءت عبر تطبيق الجوال. تحتاج إلى عددين لكل شهر: كل الطلبات، وطلبات التطبيق. عبارة FILTER تقصر دالة تجميع واحدة على بعض الصفوف:

SQL
SELECT
  strftime('%Y-%m', order_date) AS month,
  COUNT(*) AS orders,
  COUNT(*) FILTER (WHERE channel = 'mobile_app') AS app_orders,
  ROUND(100.0 * COUNT(*) FILTER (WHERE channel = 'mobile_app') / COUNT(*), 1) AS app_pct
FROM orders
WHERE order_date >= '2025-07-01'
  AND order_date <  '2026-01-01'
GROUP BY month
ORDER BY month;

تتراوح حصة التطبيق بين 28% و40% على وجه التقريب، وحجم نوفمبر يزيد على ثلاثة أضعاف حجم يوليو بفضل Black Friday. هناك تفصيلان مهمّان. استخدام 100.0 بدل 100 يفرض القسمة العشرية؛ فـ SQLite يقسم الأعداد الصحيحة قسمة صحيحة، و47 / 129 تساوي 0. وFILTER جزء من SQL القياسي، ويدعمه SQLite وPostgreSQL. في المحرّكات التي لا تدعمه، اكتب SUM(CASE WHEN channel = 'mobile_app' THEN 1 ELSE 0 END)، وهو العدد نفسه بصيغة أطول.

التحقق من مستوى التفصيل

حين يعمل query مجمَّع، تحقّق منه قبل أن تُبلغ به:

  1. عدد الصفوف: هل يطابق عدد المجموعات الذي تتوقعه (4 أولويات، 6 أشهر)؟
  2. التفرّد: هل مفتاح GROUP BY فريد في النتيجة؟ هو كذلك بحكم البناء، إلا إذا كان عمود مجرّد يخفي شيئًا.
  3. المجاميع: هل مجموع عمود عبر المجموعات يساوي المجموع غير المجمَّع؟ في التذاكر، الأعداد الأربعة مجموعها 873.

لا يستغرق أيٌّ من هذه الفحوص أكثر من دقيقة، ومعًا تلتقط أغلب أخطاء التجميع قبل أن يلتقطها أصحاب الطلب. اجعلها جزءًا من الحلقة التي بدأتها في الدرس الأول، بين «اكتب الـ query» و«أجب». ويصبح الفحص الثالث حيويًا في الدروس القادمة، حين تبدأ بربط الجداول. فالـ JOIN قد يغيّر مستوى التفصيل دون أن يخبرك، والجمع على مستوى تفصيل خاطئ هو بداية الحساب المزدوج.

أهم النقاط

  • مستوى التفصيل (grain) في النتيجة هو ما يمثّله الصف الواحد؛ قُله بصوت عالٍ قبل كتابة GROUP BY، وتحقّق منه بعدها.
  • كل عمود في قائمة SELECT يجب أن يكون إمّا في GROUP BY أو داخل دالة تجميع؛ يسمح SQLite بالأعمدة المجرّدة لكنه يملؤها من صف اعتباطي.
  • يصفّي WHERE الصفوف قبل التجميع، ويصفّي HAVING المجموعات بعده، لذا تنتمي الشروط على SUM أو COUNT إلى HAVING.
  • COUNT(*) FILTER (WHERE …) أو SUM(CASE WHEN … THEN 1 ELSE 0 END) يعدّ جزءًا من الصفوف داخل كل مجموعة دون query ثانٍ.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

تحتاج إلى الدول التي يزيد عدد طلباتها في 2025 على 50. أين يذهب كل شرط؟

السؤال 2 من 4

ما مستوى التفصيل في هذه النتيجة؟

SQL
SELECT customer_id, strftime('%Y-%m', order_date) AS month, COUNT(*)
FROM orders
GROUP BY customer_id, month;
السؤال 3 من 4

في SQLite يعمل SELECT channel, status, COUNT(*) FROM orders GROUP BY channel دون خطأ. ماذا يعرض العمود status؟

السؤال 4 من 4

أي تعبير يعطي نسبة الطلبات الملغاة من طلبات كل شهر، كنسبة مئوية؟

تدريب

تمرين SQL

المنتجات الأكثر مبيعًا لدى كريم

يريد كريم كل منتج باع 130 وحدة على الأقل عبر كل سطور الطلبات (تجاهل حالة الطلب الآن).

أعِد صفًا واحدًا لكل منتج فيه product_id وunits (إجمالي الكمية) وrevenue (مجموع quantity * unit_price * (1 - discount) مقرّبًا إلى منزلتين عشريتين). ترتيب الصفوف لا يهم.

الكود المبدئي يعيد صفًا واحدًا. اجعله يعيد مستوى التفصيل الصحيح وطبّق الحدّ.

الجداول
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)
-- Best-selling products: at least 130 units
SELECT
  product_id,
  SUM(quantity) AS units,
  ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue
FROM order_items
ORDER BY units DESC;جارٍ تحميل المحرّر…

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