الربط باستخدام $lookup
جزء من قسم قواعد البيانات باستخدام MongoDB في رحلة Node.js على Coddy. الدرس 25 من 29.
يخزّن كتابٌ اسم مؤلفه، وتوجد تفاصيل المؤلف في مجموعة أخرى. يريد العميل الحصول على كليهما في إجابة واحدة.
تربط مرحلة $lookup المجموعات. فلكل مستند، تبحث عن المستندات في المجموعة from التي يساوي فيها foreignField قيمة localField لهذا المستند، وتضعها في حقل مصفوفة جديد اسمه as.
await orders.aggregate([
{ $match: { _id: orderId } },
{ $lookup: {
from: 'customers',
localField: 'customerEmail',
foreignField: 'email',
as: 'customer',
} },
]).toArray();
// [{ _id: ..., customerEmail: 'ada@x.io', customer: [{ email: 'ada@x.io', ... }] }]تعيد toArray() مصفوفة من الطلبات، تحتوي هنا على طلب واحد. خذ العنصر الأول منها للحصول على الطلب نفسه.
ينتمي localField إلى المستندات في مسار المعالجة، وينتمي foreignField إلى المجموعة الأخرى. تكون النتيجة مصفوفة حتى عند وجود تطابق واحد.
توفر المجموعة from كل مستند مطابق في المصفوفة التي يسميها as. يأخذ المسار المقدم أول نتيجة من مسار المعالجة لإرجاع الكتاب المطلوب.
التحدي
سهلتفتح هذه الخطوة مشروع واجهة API لرف الكتب المُعدّ للدورة، بدلًا من ملفات مشروعك من درس Aggregation Pipelines (بما فيها server.js). يبقى عملك السابق ضمن درسه الخاص. اقرأ الملفات المقدمة قبل تعديل server.js. تحتوي هذه النسخة على مسارات رف الكتب، وعدّ الأنواع، ومسار التجميع لأعلى التقييمات، بالإضافة إلى مؤلفين مضافين مسبقًا ومعالجة الاستجابات.
في المسار المُعدّ GET /books/:id/full في server.js، أضف مرحلة lookup بعد match الموجودة. اربط المؤلفين عندما تتساوى قيمة book.author مع author.name. ضع النتائج المطابقة في authorInfo. أبقِ على الشيفرة المقدمة التي تأخذ النتيجة الأولى وتُرجع 404 عندما يكون الكتاب غير موجود.
جرّب ذلك: استخدم GET /books وانسخ _id الخاص بـ Emma. يعيد GET /books/<Emma’s _id>/full كتاب Emma مع مصفوفة authorInfo تحتوي على Jane Austen وborn 1775. يعيد الكتاب ذو المؤلف غير المعروف authorInfo: []. ويظل معرّف الكتاب الصالح الذي لا يشير إلى كتاب موجود يعيد 404.
جرّب بنفسك
import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';
import { authorList } from './authors.js';
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shelf');
const books = db.collection('books');
const ratings = db.collection('ratings');
const authors = db.collection('authors');
await books.createIndex({ isbn: 1 }, { unique: true });
await books.insertMany([
{ title: 'Dune', author: 'Frank Herbert', year: 1965, isbn: '9780441013593', genre: 'scifi' },
{ title: 'Kindred', author: 'Octavia E. Butler', year: 1979, isbn: '9780807083697', genre: 'scifi' },
{ title: 'Emma', author: 'Jane Austen', year: 1815, isbn: '9780141439587', genre: 'classic' },
]);
await authors.insertMany(authorList);
const app = express();
app.use(express.json());
// تُرجع كل حالة خطأ { error: message }.
function sendError(res, status, message) {
res.status(status).json({ error: message });
}
// تُرجع :id على هيئة ObjectId، أو null بعد إرسال استجابة 400 إذا كان المعرّف غير صالح.
function parseId(req, res) {
if (ObjectId.isValid(req.params.id)) {
return new ObjectId(req.params.id);
}
sendError(res, 400, 'Not a valid id');
return null;
}
app.get('/', (req, res) => {
res.send('Bookshelf API');
});
app.get('/books', async (req, res) => {
const filter = {};
if (req.query.author) {
filter.author = req.query.author;
}
if (req.query.from || req.query.to) {
filter.year = {};
if (req.query.from) {
filter.year.$gte = Number(req.query.from);
}
if (req.query.to) {
filter.year.$lte = Number(req.query.to);
}
}
const list = await books.find(filter).toArray();
res.json(list);
});
app.post('/books', async (req, res) => {
const book = req.body;
try {
await books.insertOne(book);
} catch (err) {
if (err.code === 11000) {
return sendError(res, 409, 'That ISBN is already on the shelf');
}
throw err;
}
res.status(201).json(book);
});
app.get('/books/newest', async (req, res) => {
const n = Number(req.query.n) || 3;
const list = await books
.find({}, { projection: { title: 1, year: 1, _id: 0 } })
.sort({ year: -1 })
.limit(n)
.toArray();
res.json(list);
});
app.get('/books/:id', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const book = await books.findOne({ _id: id });
if (!book) {
return sendError(res, 404, 'No such book');
}
res.json(book);
});
app.patch('/books/:id', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const result = await books.updateOne({ _id: id }, { $set: req.body });
if (result.matchedCount === 0) {
return sendError(res, 404, 'No such book');
}
res.json(await books.findOne({ _id: id }));
});
app.post('/books/:id/copies', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const result = await books.updateOne({ _id: id }, { $inc: { copies: 1 } });
if (result.matchedCount === 0) {
return sendError(res, 404, 'No such book');
}
res.json(await books.findOne({ _id: id }));
});
app.post('/books/:id/tags', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const result = await books.updateOne({ _id: id }, { $push: { tags: req.body.tag } });
if (result.matchedCount === 0) {
return sendError(res, 404, 'No such book');
}
res.json(await books.findOne({ _id: id }));
});
app.delete('/books/:id', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const result = await books.deleteOne({ _id: id });
if (result.deletedCount === 0) {
return sendError(res, 404, 'No such book');
}
res.status(204).end();
});
app.put('/books/:id/ratings', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const { user, stars } = req.body;
await ratings.updateOne({ bookId: id, user }, { $set: { stars } }, { upsert: true });
res.json({ user, stars });
});
app.get('/books/:id/ratings', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
res.json(await ratings.find({ bookId: id }).toArray());
});
app.get('/stats', async (req, res) => {
const filter = req.query.genre ? { genre: req.query.genre } : {};
res.json({
books: await books.countDocuments(filter),
ratings: await ratings.countDocuments(),
});
});
app.get('/stats/genres', async (req, res) => {
const genres = await books
.aggregate([{ $group: { _id: '$genre', count: { $sum: 1 } } }])
.toArray();
res.json(genres);
});
app.get('/stats/top', async (req, res) => {
const top = await ratings
.aggregate([
{ $group: { _id: '$bookId', avg: { $avg: '$stars' }, count: { $sum: 1 } } },
{ $match: { count: { $gte: 2 } } },
{ $sort: { avg: -1 } },
{ $limit: 3 },
])
.toArray();
res.json(top);
});
app.get('/books/:id/full', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const [book] = await books
.aggregate([
{ $match: { _id: id } },
])
.toArray();
if (!book) {
return sendError(res, 404, 'No such book');
}
res.json(book);
});
app.listen(3000, () => {
console.log('Bookshelf is running on port 3000');
});
جميع دروس قواعد البيانات باستخدام MongoDB
3التجميع
عدّ المستنداتمسار من المراحلالتجميع باستخدام $groupالمتوسطات داخل المجموعاتتصفية نتائج المجموعاتمسارات التجميعالربط باستخدام $lookupتدرّب بنفسك: Playground لـ Express