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, и вкладка Data покажет их в документе книги.

Попробуйте сами

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

Потренируйтесь самостоятельно: Песочница Express