Incrustar o referenciar
Parte de la sección Bases de datos con MongoDB del Journey de Node.js de Coddy. Lección 29 de 29.
Las reseñas y las calificaciones pertenecen a un libro, pero el mejor lugar para almacenar cada una es diferente.
La incrustación coloca los datos relacionados dentro del documento, de modo que una sola lectura obtiene el libro y sus reseñas. Es adecuada para datos que se leen junto con su elemento principal y cuyo tamaño se mantiene reducido. La referencia los mantiene en su propia colección junto con el identificador del elemento principal, como hace ratings. Es adecuada para datos que crecen sin límite o que se consultan por separado, como un promedio de todas las calificaciones.
Un documento puede contener como máximo 16 MB, así que un arreglo incrustado necesita un límite. Dentro de $push, $each enumera los valores que se añadirán y $slice: -3 conserva los tres últimos.
await players.updateOne(
{ name: 'Ada' },
{ $push: { recentScores: { $each: [120], $slice: -3 } } }
);Desafío
FácilLas reseñas son breves, siempre se muestran junto con su libro y cada libro muestra solo sus 5 reseñas más recientes. Así que guárdalas dentro del documento del libro, en una matriz reviews, y no permitas que esa matriz supere los 5 elementos.
- En
server.js, añadePOST /books/:id/reviews. El cuerpo tiene este formato:{ "user": "ada", "text": "Loved it" }. UsaparseIdpara gestionar un id no válido, que da como resultado un código 400. - Con una sola actualización, añade la reseña, por ejemplo
{ "user": "ada", "text": "Loved it" }, al final de la matrizreviewsdel libro. En la misma actualización, limita la matriz a sus últimos 5 elementos para que se elimine la reseña más antigua. Si un libro no tiene reseñas, se crea una matriz nueva. - Si ningún libro tiene ese
_id, responde con el código 404. - En caso contrario, responde con el estado 201 y solo con la matriz
reviewsdel libro, no con el libro completo, empezando por la reseña más antigua. Después de la primera reseña, la respuesta es:Pruébalo: Envía[ { "user": "ada", "text": "Loved it" } ]GET /booksy copia el_idde Dune. EnvíaPOST /books/<Dune's _id>/reviewscon{ "user": "ada", "text": "Loved it" }: obtendrás el código 201 y la matriz de arriba. Envía cinco reseñas más, con los textos"Review 2"a"Review 6". La última respuesta contiene cinco reseñas, deReview 2aReview 6, yLoved itya no está. EnvíaGET /books/<Dune's _id>: Dune contiene las mismas cinco reseñas enreviews, y la pestaña Data las muestra dentro del libro.
Pruébalo tú mismo
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());
// Cada error devuelve { error: message }.
function sendError(res, status, message) {
res.status(status).json({ error: message });
}
// El :id como ObjectId, o null después de responder con 400 si el id no es válido.
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');
});
Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.
Todas las lecciones de Bases de datos con MongoDB
1Documentos
Un libro como documentoColecciones y bases de datosConexión a MongoDBLectura de documentos almacenadosInserción de documentosBúsqueda de un documentoBúsqueda por ObjectIdComparación de númerosFiltros de consultaSelección de los campos devueltosOrdenación de los resultados de consultaConservar los primeros resultadosProyección, ordenación y límite4Estructura
Un módulo de base de datosPaginación en la base de datos2Modificación de documentos
Actualización de documentosOperadores de actualizaciónPractica por tu cuenta: Playground de Express