Menu
CoddyTech

Agrupar con $group

Parte de la sección Bases de datos con MongoDB del Journey de Node.js de Coddy. Lección 21 de 29.

Contar un género es una consulta. Contar todos los géneros a la vez requiere que la base de datos ordene los libros en grupos.

aggregate ejecuta una canalización: una matriz de etapas, cada una pasa su resultado a la siguiente. La etapa $group crea un documento de salida por cada valor distinto de su _id. Aquí, _id no es el id de un documento. Indica por qué agrupar, y cada documento de salida contiene ese valor en _id. Un $ antes del nombre de un campo, '$team', significa «el valor de ese campo en este documento». Sin él, 'team' es el mismo texto para todos los documentos, así que todos quedan en un único grupo. $sum: 1 suma 1 por documento.

await players
    .aggregate([{ $group: { _id: '$team', players: { $sum: 1 } } }])
    .toArray();
// [{ _id: 'red', players: 4 }, { _id: 'blue', players: 3 }]

El resultado de un grupo contiene solo ese _id y los campos que calculan sus acumuladores; los demás campos de los documentos de entrada no se conservan.

challenge icon

Desafío

Fácil

Este paso abre el proyecto de la API de la estantería preparado para el curso, en lugar de los archivos de tu proyecto de Recuento de documentos (incluido server.js). Tu trabajo anterior permanece en su propia lección. Lee los archivos proporcionados antes de editar server.js. Esta copia contiene las rutas CRUD de la estantería, el índice ISBN único, los filtros de GET /books y los recuentos de GET /stats.

Una página de géneros muestra cuántos libros hay de cada género, todo con una sola consulta, incluidos los géneros que nadie había previsto.

  1. En server.js, añade una ruta GET /stats/genres.
  2. Ejecuta una agregación en books con una etapa $group. Esta crea un resultado por cada genre distinto, con el número de libros que lo tienen.
  3. Responde con el estado 200 y el arreglo que devuelve la agregación. Cada entrada contiene el género en _id y el número de libros en count. Dentro de $group, _id no es un ObjectId: es el valor por el que se agruparon los libros. Las entradas pueden aparecer en cualquier orden. Con los libros iniciales, la respuesta es:
    [ { "_id": "scifi", "count": 2 }, { "_id": "classic", "count": 1 } ]
    Pruébalo: Envía GET /stats/genres: obtienes las dos entradas anteriores. Envía POST /books con { "title": "Leaves of Grass", "author": "Walt Whitman", "year": 1855, "isbn": "9780140421996", "genre": "poetry" }: obtienes 201. Envía GET /stats/genres de nuevo: aparece una tercera entrada, { "_id": "poetry", "count": 1 }.

Pruébalo tú mismo

import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';

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

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' },
]);

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 tras 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;
}

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.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});

Todas las lecciones de Bases de datos con MongoDB

Practica por tu cuenta: Playground de Express