Menu
CoddyTech

Kết hợp dữ liệu với $lookup

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 25 trên 29.

Một cuốn sách lưu tên tác giả, còn thông tin chi tiết về tác giả nằm trong một bộ sưu tập khác. Khách hàng muốn nhận cả hai trong cùng một câu trả lời.

Giai đoạn $lookup thực hiện phép nối. Với mỗi tài liệu, giai đoạn này tìm các tài liệu trong bộ sưu tập from có foreignField bằng localField của tài liệu đó, rồi đưa chúng vào một trường mảng mới có tên được chỉ định bởi as.

await orders.aggregate([
    { $match: { _id: orderId } },
    { $lookup: {
        from: 'customers',
        localField: 'customerEmail',
        foreignField: 'email',
        as: 'customer',
    } },
]).toArray();
// [{ _id: ..., customerEmail: 'ada@x.io', customer: [{ email: 'ada@x.io', ... }] }]

toArray() trả về một mảng đơn hàng, trong trường hợp này có một đơn hàng. Lấy phần tử đầu tiên để nhận được chính đơn hàng đó.

localField thuộc về các tài liệu trong pipeline, còn foreignField thuộc về bộ sưu tập kia. Kết quả luôn là một mảng, kể cả khi chỉ có một kết quả khớp.

Bộ sưu tập from cung cấp mọi tài liệu khớp trong mảng có tên được chỉ định bởi as. Route được cung cấp lấy kết quả đầu tiên của pipeline để trả về cuốn sách được yêu cầu.

challenge icon

Thử thách

Dễ

Bước này mở dự án API kệ sách đã được chuẩn bị sẵn cho khóa học, thay vì các tệp dự án của bạn trong Aggregation Pipelines (bao gồm server.js). Phần bạn đã làm trước đó vẫn nằm trong bài học riêng. Đọc các tệp được cung cấp trước khi chỉnh sửa server.js. Bản sao này có các tuyến đường cho kệ sách, bộ đếm thể loại và quy trình tổng hợp sách được đánh giá cao nhất, cùng dữ liệu tác giả đã tạo sẵn và cách xử lý phản hồi.

Trong tuyến đường GET /books/:id/full đã được chuẩn bị trong server.js, hãy thêm một giai đoạn tra cứu sau giai đoạn khớp hiện có. Kết hợp tác giả bằng cách so sánh book.author với author.name. Đưa các kết quả khớp vào authorInfo. Giữ nguyên code được cung cấp để lấy kết quả đầu tiên và trả về 404 khi không tìm thấy sách.

Thử nhé: GET /books rồi sao chép _id của Emma. GET /books/<Emma’s _id>/full trả về Emma cùng một mảng authorInfo chứa Jane Austen và born 1775. Sách có tác giả không xác định sẽ trả về authorInfo: []. ID sách hợp lệ nhưng không tồn tại vẫn trả về 404.

Tự làm thử

import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';
import { authorList } from './authors.js';

const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shelf');
const books = db.collection('books');
const ratings = db.collection('ratings');
const authors = db.collection('authors');

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' },
]);
await authors.insertMany(authorList);

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 trả về 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;
}

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.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 } },

        ])
        .toArray();
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

app.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});

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

Tự luyện tập: Playground Express