العلاقات في MongoDB: التضمين أم المرجع؟ و $lookup
في قواعد البيانات العلائقية القاعدة واضحة: كل شيء في جدوله، والربط بـ JOIN. في MongoDB عندك حرية أكبر، ومعها سؤال يتكرر مع كل مشروع: هل أضع البيانات المرتبطة داخل المستند نفسه، أم في مجموعة أخرى وأشير إليها؟ الإجابة تحدد سرعة تطبيقك وسهولة كوده. في هذا الدرس الأخير ستتعلم الطريقتين، ومتى تختار كل واحدة.
الطريقة الأولى: التضمين
Section titled “الطريقة الأولى: التضمين”التضمين (Embedding) أن تضع البيانات المرتبطة داخل المستند نفسه، ككائن أو مصفوفة. رأيت ذلك في حقل publisher وحقل genres في الكتب. مثال أوضح: طلب شراء من متجر المكتبة:
db.orders.insertOne({ _id: 101, customer: { name: 'هدى', phone: '0100-000-0000' }, items: [ { title: 'رحلة إلى القمر', price: 75.5, qty: 1 }, { title: 'حكايات جدتي', price: 45, qty: 2 } ], status: 'جديد'})الطلب كله في مستند واحد: العميل، والأصناف، والحالة. الأمر مثل فاتورة ورقية: كل ما يخصها مكتوب عليها، ولا تحتاج فتح دفتر آخر لتفهمها.
قراءة واحدة تجلب كل شيء، والبحث داخل الأصناف مباشر:
db.orders.findOne( { 'items.title': 'حكايات جدتي' }, { 'customer.name': 1, status: 1, _id: 0 }){ customer: { name: 'هدى' }, status: 'جديد' }وإضافة صنف جديد للطلب تحديث لمستند واحد بالمعامل $push:
db.orders.updateOne( { _id: 101 }, { $push: { items: { title: 'أسرار البحار', price: 90, qty: 1 } } })وحساب إجمالي الطلب بما تعلمته في درس التجميع:
db.orders.aggregate([ { $unwind: '$items' }, { $group: { _id: '$_id', total: { $sum: { $multiply: ['$items.price', '$items.qty'] } }, pieces: { $sum: '$items.qty' } } }])[ { _id: 101, total: 165.5, pieces: 3 } ](هذا الناتج قبل إضافة الصنف الثالث.)
الطريقة الثانية: المرجع
Section titled “الطريقة الثانية: المرجع”المرجع (Reference) أن تخزّن في المستند معرّف مستند آخر فقط، كالمفتاح الأجنبي في SQL. هكذا صممنا مجموعة الاستعارات في درس التجميع:
{ member: 'كريم', city: 'عمّان', bookId: 3, days: 14, fee: 0 }bookId: 3 يشير إلى الكتاب الذي _id له يساوي 3 في مجموعة books. بيانات الكتاب مخزنة مرة واحدة هناك، ومهما كثرت استعاراته لا تتكرر.
المرجع مثل رقم الصفحة في فهرس الكتاب: لا ينسخ المحتوى، بل يدلك على مكانه.
$lookup: ربط مجموعتين
Section titled “$lookup: ربط مجموعتين”لعرض استعارات كريم مع عناوين الكتب نحتاج بيانات من المجموعتين. المرحلة $lookup في خط التجميع تقوم بالربط:
db.loans.aggregate([ { $match: { member: 'كريم' } }, { $lookup: { from: 'books', localField: 'bookId', foreignField: '_id', as: 'book' } }, { $project: { _id: 0, member: 1, days: 1, 'book.title': 1 } }])[ { member: 'كريم', days: 14, book: [ { title: 'البرمجة للمبتدئين' } ] }, { member: 'كريم', days: 5, book: [ { title: 'أسرار البحار' } ] }]| الحقل | المعنى |
|---|---|
from |
المجموعة الأخرى التي نجلب منها |
localField |
الحقل في مجموعتنا (loans.bookId) |
foreignField |
الحقل المقابل في المجموعة الأخرى (books._id) |
as |
اسم الحقل الجديد الذي توضع فيه النتائج |
لاحظ أن book مصفوفة، لأن الربط قد يجد عدة مستندات مطابقة. وهو يعمل مثل LEFT JOIN: إذا لم يوجد كتاب مطابق تبقى الاستعارة في الناتج ومعها مصفوفة فارغة book: [].
ولأننا نعلم أن لكل استعارة كتاباً واحداً، نفكك المصفوفة بـ $unwind لنحصل على ناتج مسطح:
db.loans.aggregate([ { $match: { member: 'كريم' } }, { $lookup: { from: 'books', localField: 'bookId', foreignField: '_id', as: 'book' } }, { $unwind: '$book' }, { $project: { _id: 0, member: 1, days: 1, title: '$book.title' } }])[ { member: 'كريم', days: 14, title: 'البرمجة للمبتدئين' }, { member: 'كريم', days: 5, title: 'أسرار البحار' }]والآن نجمع كل ما تعلمته: أكثر الكتب استعارة بعناوينها.
db.loans.aggregate([ { $group: { _id: '$bookId', times: { $sum: 1 } } }, { $lookup: { from: 'books', localField: '_id', foreignField: '_id', as: 'book' } }, { $unwind: '$book' }, { $project: { _id: 0, title: '$book.title', times: 1 } }, { $sort: { times: -1, title: 1 } }])[ { times: 2, title: 'البرمجة للمبتدئين' }, { times: 2, title: 'رحلة إلى القمر' }, { times: 2, title: 'عالم الروبوتات' }, { times: 1, title: 'أسرار البحار' }]جمّعنا أولاً ثم ربطنا: هكذا يعمل $lookup على أربع مجموعات بدل سبع استعارات.
أيهما أختار؟
Section titled “أيهما أختار؟”اسأل نفسك: كيف سيقرأ تطبيقي هذه البيانات؟
| ضمّن عندما | استخدم المرجع عندما |
|---|---|
| البيانات تُقرأ معاً دائماً (الطلب وأصنافه) | البيانات تُقرأ منفردة كثيراً (الكتاب وحده) |
| العدد محدود وصغير (عناوين المستخدم) | العدد يكبر بلا حد (تعليقات على منشور مشهور) |
| البيانات تخص هذا المستند وحده | البيانات مشتركة بين مستندات كثيرة |
| نادراً ما تتغير بعد كتابتها | تتغير كثيراً وتريد تعديلها في مكان واحد |
والقاعدة التي يرددها مصممو MongoDB: «ما يُقرأ معاً يُخزَّن معاً».
لاحظ أمراً دقيقاً في مثال الطلب: ضمّنّا سعر الكتاب داخل الصنف ولم نكتفِ بمرجع إليه. هذا مقصود: الفاتورة يجب أن تحتفظ بالسعر وقت الشراء، حتى لو ارتفع سعر الكتاب غداً. ليس كل تكرار للبيانات خطأ.
جرّب بنفسك
Section titled “جرّب بنفسك”- أنشئ طلباً في
ordersفيه ثلاثة أصناف مضمّنة، واحسب إجماليه بخط تجميع. - اعرض استعارات العضو «هدى» مع عناوين الكتب باستخدام
$lookupثم$unwind. - أضف استعارة برقم كتاب غير موجود، ونفّذ
$lookupعليها ولاحظ المصفوفة الفارغة. - تحدٍّ: صمّم على الورق بيانات مدوّنة: مقالات، ولكل مقال مؤلف ووسوم وتعليقات. قرر لكل منها: تضمين أم مرجع؟ واكتب سبب قرارك.
أخطاء شائعة
Section titled “أخطاء شائعة”- تصميم MongoDB كأنها SQL: مجموعة لكل شيء وربط بـ
$lookupفي كل استعلام يضيّع أهم مزايا المستندات. ابدأ بالتضمين واسأل: هل عندي سبب للفصل؟ - مصفوفات بلا حد: تضمين كل تعليقات منشور أو كل سجلات مستخدم يجعل المستند يتضخم ويبطؤ. افصلها في مجموعة.
- نسيان فهرس حقل الربط:
$lookupيبحث فيforeignFieldلكل مستند. إذا لم يكن_idفأنشئ عليه فهرساً. - نسيان أن ناتج
$lookupمصفوفة: الوصول بـbook.titleمباشرة في الكود يفشل. استخدم$unwindأو خذ العنصر الأول.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
متى يكون التضمين أنسب من المرجع؟
ما يُقرأ معاً يُخزَّن معاً: قراءة واحدة تجلب كل شيء دون ربط.
سؤال
ما نوع الحقل الذي يضيفه $lookup باسم as؟
الربط قد يجد صفراً أو أكثر من المستندات، فالنتيجة مصفوفة دائماً.
الخلاصة
Section titled “الخلاصة”- التضمين يضع البيانات المرتبطة داخل المستند: قراءة واحدة تجلب كل شيء.
- المرجع يخزّن معرّف مستند في مجموعة أخرى: لا تكرار، وتعديل في مكان واحد.
$lookupيربط مجموعتين في خط التجميع، وناتجه مصفوفة تُفكك بـ$unwind.- MongoDB لا تتحقق من صحة المراجع، فالمسؤولية على تطبيقك.
- مبروك! أنهيت كورس MongoDB: من أول مستند إلى التجميع والفهارس وتصميم البيانات.