Menu
CoddyTech

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 } } }
);
challenge icon

Desafío

Fácil

Las 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.

  1. En server.js, añade POST /books/:id/reviews. El cuerpo tiene este formato: { "user": "ada", "text": "Loved it" }. Usa parseId para gestionar un id no válido, que da como resultado un código 400.
  2. Con una sola actualización, añade la reseña, por ejemplo { "user": "ada", "text": "Loved it" }, al final de la matriz reviews del 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.
  3. Si ningún libro tiene ese _id, responde con el código 404.
  4. En caso contrario, responde con el estado 201 y solo con la matriz reviews del libro, no con el libro completo, empezando por la reseña más antigua. Después de la primera reseña, la respuesta es:
    [ { "user": "ada", "text": "Loved it" } ]
    Pruébalo: Envía GET /books y copia el _id de Dune. Envía POST /books/<Dune's _id>/reviews con { "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, de Review 2 a Review 6, y Loved it ya no está. Envía GET /books/<Dune's _id>: Dune contiene las mismas cinco reseñas en reviews, 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');
});
quiz iconPonte a prueba

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

Practica por tu cuenta: Playground de Express