التجميع في MongoDB: aggregate و $group و $match
find تجيب عن سؤال «أي المستندات؟». لكن مدير المكتبة يسأل أسئلة أخرى: كم استعارة في كل مدينة؟ من أكثر الأعضاء نشاطاً؟ ما مجموع غرامات التأخير؟ هذه أسئلة تلخيص، وأداتها في MongoDB هي aggregate. في هذا الدرس ستتعلم خط التجميع (Aggregation Pipeline)، وهو المقابل لـ GROUP BY في SQL وأقوى منه.
بيانات الدرس
Section titled “بيانات الدرس”نضيف إلى قاعدة المكتبة مجموعة جديدة تسجّل الاستعارات:
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 }])في كل مستند: اسم العضو، ومدينته، ورقم الكتاب، ومدة الاستعارة بالأيام، وغرامة التأخير.
فكرة خط التجميع
Section titled “فكرة خط التجميع”aggregate تأخذ مصفوفة من المراحل (Stages). المستندات تدخل المرحلة الأولى، وناتجها يدخل الثانية، وهكذا:
db.loans.aggregate([ مرحلة1, مرحلة2, مرحلة3 ])تخيّله مثل خط إنتاج في مصنع عصير: محطة تفرز الفاكهة، ومحطة تعصرها، ومحطة تعبئها. كل محطة تستلم ناتج التي قبلها.
| المرحلة | وظيفتها | مقابلها في SQL |
|---|---|---|
$match |
تصفية المستندات | WHERE |
$group |
تجميع وحساب | GROUP BY |
$sort |
ترتيب | ORDER BY |
$project |
اختيار الحقول وحساب حقول جديدة | قائمة SELECT |
$limit |
أول عدد من النتائج | LIMIT |
$match: التصفية
Section titled “$match: التصفية”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 تعمل مثل الإسقاط الذي تعرفه.
$group: التجميع
Section titled “$group: التجميع”كم استعارة في كل مدينة؟
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 مثل معلم يطلب من الطلاب أن يقفوا حسب مدنهم، ثم يعدّ كل صف.
معاملات الحساب
Section titled “معاملات الحساب”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 } ]ترتيب المراحل مهم
Section titled “ترتيب المراحل مهم”المرحلة نفسها تعطي معنى مختلفاً حسب موضعها. $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: حقول محسوبة
Section titled “$project: حقول محسوبة”$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 للتقريب.
$unwind: تفكيك المصفوفات
Section titled “$unwind: تفكيك المصفوفات”في مجموعة 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 }]جرّب بنفسك
Section titled “جرّب بنفسك”- أضف مجموعة
loansبالبيانات السابقة، واحسب مجموع الغرامات في كل مدينة. - اعرض متوسط مدة الاستعارة لكل كتاب (
bookId)، مرتباً من الأطول للأقصر. - اعرض عدد الاستعارات المتأخرة (غرامتها أكبر من صفر) لكل عضو.
- تحدٍّ: اعرض المدن التي مجموع أيام الاستعارة فيها تجاوز 20، مع عدد الأعضاء المختلفين في كل مدينة. (تلميح:
$addToSetثم$sizeفي$project.)
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان
$قبل اسم الحقل:_id: 'city'تجمع بالنص الثابت «city» فتحصل على مجموعة واحدة فيها كل المستندات. الصحيح'$city'. - اسم مرحلة خاطئ:
$grupيعطيUnrecognized pipeline stage name: '$grup'. $matchفي المكان الخطأ: قبل$groupتصفّي المستندات، وبعدها تصفّي المجموعات. والحقول المحسوبة مثلloansلا توجد إلا بعد$group.- نسيان الأقواس المربعة:
aggregateتأخذ مصفوفة مراحل حتى لو كانت مرحلة واحدة.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
ماذا يحسب { $sum: 1 } داخل $group؟
تضيف واحداً عن كل مستند في المجموعة، فالنتيجة عدد مستنداتها.
سؤال
أين نضع $match لتصفية المجموعات بعدد عناصرها، كما تفعل HAVING في SQL؟
العدد لا يوجد إلا بعد التجميع، فالتصفية عليه تأتي في مرحلة لاحقة.
الخلاصة
Section titled “الخلاصة”aggregateتمرر المستندات عبر مراحل متتالية، وناتج كل مرحلة مدخل للتالية.$matchتصفّي، و$groupتجمّع بمفتاح_idوتحسب بـ$sumو$avgو$max.$sortترتب، و$limitتحدد العدد، و$projectتختار وتحسب حقولاً جديدة.- موضع
$matchيغيّر معناها: قبل التجميع للمستندات وبعده للمجموعات. $unwindتفكك المصفوفة إلى مستند لكل عنصر ليسهل التجميع عليها.