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 } } }
);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ử.
- Trong
server.js, thêmPOST /books/:id/reviews. Phần thân yêu cầu có dạng{ "user": "ada", "text": "Loved it" }. DùngparseIdđể xử lý ID không hợp lệ, trường hợp đó trả về 400. - 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ảngreviewscủ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. - Nếu không có sách nào có
_idđó, hãy trả về 404. - Nếu có sách, hãy trả về trạng thái 201 và chỉ trả về mảng
reviewscủ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à:Thử ngay: Gửi[ { "user": "ada", "text": "Loved it" } ]GET /booksvà sao chép_idcủa Dune. GửiPOST /books/<Dune's _id>/reviewsvớ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đếnReview 6, cònLoved itđã bị loại bỏ. GửiGET /books/<Dune's _id>: Dune có cùng năm bài đánh giá trongreviews, 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');
});
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
1Tài liệu
Một cuốn sách dưới dạng tài liệuBộ sưu tập và cơ sở dữ liệuKết nối với MongoDBĐọc tài liệu đã lưuChèn tài liệuTìm một tài liệuTìm theo ObjectIdSo sánh các sốBộ lọc truy vấnChọn các trường được trả vềSắp xếp kết quả truy vấnGiữ lại các kết quả đầu tiênPhép chiếu, sắp xếp và giới hạn4Cấu trúc
Một module cơ sở dữ liệuPhân trang trong cơ sở dữ liệuTự luyện tập: Playground Express