$lookup으로 조인하기
Coddy Node.js 여정의 MongoDB 데이터베이스 섹션에 포함된 레슨. 29개 중 25번째.
책에는 저자의 이름이 저장되고, 저자 정보는 다른 컬렉션에 있습니다. 클라이언트는 두 정보를 하나의 응답으로 원합니다.
$lookup 단계는 조인합니다. 각 문서에 대해 from 컬렉션에서 foreignField 값이 이 문서의 localField 값과 같은 문서를 찾아 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()는 주문 배열을 반환하며, 여기에는 주문 하나가 들어 있습니다. 첫 번째 항목을 가져와 주문 자체를 얻으세요.
localField는 파이프라인의 문서에 속하고, foreignField는 다른 컬렉션에 속합니다. 일치하는 항목이 하나뿐이어도 결과는 배열입니다.
from 컬렉션은 as로 지정한 배열에 일치하는 모든 문서를 제공합니다. 제공된 경로는 파이프라인의 첫 번째 결과를 가져와 요청된 책 하나를 반환합니다.
챌린지
쉬움이 단계에서는 Aggregation Pipelines에서 작업한 프로젝트 파일( server.js 포함)이 아니라, 과정에서 준비한 bookshelf API 프로젝트를 엽니다. 이전 작업은 해당 강의에 그대로 남아 있습니다. server.js를 수정하기 전에 제공된 파일을 읽어 보세요. 이 사본에는 bookshelf 경로, 장르별 개수와 최고 평점 파이프라인, 미리 입력된 저자 정보, 응답 처리 코드가 포함되어 있습니다.
server.js에 준비된 GET /books/:id/full 경로에서 기존 match 뒤에 lookup 단계를 추가하세요. book.author와 author.name이 같은 저자를 조인하세요. 일치하는 결과는 authorInfo에 넣으세요. 첫 번째 결과를 가져오고 책이 없으면 404를 반환하는 제공된 코드는 그대로 두세요.
시험해 보세요. GET /books를 호출하고 Emma의 _id를 복사하세요. GET /books/<Emma’s _id>/full은 authorInfo 배열에 Jane Austen과 born 1775가 포함된 Emma를 반환합니다. 저자를 알 수 없는 책의 경우 authorInfo: []를 반환합니다. 형식은 유효하지만 존재하지 않는 책 ID에는 여전히 404가 반환됩니다.
직접 해보기
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());
// 모든 오류는 { error: message } 형식으로 응답합니다.
function sendError(res, status, message) {
res.status(status).json({ error: message });
}
// :id를 ObjectId로 변환하거나, 유효하지 않은 id에 대해 400으로 응답한 후 null을 반환합니다.
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');
});
MongoDB 데이터베이스의 모든 레슨
직접 연습해 보세요: Express 플레이그라운드