Menu
CoddyTech

التضمين أم الإشارة

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

ترتبط المراجعات والتقييمات بكتاب، لكن أفضل مكان لتخزين كلٍّ منها مختلف.

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

يمكن أن يحتوي المستند على 16 MB كحد أقصى، لذا يجب وضع حد لحجم المصفوفة المضمّنة. داخل $push، تسرد $each القيم المراد إضافتها، وتحافظ $slice: -3 على آخر ثلاث قيم.

await players.updateOne(
    { name: 'Ada' },
    { $push: { recentScores: { $each: [120], $slice: -3 } } }
);
challenge icon

التحدي

سهل

المراجعات قصيرة، وتُعرض دائمًا مع كتابها، ولا يعرض الكتاب سوى أحدث 5 مراجعات له. لذا احتفظ بها داخل مستند الكتاب، في مصفوفة reviews، ولا تدع هذه المصفوفة تتجاوز 5 عناصر أبدًا.

  1. في server.js، أضف POST /books/:id/reviews. يبدو متن الطلب هكذا: { "user": "ada", "text": "Loved it" }. استخدم parseId للتعامل مع المعرّف غير الصالح، فتكون الاستجابة 400.
  2. باستخدام عملية تحديث واحدة، أضف المراجعة، مثل { "user": "ada", "text": "Loved it" }، إلى نهاية مصفوفة reviews الخاصة بالكتاب. وفي عملية التحديث نفسها، قلّص المصفوفة لتحتوي على آخر 5 عناصر، بحيث تُحذف أقدم مراجعة. إذا لم تكن للكتاب مراجعات، فأنشئ مصفوفة جديدة.
  3. إذا لم يوجد كتاب يملك _id هذا، فأرسل استجابة 404.
  4. وإلا، فأرسل استجابة بحالة 201، تحتوي على مصفوفة reviews الخاصة بالكتاب فقط، وليس الكتاب بأكمله، مرتبة من أقدم مراجعة إلى أحدثها. بعد المراجعة الأولى، تكون الاستجابة:
    [ { "user": "ada", "text": "Loved it" } ]
    جرّب ذلك: أرسل GET /books وانسخ قيمة _id الخاصة بـ Dune. أرسل POST /books/<Dune's _id>/reviews مع { "user": "ada", "text": "Loved it" }: ستحصل على استجابة 201 والمصفوفة أعلاه. أرسل خمس مراجعات أخرى، بنصوص تتراوح من "Review 2" إلى "Review 6". ستحتوي آخر استجابة على خمس مراجعات، من Review 2 إلى Review 6، وستُحذف Loved it. أرسل GET /books/<Dune's _id>: سيحتوي Dune على المراجعات الخمس نفسها في reviews، وستعرض علامة تبويب البيانات هذه المراجعات داخل الكتاب.

جرّب بنفسك

import express from 'express';
import { z } from 'zod';
import { ObjectId } from 'mongodb';
import { books, ratings } from './db.js';

const app = express();
app.use(express.json());

// تُرجع كل حالة خطأ { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// تُرجع :id على هيئة ObjectId، أو null بعد إرسال استجابة 400 إذا كان id غير صالح.
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;
}

const BookInput = z.object({
    title: z.string().min(1),
    author: z.string().min(1),
    year: z.number().int(),
    isbn: z.string().min(1),
    genre: z.string().min(1),
});

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 page = Number(req.query.page) || 1;
    const limit = Number(req.query.limit) || 10;
    const items = await books
        .find(filter)
        .skip((page - 1) * limit)
        .limit(limit)
        .toArray();
    const total = await books.countDocuments(filter);
    res.json({ items, total });
});

app.post('/books', async (req, res) => {
    const parsed = BookInput.safeParse(req.body);
    if (!parsed.success) {
        const issue = parsed.error.issues[0];
        return sendError(res, 400, `${issue.path.join('.')}: ${issue.message}`);
    }
    const book = parsed.data;
    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 } },
            {
                $lookup: {
                    from: 'authors',
                    localField: 'author',
                    foreignField: 'name',
                    as: 'authorInfo',
                },
            },
        ])
        .toArray();
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

app.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});
quiz iconاختبر نفسك

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

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

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