Menu
CoddyTech

مسارات التجميع

جزء من قسم قواعد البيانات باستخدام MongoDB في رحلة Node.js على Coddy. الدرس 24 من 29.

تحتاج قائمة «الأعلى تقييمًا» إلى متوسطات، وحد أدنى لعدد التقييمات، وترتيب، واقتطاع. كل واحد من هذه يُعد مرحلة.

يُبقي $match المستندات التي تجتاز عامل تصفية، مثل find. يحسب $avg متوسط حقل داخل $group. تُنفَّذ المراحل بالترتيب الذي تكتبها به، ولا ترى كل مرحلة إلا ما مررته إليها المرحلة السابقة. يُصفّي $match قبل $group المستندات المخزنة. أما $match بعد $group فيُصفّي المجموعات، بحسب الحقول التي أنشأتها المجموعة، كما في { $match: { orders: { $gte: 3 } } }. يمكن لـ $group واحد إنشاء عدة حقول في آنٍ واحد، مثل spent: { $sum: '$total' } وorders: { $sum: 1 }.

await orders.aggregate([
    { $match: { status: 'paid' } },
    { $group: { _id: '$customer', spent: { $sum: '$total' } } },
    { $sort: { spent: -1 } },
    { $limit: 5 },
]).toArray();

يأخذ $sort و$limit الوسيطات نفسها كما في المؤشر، لكن خط الأنابيب ينفذهما بالترتيب الذي تكتبهما به. يُبقي $limit قبل $sort أي خمسة مستندات، ثم يرتب هذه المستندات وحدها.

challenge icon

التحدي

سهل

تفتح هذه الخطوة مشروع Bookshelf API المُعدّ للدورة، وليس ملفات مشروعك من درس التجميع باستخدام $group (بما فيها server.js). يبقى عملك السابق في درسه الخاص. اقرأ الملفات المقدّمة قبل تعديل server.js. تحتوي هذه النسخة على مسارات Bookshelf وملخص أعداد الأنواع.

تريد المكتبة قائمة «الأعلى تقييماً»: الكتب الثلاثة ذات أعلى متوسط للنجوم. يحتاج الكتاب إلى تقييمين على الأقل كي يظهر في القائمة، لذلك لا يكفي تقييم واحد محظوظ من 5 نجوم ليضعه في المقدمة.

  1. في server.js، أضف مسار GET /stats/top. ينفّذ هذا المسار تجميعاً على مجموعة ratings، وليس على books.
  2. جمّع التقييمات حسب bookId. تحصل كل مجموعة على ثلاثة حقول: _id، وهو معرّف الكتاب؛ وavg، وهو متوسط قيمة stars؛ وcount، وهو عدد التقييمات التي حصل عليها. يمكن لمرحلة $group واحدة حساب كل من avg وcount.
  3. احتفظ بالمجموعات التي يكون فيها count 2 أو أكثر فقط. تنطبق هذه القاعدة على مجموعة الكتاب، لا على تقييم واحد، لذلك تأتي هذه المرحلة بعد التجميع.
  4. رتّب المجموعات حسب avg، من الأعلى إلى الأقل، واحتفظ بأول 3.
  5. أعِد الاستجابة بالحالة 200 والمصفوفة. يحتوي كل عنصر على هذه الحقول الثلاثة بالضبط:
    [
      { "_id": "6720a1f4c2b9e81d3a5f0c11", "avg": 4.5, "count": 2 },
      { "_id": "6720a1f4c2b9e81d3a5f0c12", "avg": 3, "count": 2 }
    ]
    _id هو _id الخاص بالكتاب، لذا يمكن للعميل جلب الكتاب باستخدام GET /books/ متبوعاً بذلك المعرّف. عندما يكون عدد الكتب التي لديها تقييمان أقل من ثلاثة، تكون المصفوفة أقصر. جرّب ذلك: كل مرة تضغط فيها «العب» تُفرغ ratings، لذا أرسل هذه الطلبات بعد آخر مرة تضغط فيها «العب». أرسل GET /books وانسخ _id لكل من Dune وKindred وEmma. أرسل PUT /books/<Dune's _id>/ratings مع { "user": "ada", "stars": 5 }، ثم أرسل الطلب مرة أخرى مع { "user": "bo", "stars": 4 }. قيّم Kindred بالطريقة نفسها، بـ3 نجوم من ada و3 من bo. قيّم Emma مرة واحدة، مع { "user": "ada", "stars": 5 }. أرسل GET /stats/top: ستظهر Dune أولاً (avg 4.5، وcount 2)، ثم Kindred (avg 3، وcount 2). لن تظهر Emma، لأنها حصلت على تقييم واحد فقط. إذا ظهرت Emma أولاً، فهذا يعني أن مرشح العدد يُنفّذ قبل التجميع أو لا يُنفّذ إطلاقاً.

جرّب بنفسك

import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';

const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shelf');
const books = db.collection('books');
const ratings = db.collection('ratings');

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' },
]);

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.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});
quiz iconاختبر نفسك

يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.

جميع دروس قواعد البيانات باستخدام MongoDB

تدرّب بنفسك: Playground لـ Express