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

العلاقات في MongoDB: التضمين أم المرجع؟ و $lookup

الدرس 8 من 8 · MongoDB

في قواعد البيانات العلائقية القاعدة واضحة: كل شيء في جدوله، والربط بـ 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 في خط التجميع تقوم بالربط:

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 على أربع مجموعات بدل سبع استعارات.

اسأل نفسك: كيف سيقرأ تطبيقي هذه البيانات؟

ضمّن عندما استخدم المرجع عندما
البيانات تُقرأ معاً دائماً (الطلب وأصنافه) البيانات تُقرأ منفردة كثيراً (الكتاب وحده)
العدد محدود وصغير (عناوين المستخدم) العدد يكبر بلا حد (تعليقات على منشور مشهور)
البيانات تخص هذا المستند وحده البيانات مشتركة بين مستندات كثيرة
نادراً ما تتغير بعد كتابتها تتغير كثيراً وتريد تعديلها في مكان واحد

والقاعدة التي يرددها مصممو MongoDB: «ما يُقرأ معاً يُخزَّن معاً».

لاحظ أمراً دقيقاً في مثال الطلب: ضمّنّا سعر الكتاب داخل الصنف ولم نكتفِ بمرجع إليه. هذا مقصود: الفاتورة يجب أن تحتفظ بالسعر وقت الشراء، حتى لو ارتفع سعر الكتاب غداً. ليس كل تكرار للبيانات خطأ.

  1. أنشئ طلباً في orders فيه ثلاثة أصناف مضمّنة، واحسب إجماليه بخط تجميع.
  2. اعرض استعارات العضو «هدى» مع عناوين الكتب باستخدام $lookup ثم $unwind.
  3. أضف استعارة برقم كتاب غير موجود، ونفّذ $lookup عليها ولاحظ المصفوفة الفارغة.
  4. تحدٍّ: صمّم على الورق بيانات مدوّنة: مقالات، ولكل مقال مؤلف ووسوم وتعليقات. قرر لكل منها: تضمين أم مرجع؟ واكتب سبب قرارك.
  • تصميم MongoDB كأنها SQL: مجموعة لكل شيء وربط بـ $lookup في كل استعلام يضيّع أهم مزايا المستندات. ابدأ بالتضمين واسأل: هل عندي سبب للفصل؟
  • مصفوفات بلا حد: تضمين كل تعليقات منشور أو كل سجلات مستخدم يجعل المستند يتضخم ويبطؤ. افصلها في مجموعة.
  • نسيان فهرس حقل الربط: $lookup يبحث في foreignField لكل مستند. إذا لم يكن _id فأنشئ عليه فهرساً.
  • نسيان أن ناتج $lookup مصفوفة: الوصول بـ book.title مباشرة في الكود يفشل. استخدم $unwind أو خذ العنصر الأول.

سؤال

متى يكون التضمين أنسب من المرجع؟

سؤال

ما نوع الحقل الذي يضيفه $lookup باسم as؟

  • التضمين يضع البيانات المرتبطة داخل المستند: قراءة واحدة تجلب كل شيء.
  • المرجع يخزّن معرّف مستند في مجموعة أخرى: لا تكرار، وتعديل في مكان واحد.
  • $lookup يربط مجموعتين في خط التجميع، وناتجه مصفوفة تُفكك بـ $unwind.
  • MongoDB لا تتحقق من صحة المراجع، فالمسؤولية على تطبيقك.
  • مبروك! أنهيت كورس MongoDB: من أول مستند إلى التجميع والفهارس وتصميم البيانات.