تخطَّ إلى المحتوى

التجميع في MongoDB: aggregate و $group و $match

الدرس 6 من 8 · MongoDB

find تجيب عن سؤال «أي المستندات؟». لكن مدير المكتبة يسأل أسئلة أخرى: كم استعارة في كل مدينة؟ من أكثر الأعضاء نشاطاً؟ ما مجموع غرامات التأخير؟ هذه أسئلة تلخيص، وأداتها في MongoDB هي aggregate. في هذا الدرس ستتعلم خط التجميع (Aggregation Pipeline)، وهو المقابل لـ GROUP BY في SQL وأقوى منه.

نضيف إلى قاعدة المكتبة مجموعة جديدة تسجّل الاستعارات:

db.loans.insertMany([
{ member: 'هدى', city: 'القاهرة', bookId: 1, days: 7, fee: 0 },
{ member: 'كريم', city: 'عمّان', bookId: 3, days: 14, fee: 0 },
{ member: 'هدى', city: 'القاهرة', bookId: 3, days: 21, fee: 10 },
{ member: 'ريم', city: 'القاهرة', bookId: 5, days: 10, fee: 0 },
{ member: 'زياد', city: 'تونس', bookId: 1, days: 30, fee: 25 },
{ member: 'كريم', city: 'عمّان', bookId: 2, days: 5, fee: 0 },
{ member: 'هدى', city: 'القاهرة', bookId: 5, days: 16, fee: 5 }
])

في كل مستند: اسم العضو، ومدينته، ورقم الكتاب، ومدة الاستعارة بالأيام، وغرامة التأخير.

aggregate تأخذ مصفوفة من المراحل (Stages). المستندات تدخل المرحلة الأولى، وناتجها يدخل الثانية، وهكذا:

db.loans.aggregate([ مرحلة1, مرحلة2, مرحلة3 ])

تخيّله مثل خط إنتاج في مصنع عصير: محطة تفرز الفاكهة، ومحطة تعصرها، ومحطة تعبئها. كل محطة تستلم ناتج التي قبلها.

المرحلة وظيفتها مقابلها في SQL
$match تصفية المستندات WHERE
$group تجميع وحساب GROUP BY
$sort ترتيب ORDER BY
$project اختيار الحقول وحساب حقول جديدة قائمة SELECT
$limit أول عدد من النتائج LIMIT
db.loans.aggregate([
{ $match: { city: 'القاهرة' } },
{ $project: { member: 1, days: 1, _id: 0 } }
])
الناتج
[
{ member: 'هدى', days: 7 },
{ member: 'هدى', days: 21 },
{ member: 'ريم', days: 10 },
{ member: 'هدى', days: 16 }
]

$match تقبل الفلتر نفسه الذي تكتبه في find، بكل معاملاته مثل $gte و $in. و $project تعمل مثل الإسقاط الذي تعرفه.

كم استعارة في كل مدينة؟

db.loans.aggregate([
{ $group: { _id: '$city', loans: { $sum: 1 } } },
{ $sort: { loans: -1 } }
])
الناتج
[
{ _id: 'القاهرة', loans: 4 },
{ _id: 'عمّان', loans: 2 },
{ _id: 'تونس', loans: 1 }
]
  • _id: '$city': مفتاح التجميع. كل قيمة مختلفة للمدينة تصبح مجموعة. علامة $ قبل الاسم تعني «قيمة هذا الحقل».
  • loans: { $sum: 1 }: حقل جديد نسميه كما نشاء. $sum: 1 تضيف واحداً لكل مستند، أي تعدّ.
  • $sort بعدها ترتب المجموعات: -1 تنازلي و 1 تصاعدي.

تخيّل $group مثل معلم يطلب من الطلاب أن يقفوا حسب مدنهم، ثم يعدّ كل صف.

db.loans.aggregate([
{ $group: {
_id: '$member',
loans: { $sum: 1 },
totalDays: { $sum: '$days' },
avgDays: { $avg: '$days' },
maxDays: { $max: '$days' },
fees: { $sum: '$fee' }
} },
{ $sort: { loans: -1, _id: 1 } }
])
الناتج
[
{ _id: 'هدى', loans: 3, totalDays: 44, avgDays: 14.666666666666666, maxDays: 21, fees: 15 },
{ _id: 'كريم', loans: 2, totalDays: 19, avgDays: 9.5, maxDays: 14, fees: 0 },
{ _id: 'ريم', loans: 1, totalDays: 10, avgDays: 10, maxDays: 10, fees: 0 },
{ _id: 'زياد', loans: 1, totalDays: 30, avgDays: 30, maxDays: 30, fees: 25 }
]
المعامل يحسب
{ $sum: 1 } عدد المستندات
{ $sum: '$field' } مجموع قيم الحقل
$avg و $min و $max المتوسط والأصغر والأكبر
$addToSet مصفوفة بقيم الحقل بلا تكرار
$push مصفوفة بكل قيم الحقل

ولإحصاءات على المجموعة كلها دون تقسيم، اجعل المفتاح null:

db.loans.aggregate([
{ $group: { _id: null, total: { $sum: 1 }, fees: { $sum: '$fee' } } }
])
الناتج
[ { _id: null, total: 7, fees: 40 } ]

المرحلة نفسها تعطي معنى مختلفاً حسب موضعها. $match قبل $group تصفّي المستندات الأصلية:

db.loans.aggregate([
{ $match: { days: { $gte: 10 } } },
{ $group: { _id: '$city', loans: { $sum: 1 }, fees: { $sum: '$fee' } } },
{ $sort: { fees: -1 } }
])
الناتج
[
{ _id: 'تونس', loans: 1, fees: 25 },
{ _id: 'القاهرة', loans: 3, fees: 15 },
{ _id: 'عمّان', loans: 1, fees: 0 }
]

الاستعارات القصيرة استُبعدت قبل العدّ. أما $match بعد $group فتصفّي المجموعات الناتجة، مثل HAVING في SQL. الأعضاء الذين استعاروا مرتين أو أكثر:

db.loans.aggregate([
{ $group: { _id: '$member', loans: { $sum: 1 } } },
{ $match: { loans: { $gte: 2 } } },
{ $sort: { loans: -1 } }
])
الناتج
[ { _id: 'هدى', loans: 3 }, { _id: 'كريم', loans: 2 } ]

$project لا تختار الحقول فقط، بل تحسب حقولاً جديدة:

db.loans.aggregate([
{ $project: {
_id: 0,
member: 1,
weeks: { $divide: ['$days', 7] },
late: { $gt: ['$days', 14] }
} },
{ $limit: 3 }
])
الناتج
[
{ member: 'هدى', weeks: 1, late: false },
{ member: 'كريم', weeks: 2, late: false },
{ member: 'هدى', weeks: 3, late: true }
]

المعاملات الحسابية تأخذ معطياتها في مصفوفة: $divide للقسمة، و $multiply للضرب، و $add و $subtract للجمع والطرح، و $round للتقريب.

في مجموعة books كل كتاب له مصفوفة تصنيفات genres. كم كتاباً في كل تصنيف؟ $group لا تستطيع التجميع بعناصر مصفوفة مباشرة. المرحلة $unwind تحوّل المستند الواحد إلى عدة مستندات، واحد لكل عنصر:

db.books.aggregate([
{ $match: { _id: 1 } },
{ $unwind: '$genres' },
{ $project: { title: 1, genres: 1, _id: 0 } }
])
الناتج
[
{ title: 'رحلة إلى القمر', genres: 'خيال علمي' },
{ title: 'رحلة إلى القمر', genres: 'مغامرات' }
]

الكتاب ظهر مرتين، مرة لكل تصنيف. والآن يصبح العدّ سهلاً:

db.books.aggregate([
{ $unwind: '$genres' },
{ $group: { _id: '$genres', books: { $sum: 1 } } },
{ $sort: { books: -1, _id: 1 } },
{ $limit: 3 }
])
الناتج
[
{ _id: 'تقنية', books: 2 },
{ _id: 'علوم', books: 2 },
{ _id: 'مغامرات', books: 2 }
]
  1. أضف مجموعة loans بالبيانات السابقة، واحسب مجموع الغرامات في كل مدينة.
  2. اعرض متوسط مدة الاستعارة لكل كتاب (bookId)، مرتباً من الأطول للأقصر.
  3. اعرض عدد الاستعارات المتأخرة (غرامتها أكبر من صفر) لكل عضو.
  4. تحدٍّ: اعرض المدن التي مجموع أيام الاستعارة فيها تجاوز 20، مع عدد الأعضاء المختلفين في كل مدينة. (تلميح: $addToSet ثم $size في $project.)
  • نسيان $ قبل اسم الحقل: _id: 'city' تجمع بالنص الثابت «city» فتحصل على مجموعة واحدة فيها كل المستندات. الصحيح '$city'.
  • اسم مرحلة خاطئ: $grup يعطي Unrecognized pipeline stage name: '$grup'.
  • $match في المكان الخطأ: قبل $group تصفّي المستندات، وبعدها تصفّي المجموعات. والحقول المحسوبة مثل loans لا توجد إلا بعد $group.
  • نسيان الأقواس المربعة: aggregate تأخذ مصفوفة مراحل حتى لو كانت مرحلة واحدة.

سؤال

ماذا يحسب { $sum: 1 } داخل $group؟

سؤال

أين نضع $match لتصفية المجموعات بعدد عناصرها، كما تفعل HAVING في SQL؟

  • aggregate تمرر المستندات عبر مراحل متتالية، وناتج كل مرحلة مدخل للتالية.
  • $match تصفّي، و $group تجمّع بمفتاح _id وتحسب بـ $sum و $avg و $max.
  • $sort ترتب، و $limit تحدد العدد، و $project تختار وتحسب حقولاً جديدة.
  • موضع $match يغيّر معناها: قبل التجميع للمستندات وبعده للمجموعات.
  • $unwind تفكك المصفوفة إلى مستند لكل عنصر ليسهل التجميع عليها.