Menu
CoddyTech

التجميع باستخدام $group

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

يُعدّ حساب نوع أدبي واحد استعلامًا واحدًا. أما حساب جميع الأنواع الأدبية دفعةً واحدة فيتطلب من قاعدة البيانات فرز الكتب إلى مجموعات.

تنفّذ aggregate مسارًا: مصفوفة من المراحل، تمرّر كلٌّ منها ناتجها إلى المرحلة التالية. تنشئ مرحلة $group مستند ناتج واحدًا لكل قيمة مميزة من _id. هنا، لا يشير _id إلى معرّف مستند. بل يحدد أساس التجميع، ويحتوي كل مستند ناتج على تلك القيمة في _id. تعني إضافة $ قبل اسم حقل، مثل '$team'، «قيمة هذا الحقل في هذا المستند». ومن دونها، تكون 'team' النص نفسه في كل مستند، لذا ستندرج جميعها في مجموعة واحدة. تضيف $sum: 1 العدد 1 لكل مستند.

await players
    .aggregate([{ $group: { _id: '$team', players: { $sum: 1 } } }])
    .toArray();
// [{ _id: 'red', players: 4 }, { _id: 'blue', players: 3 }]

تحتوي نتيجة المجموعة على _id وحده، وعلى الحقول التي تحسبها المُجمِّعات؛ أما الحقول الأخرى من مستندات الإدخال فلا تبقى.

challenge icon

التحدي

سهل

تفتح هذه الخطوة مشروع واجهة API لرف الكتب المُعَدّ للدورة، بدلًا من ملفات مشروعك من درس عدّ المستندات (بما فيها server.js). يبقى عملك السابق في درسه الخاص. اقرأ الملفات المقدَّمة قبل تعديل server.js. تحتوي هذه النسخة على مسارات CRUD لرف الكتب، وفهرس ISBN فريد، ومرشّحات GET /books وإحصاءات GET /stats.

تعرض صفحة الأنواع عدد الكتب في كل نوع، وكل ذلك عبر استعلام واحد، بما يشمل الأنواع التي لم يتوقعها أحد.

  1. أضف مسارًا لـ GET /stats/genres في server.js.
  2. نفّذ تجميعًا على books بمرحلة $group واحدة. تُنشئ هذه المرحلة نتيجة واحدة لكل genre مميّز، مع عدد الكتب التي تنتمي إليه.
  3. أعِد الاستجابة بالحالة 200 وبالمصفوفة التي يُرجعها التجميع. يحتوي كل عنصر على النوع في _id وعدد الكتب في count. داخل $group، لا يكون _id من نوع ObjectId؛ بل هو القيمة التي جُمّعت الكتب بحسبها. يمكن أن تظهر العناصر بأي ترتيب. مع كتب البداية، تكون الاستجابة:
    [ { "_id": "scifi", "count": 2 }, { "_id": "classic", "count": 1 } ]
    جرّب ذلك: أرسل GET /stats/genres: ستحصل على العنصرين أعلاه. أرسل POST /books مع { "title": "Leaves of Grass", "author": "Walt Whitman", "year": 1855, "isbn": "9780140421996", "genre": "poetry" }: ستحصل على 201. أرسل GET /stats/genres مرة أخرى: سيظهر عنصر ثالث، { "_id": "poetry", "count": 1 }.

جرّب بنفسك

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.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});

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

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