Menu
CoddyTech

Nhúng hay tham chiếu

Thuộc phần Cơ sở dữ liệu với MongoDB trong hành trình Node.js của Coddy. Bài học 29 trên 29.

Đánh giá và xếp hạng đều thuộc về một cuốn sách, nhưng nơi tốt nhất để lưu trữ từng loại lại khác nhau.

Nhúng đặt dữ liệu liên quan vào bên trong tài liệu, nhờ đó chỉ cần đọc một lần là có sách và các bài đánh giá. Cách này phù hợp với dữ liệu được đọc cùng dữ liệu gốc và có kích thước nhỏ. Tham chiếu lưu dữ liệu trong bộ sưu tập riêng cùng với id của dữ liệu gốc, như ratings. Cách này phù hợp với dữ liệu tăng không giới hạn hoặc được truy vấn riêng, chẳng hạn như tính điểm trung bình của mọi lượt xếp hạng.

Một tài liệu có thể chứa tối đa 16 MB, vì vậy mảng được nhúng cần có giới hạn. Bên trong $push, $each liệt kê các giá trị cần thêm và $slice: -3 giữ lại ba giá trị cuối cùng.

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

Thử thách

Dễ

Các bài đánh giá ngắn, luôn hiển thị cùng với sách và mỗi sách chỉ hiển thị 5 bài mới nhất. Vì vậy, hãy giữ chúng trong tài liệu sách, trong mảng reviews, và không để mảng đó có quá 5 phần tử.

  1. Trong server.js, thêm POST /books/:id/reviews. Phần thân yêu cầu có dạng { "user": "ada", "text": "Loved it" }. Dùng parseId để xử lý ID không hợp lệ, trường hợp đó trả về 400.
  2. Chỉ dùng một lần cập nhật để thêm bài đánh giá, chẳng hạn { "user": "ada", "text": "Loved it" }, vào cuối mảng reviews của sách. Trong cùng lần cập nhật, cắt mảng để chỉ còn 5 phần tử cuối, nhờ đó bài đánh giá cũ nhất sẽ bị loại bỏ. Nếu sách chưa có bài đánh giá nào, hãy tạo một mảng mới.
  3. Nếu không có sách nào có _id đó, hãy trả về 404.
  4. Nếu có sách, hãy trả về trạng thái 201 và chỉ trả về mảng reviews của sách, không trả về toàn bộ sách, theo thứ tự từ bài đánh giá cũ nhất đến mới nhất. Sau bài đánh giá đầu tiên, phản hồi là:
    [ { "user": "ada", "text": "Loved it" } ]
    Thử ngay: Gửi GET /books và sao chép _id của Dune. Gửi POST /books/<Dune's _id>/reviews với { "user": "ada", "text": "Loved it" }: bạn sẽ nhận được 201 và mảng ở trên. Gửi thêm năm bài đánh giá, với nội dung từ "Review 2" đến "Review 6". Phản hồi cuối cùng chứa năm bài đánh giá, từ Review 2 đến Review 6, còn Loved it đã bị loại bỏ. Gửi GET /books/<Dune's _id>: Dune có cùng năm bài đánh giá trong reviews, và thẻ Data hiển thị chúng bên trong sách.

Tự làm thử

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());

// Mọi lỗi đều trả về { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Trả về :id dưới dạng ObjectId, hoặc null sau khi phản hồi 400 nếu id không hợp lệ.
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 iconTự kiểm tra

Bài học này có một bài trắc nghiệm ngắn. Bắt đầu bài học để trả lời và theo dõi tiến độ.

Tất cả bài học trong Cơ sở dữ liệu với MongoDB

Tự luyện tập: Playground Express