التجميع وGROUP BY في SQL
دالة التجميع (Aggregate Function) مثل COUNT أو SUM أو AVG أو MIN أو MAX تقرأ عموداً عبر مجموعة من الصفوف وتعيد قيمة واحدة. ويقسّم GROUP BY الصفوف إلى مجموعات فتعمل الدالة مرة لكل مجموعة: SELECT status, COUNT(*) FROM orders GROUP BY status يعيد صفاً لكل حالة مع عدد طلباتها.
وبهذا يسأل المختبِر قاعدة البيانات عن مجموعات من السجلات لا عن سجل واحد، بناءً على SELECT وWHERE والربط (JOIN). ثم يرشّح HAVING المجموعات، وتقارن فحوص المطابقة (Reconciliation) العدد أو المجموع نفسه محسوباً في مكانين.
لماذا يهم
كثير من عيوب البيانات لا يُرى صفاً بصف: طلب يختلف إجماليه عن أسطره، أو استيراد حمّل 9998 صفاً من 10000، أو تقرير يحتسب الطلبات الملغاة. كل صف يبدو معقولاً، ولا يظهر العيب إلا حين تُعدّ مجموعة من الصفوف أو تُجمع ثم تُقارن بقيمة متوقعة.
واستعلام تجميع واحد يفحص تقريراً أو دفعة فواتير أو عملية ترحيل لا يستطيع أحد قراءتها صفاً بصف. والخطر هو الاستعلام الخاطئ: قيمة NULL متجاهَلة، أو ربط يكرّر الصفوف، أو شرط في الموضع الخطأ، فيعطي نجاحاً كاذباً أو إنذاراً كاذباً.
دوال التجميع: COUNT وSUM وAVG وMIN وMAX
تعيد دالة التجميع قيمة واحدة لمجموعة من الصفوف. ودون GROUP BY تشكّل كل الصفوف التي تجتاز WHERE مجموعة واحدة، فيعيد الاستعلام صفاً واحداً بالضبط حتى لو لم يطابق شيء. ويعرض مقطع الشيفرة جدول orders ذا الصفوف الستة المستخدم في هذا الدرس كله.
وأهم تفصيل للاختبار هو NULL: كل دوال التجميع ما عدا COUNT(*) تتجاهله. ثلاثة طلبات بلا تقييم، لذا COUNT(rating) يساوي 3، وAVG(rating) يساوي 12 / 3 = 4 لا 12 / 6 = 2. وإن كانت قاعدة العمل تعدّ القيمة الغائبة صفراً فاكتب AVG(COALESCE(rating, 0)) ودوّن ذلك في حالة الاختبار.
COUNT(*)يعدّ الصفوف، وCOUNT(column)يعدّ القيم التي ليست NULL، وCOUNT(DISTINCT column)يعدّ القيم المختلفة التي ليست NULL.SUMعلى صفر صفوف، أو على قيم NULL فقط، يعيد NULL لا صفراً: قارنCOALESCE(SUM(amount), 0).AVGفي SQL Server يعيد عدداً صحيحاً لعمود من الأعداد الصحيحة؛ حوّل القيمة إلى نوع عشري أولاً.MINوMAXيعملان على الأرقام والتواريخ والنصوص، فهما فحص سريع للنطاق مثل أقدمcreated_atفي عملية استيراد.
-- orders: the example table used in this lesson
-- order_id customer_id status total_amount rating
-- 1001 7 PAID 120.00 5
-- 1002 7 PAID 80.00 NULL
-- 1003 9 CANCELLED 50.00 NULL
-- 1004 9 PAID 200.00 4
-- 1005 12 PENDING 35.00 NULL
-- 1006 12 PAID 15.00 3
SELECT COUNT(*) AS all_rows, -- 6
COUNT(rating) AS rated, -- 3
COUNT(DISTINCT customer_id) AS customers, -- 3
SUM(total_amount) AS amount, -- 500.00
AVG(rating) AS avg_rating, -- 4 = 12 / 3, not 12 / 6
MIN(total_amount) AS smallest, -- 15.00
MAX(total_amount) AS largest -- 200.00
FROM orders;جرّبها: مولّد جمل SQL INSERT جرّبها: مولّد مجموعات بيانات SQL
GROUP BY: صف واحد في الناتج لكل مجموعة
يضع GROUP BY الصفوف التي تتشارك القيمة نفسها في أعمدة التجميع في مجموعة واحدة، ويحسب كل دالة تجميع مرة لكل مجموعة. فـGROUP BY status يحوّل الطلبات الستة إلى PAID (أربعة صفوف) وCANCELLED (صف واحد) وPENDING (صف واحد)، والتجميع حسب customer_id, status يكوّن مجموعة لكل تركيبة.
كل عمود في قائمة SELECT يجب أن يكون مجمَّعاً أو داخل دالة تجميع. فـSELECT status, order_id, COUNT(*) … GROUP BY status يطلب قيمة order_id واحدة بينما في PAID أربع قيم. يرفضه PostgreSQL وSQL Server، وكذلك MySQL في وضعه الافتراضي ONLY_FULL_GROUP_BY؛ أما SQLite فيعيد قيمة من صف غير محدد، فقد يبدو الفحص صحيحاً بالمصادفة.
- قيمة NULL في عمود التجميع تكوّن مجموعة مستقلة، وهي غالباً ملاحظة بحد ذاتها.
- لا ترتيب مضموناً للمجموعات: أضف ORDER BY قبل المقارنة بتقرير.
- يجب أن تساوي مجاميع المجموعات المجموع الكلي: 50.00 + 415.00 + 35.00 = 500.00 يُثبت أنه لم يُفقد صف ولم يُحسب صف مرتين.
SELECT status,
COUNT(*) AS orders,
SUM(total_amount) AS amount
FROM orders
GROUP BY status
ORDER BY status;
-- status orders amount
-- CANCELLED 1 50.00
-- PAID 4 415.00
-- PENDING 1 35.00WHERE يرشّح الصفوف وHAVING يرشّح المجموعات
يُقيَّم الاستعلام المجمَّع بترتيب منطقي: FROM وJOIN، ثم WHERE، ثم GROUP BY، ثم دوال التجميع، ثم HAVING، ثم SELECT، ثم ORDER BY. قد ينفّذه المحرك بطريقة أخرى، لكن النتيجة دائماً كأنه اتبع هذا الترتيب. ولهذا لا يستطيع WHERE استخدام دالة تجميع، إذ لم توجد المجموعات بعد، ويستطيع HAVING ذلك.
ضع الشروط على الصفوف المفردة، مثل الحالة أو نطاق تاريخ، في WHERE، والشروط على نتائج المجموعات، مثل COUNT(*) > 1، في HAVING. يجد الاستعلام أدناه العملاء الذين لهم أكثر من طلب مدفوع: العميل 7 وحده، بطلبين و200.00. احذف سطر WHERE فيظهر العميلان 9 و12 أيضاً، لأن طلباتهما الملغاة والمعلّقة صارت تُحتسب.
وبالشكل نفسه يُكتب فحص التكرار المعتاد: SELECT email, COUNT(*) FROM customers GROUP BY email HAVING COUNT(*) > 1 يعرض كل عنوان بريد إلكتروني مسجَّل لأكثر من عميل.
- 1قراءة الصفوفستة صفوف: الطلبات من 1001 إلى 1006
FROM orders - 2ترشيح الصفوفتبقى أربعة صفوف: 1001 و1002 و1004 و1006
WHERE status = 'PAID'ترشيح صفوففرع: حُذف: 1003 CANCELLED و1005 PENDING
- 3تكوين المجموعاتثلاث مجموعات: العميل 7 (1001 و1002)، و9 (1004)، و12 (1006)
GROUP BY customer_id - 4حساب دوال التجميعالعميل 7: طلبان، 200.00 · 9: طلب واحد، 200.00 · 12: طلب واحد، 15.00
COUNT(*), SUM(total_amount) - 5ترشيح المجموعاتتبقى مجموعة واحدة: العميل 7
HAVING COUNT(*) > 1ترشيح مجموعاتفرع: حُذف: العميلان 9 و12، لكل منهما طلب مدفوع واحد
- 6إنتاج الناتجصف واحد في الناتج: العميل 7، طلبان، 200.00
SELECT customer_id, …
طوال الخطوات 4–6 دوال التجميع موجودة هنا: يستطيع HAVING فحصها ولا يستطيع WHERE
ترتيب منطقي: قد ينفّذ المحرك الاستعلام بطريقة أخرى، والنتيجة هي نفسها.
-- Customers with more than one PAID order
SELECT customer_id,
COUNT(*) AS paid_orders,
SUM(total_amount) AS paid_amount
FROM orders
WHERE status = 'PAID' -- row filter, before grouping
GROUP BY customer_id
HAVING COUNT(*) > 1; -- group filter, after aggregation
-- customer_id paid_orders paid_amount
-- 7 2 200.00فحوص المطابقة: رقمان يجب أن يتفقا
المطابقة (Reconciliation) تحسب العدد أو المجموع نفسه في مكانين وتقارنهما. والمكان الثاني هو مرجع الاختبار (Test Oracle)، أي مصدر النتيجة المتوقعة: رأس المستند مقابل أسطره، والنظام المصدر مقابل النظام الهدف بعد استيراد، وقاعدة البيانات مقابل تقرير أو استجابة API.
اكتب الفحص بحيث تعيد قاعدة البيانات السليمة صفر صفوف: جمّع حسب المفتاح المشترك واستبقِ بـ HAVING المجموعات المختلفة فقط. وكل صف يعود دليل لتقرير خطأ. وقارن لكل مجموعة لا في الإجمالي الكلي وحده، حيث قد يلغي خطآن متعاكسان أحدهما الآخر.
- تضاعف الصفوف بالربط (Join Fan-out): ربط الطلبات بأسطرها يكرّر كل طلب بعدد أسطره، فيضخّم
SUM(o.total_amount)الإجمالي بعد الربط. اجمع الأسطر لكل طلب كما يفعل مقطع الشيفرة. - الصفوف الغائبة: الطلب الذي لا أسطر له يختفي من الربط الداخلي (INNER JOIN). ويُبقيه LEFT JOIN، لكن مجموعه NULL والمقارنة
total_amount <> NULLلا تكون صحيحة أبداً، فيستبعده HAVING رغم ذلك؛ أما COALESCE فيحوّل NULL إلى صفر فيظهر الاختلاف.
-- Rule: an order's total_amount equals the sum of its item lines
SELECT o.order_id,
o.total_amount,
COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS lines_total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> COALESCE(SUM(oi.quantity * oi.unit_price), 0);
-- Zero rows: the rule holds. Every row returned is a mismatch to report.مثال تطبيقي: مطابقة تقرير المبيعات اليومي
المتطلب: «يعرض تقرير المبيعات اليومي لكل يوم عدد الطلبات المدفوعة (PAID) ومجموع مبالغها بتوقيت الرياض». ويُخزَّن created_at بتوقيت الرياض، فتجمّع الطلبات المدفوعة حسب التاريخ للفترة من 1 إلى 3 سبتمبر وتقارن كل يوم بالتقرير. ويحذف CAST(created_at AS DATE) الوقت من الطابع الزمني، فتقع كل طلبات التاريخ الواحد في مجموعة واحدة.
يتطابق يومان ويختلف يوم 2 سبتمبر. واختلاف العدد والمبلغ معاً يوحي بصفوف زائدة لا بمبالغ خاطئة، فتعمّق في ذلك اليوم بتجميع طلباته حسب كل الحالات.
- قاعدة البيانات: 38 طلباً و5720.50. التقرير: 40 طلباً و5905.50. الفرق: طلبان و185.00.
- حسب الحالة: في CANCELLED طلبان و185.00، وهو الفرق بالضبط. وتطابق الأرقام قرينة قوية لا دليل قاطع: إن كان التقرير يعرض قائمة طلباته فتأكد من أنها تضم هذين الطلبين.
- تقرير الخطأ: يبدو أن المبيعات اليومية تحتسب طلبات أُلغيت بعد دفعها. أرفق الاستعلامين ونتيجتيهما، واسأل مالك المنتج هل تدخل هذه الطلبات في مبيعات اليوم، فالمتطلب لا يذكر ذلك.
-- 1. Expected values: PAID orders per day, 1 to 3 September
-- created_at holds Riyadh local time; in SQLite write date(created_at)
SELECT CAST(created_at AS DATE) AS sales_day,
COUNT(*) AS paid_orders,
SUM(total_amount) AS paid_amount
FROM orders
WHERE status = 'PAID'
AND created_at >= '2026-09-01' AND created_at < '2026-09-04'
GROUP BY CAST(created_at AS DATE)
ORDER BY sales_day;
-- sales_day database report result
-- 2026-09-01 41 6150.00 41 6150.00 match
-- 2026-09-02 38 5720.50 40 5905.50 mismatch
-- 2026-09-03 44 6480.00 44 6480.00 match
-- 2. Drill down into 2 September, every status
SELECT status, COUNT(*) AS orders, SUM(total_amount) AS amount
FROM orders
WHERE created_at >= '2026-09-02' AND created_at < '2026-09-03'
GROUP BY status
ORDER BY status;
-- CANCELLED 2 185.00 <- exactly the difference
-- PAID 38 5720.50
-- PENDING 3 240.00أخطاء شائعة
- استخدام
COUNT(column)لعدّ الصفوف؛ فهو يتجاوز قيم NULL. - مقارنة
SUM(...)دون COALESCE، فتسقط من فحص الاختلاف المجموعةُ التي مجموعها NULL. - اختيار أعمدة ليست مجمَّعة ولا داخل دالة تجميع، ثم الوثوق بما يعيده المحرك.
- ترشيح الصفوف في HAVING، أو محاولة استخدام دالة تجميع في WHERE.
- جمع مبالغ الرؤوس بعد ربطها بأسطرها.
- مطابقة الإجمالي الكلي وحده بدل المطابقة لكل يوم أو حالة أو دفعة.
من منظور ضمان الجودة
تنتمي فحوص التجميع إلى كل ميزة تنتج أرقاماً: التقارير ولوحات المؤشرات والفواتير والاستيراد والتصدير والترحيل. وأثناء تحليل الاختبار اكتب كل قاعدة جملةً يمكن أن تصير استعلاماً، واتفق مع مالك المنتج على مرشحاتها الدقيقة: أي الحالات تُحتسب، وأي منطقة زمنية تحدد اليوم، وهل تُستبعد حسابات الاختبار.
شغّل الاستعلامات بحساب للقراءة فقط، مقيَّدةً بالتواريخ أو المفاتيح. وسجّل طرفي كل مقارنة مع حالة الاختبار: الاستعلام ونتيجته والرقم الآخر والفرق. وإذا فشل فحص فتعمّق بـ GROUP BY أدق حتى يُفسَّر الفرق، وضع الاستعلامات في تقرير الخطأ ليعيد المطور تشغيلها.
تمرين عملي
طابق الفواتير مع أسطرها
تخزّن خدمة فوترة رؤوس الفواتير وأسطرها. والقاعدة: total للفاتورة يساوي مجموع quantity * unit_price لأسطرها، ولكل فاتورة سطر واحد على الأقل. والجدولان أدناه هما بيانات الاختبار كاملة.
اكتب استعلاماً يعيد الفواتير المخالفة للقاعدة فقط واستنتج نتيجته على الورق. ثم اكتب استعلاماً يعدّ الفواتير ويجمع إجمالياتها لكل عميل.
invoices
invoice_id customer_id total
501 21 90.00
502 21 45.00
503 22 60.00
504 23 25.00
invoice_lines
line_id invoice_id quantity unit_price
1 501 2 30.00
2 501 1 30.00
3 502 3 15.00
4 503 1 40.00
5 503 1 15.00- حدّد الرأس والتفاصيل ومفتاح الربط.
- اختر نوع ربط يُبقي الفواتير التي لا أسطر لها.
- جمّع حسب الفاتورة، واستبقِ الاختلافات بـ HAVING، وعالج المجموع الذي قد يكون NULL.
- افحص كل فاتورة يدوياً.
إظهار الإجابة النموذجية
الاستعلام: SELECT i.invoice_id, i.total, COALESCE(SUM(l.quantity * l.unit_price), 0) AS lines_total FROM invoices i LEFT JOIN invoice_lines l ON l.invoice_id = i.invoice_id GROUP BY i.invoice_id, i.total HAVING i.total <> COALESCE(SUM(l.quantity * l.unit_price), 0).
النتيجة: الفاتورة 503 (الإجمالي 60.00 والأسطر 40.00 + 15.00 = 55.00) والفاتورة 504 (الإجمالي 25.00 بلا أسطر، وlines_total صفر). أما 501 (90.00) و502 (45.00) فمتطابقتان. ومع الربط الداخلي أو دون COALESCE تضيع الفاتورة 504. ولا يكشف فحص المبالغ أيضاً فاتورة بلا أسطر إجماليها 0.00؛ وإضافة OR COUNT(l.line_id) = 0 إلى HAVING تفحص الشق الثاني من القاعدة مستقلاً.
لكل عميل: SELECT customer_id, COUNT(*) AS invoices, SUM(total) AS amount FROM invoices GROUP BY customer_id ORDER BY customer_id يعطي 21: فاتورتان و135.00؛ 22: فاتورة و60.00؛ 23: فاتورة و25.00، ومجموعها 220.00. ولو رُبطت الأسطر أولاً بربط داخلي لظهر العميل 21 خطأً بثلاث فواتير و225.00، والعميل 22 بفاتورتين و120.00، ولاختفى العميل 23.
أسئلة شائعة
ما الفرق بين WHERE وHAVING؟
يرشّح WHERE الصفوف المفردة قبل تجميعها، ولا يستطيع استخدام دوال التجميع. ويرشّح HAVING مجموعات كاملة بعد حساب دوال التجميع، فيستطيع استخدام COUNT وSUM وغيرهما. ويمكن أن يستخدم الاستعلام الاثنين معاً: WHERE للصفوف التي تدخل في الحساب، وHAVING للمجموعات التي تريد رؤيتها.
لماذا يعطي COUNT(*) رقماً مختلفاً عن COUNT(column)؟
يعدّ COUNT(*) الصفوف، ويعدّ COUNT(column) الصفوف التي ليست قيمة العمود فيها NULL فقط. والفرق بينهما هو عدد الصفوف التي فيها NULL في ذلك العمود، وهو بحد ذاته فحص مفيد للحقول التي يجب أن تكون إلزامية.
هل يمكنني استخدام اسم بديل (Alias) من SELECT داخل HAVING؟
ليس بطريقة تعمل على كل المحركات. ففي الترتيب المنطقي يأتي HAVING قبل SELECT، ويرفض PostgreSQL وSQL Server الاسم البديل هناك، بينما يقبله MySQL وSQLite. كرّر تعبير التجميع نفسه، كما تفعل أمثلة هذا الدرس، فيعمل الاستعلام على كل المحركات.
لماذا تغيب عن النتيجة مجموعة كنت أتوقعها؟
لا توجد مجموعة إلا إذا وصل صف واحد على الأقل إلى GROUP BY. فاليوم الذي لا طلبات فيه، أو الحالة التي لم يبق لها صف بعد WHERE، لا ينتج أي صف، لا صفاً قيمته صفر. ولإظهار المجموعات الغائبة ابدأ من قائمة بالأيام أو المفاتيح المتوقعة، واربط بها البيانات المجمَّعة بـ LEFT JOIN.
المراجع
- PostgreSQL Documentation: SQL Syntaxالبنية المعجمية لـ SQL في PostgreSQL: المعرّفات والاقتباس والثوابت النصية والتهريب.
- SQL As Understood By SQLiteقواعد SQL التي يقبلها SQLite، عبارة تلو الأخرى، مع مخططات نحوية.
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)الجزء الإطاري من معيار SQL الدولي، الذي تنفّذه لهجات PostgreSQL وMySQL وSQLite وغيرها بدرجات متفاوتة.
- ISTQB Glossaryالتعريفات القياسية لمصطلحات الاختبار — حالة الاختبار واختبار الانحدار والخطورة والأولوية وتحليل القيم الحدّية وغيرها.
تدرّب بأدوات متقن
فحص جمل SQL بحثاً عن مشكلات الصياغة والأقواس غير المتوازنة والسلاسل النصية غير المغلقة والأنماط الخطرة.
تنسيق استعلامات SQL بحالة أحرف متسقة للكلمات المفتاحية ومسافات بادئة وفواصل أسطر (MySQL وPostgreSQL وSQL Server وOracle وSQLite).
توليد جمل INSERT من تعريف جدول أو صفوف نموذجية أو CSV أو JSON.
ولّد سكربتات تهيئة SQL (جملة CREATE TABLE وجمل INSERT) بصفوف اختبار واقعية لأي لهجة.
تحويل البيانات الجدولية إلى قائمة SQL VALUES أو جدول مشتق مبني على VALUES.
تحويل بيانات CSV إلى جمل CREATE TABLE وINSERT مع استنتاج أنواع الأعمدة.
للتعمّق أكثر
تقدّمك في هذا الموضوع
سجّل الدخول لتتابع تقدّمك
علّم الموضوعات مكتملة، وتابع من حيث توقفت، وافتح امتحانات التخصص.