Menu
CoddyTech

Canalizaciones de agregación

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

Una lista de los «mejor valorados» necesita promedios, un número mínimo de valoraciones, un orden y un límite. Cada uno de ellos es una etapa.

$match conserva los documentos que cumplen un filtro, como find. $avg calcula el promedio de un campo dentro de $group. Las etapas se ejecutan en el orden en que las escribes, y cada una solo ve lo que le pasó la etapa anterior. Un $match antes de $group filtra los documentos almacenados. Un $match después de $group filtra los grupos usando los campos que creó el grupo, como en { $match: { orders: { $gte: 3 } } }. Un $group puede crear varios campos a la vez, como spent: { $sum: '$total' } y orders: { $sum: 1 }.

await orders.aggregate([
    { $match: { status: 'paid' } },
    { $group: { _id: '$customer', spent: { $sum: '$total' } } },
    { $sort: { spent: -1 } },
    { $limit: 5 },
]).toArray();

$sort y $limit aceptan los mismos argumentos que en un cursor, pero una canalización los ejecuta en el orden en que los escribes. Un $limit antes de $sort conserva cinco elementos cualesquiera y solo ordena esos.

challenge icon

Desafío

Fácil

Este paso abre el proyecto de API de biblioteca preparado para el curso, en lugar de los archivos de tu proyecto de Agrupar con $group (incluido server.js). Tu trabajo anterior sigue en su propia lección. Lee los archivos proporcionados antes de editar server.js. Esta copia contiene las rutas de la biblioteca y el resumen del recuento por género.

La biblioteca quiere una lista de los «mejor valorados»: los tres libros con el promedio de estrellas más alto. Un libro necesita al menos dos valoraciones para aparecer en la lista, así que una sola valoración afortunada de 5 estrellas no lo coloca en los primeros puestos.

  1. En server.js, añade una ruta GET /stats/top. Ejecuta una agregación en la colección ratings, no en books.
  2. Agrupa las valoraciones por bookId. Cada grupo tiene tres campos: _id, que es el id del libro; avg, el promedio de sus stars; y count, el número de valoraciones que tiene. Una etapa $group puede calcular tanto avg como count.
  3. Conserva solo los grupos cuyo count sea 2 o más. Esta regla se aplica al grupo de un libro, no a una sola valoración, así que esta etapa va después de la agrupación.
  4. Ordena los grupos por avg, de mayor a menor, y conserva los primeros 3.
  5. Responde con el estado 200 y el arreglo. Cada elemento tiene exactamente estos tres campos:
    [
      { "_id": "6720a1f4c2b9e81d3a5f0c11", "avg": 4.5, "count": 2 },
      { "_id": "6720a1f4c2b9e81d3a5f0c12", "avg": 3, "count": 2 }
    ]
    _id es el _id del libro, así que un cliente puede obtener el libro con GET /books/ seguido de ese id. Si menos de tres libros tienen dos valoraciones, el arreglo será más corto. Pruébalo: cada vez que pulses «Ejecutar», se vacía ratings, así que envía estas solicitudes después de tu última ejecución. Envía GET /books y copia el _id de Dune, Kindred y Emma. Envía PUT /books/<Dune's _id>/ratings con { "user": "ada", "stars": 5 } y, luego, otra vez con { "user": "bo", "stars": 4 }. Valora Kindred de la misma manera, con 3 estrellas de ada y 3 de bo. Valora Emma una vez, con { "user": "ada", "stars": 5 }. Envía GET /stats/top: obtendrás Dune (avg 4.5, count 2) y, después, Kindred (avg 3, count 2). Emma no aparece porque solo tiene una valoración. Si Emma aparece primero, el filtro por recuento se ejecuta antes de la agrupación o no se ejecuta en absoluto.

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 responde con { 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.get('/stats/genres', async (req, res) => {
    const genres = await books
        .aggregate([{ $group: { _id: '$genre', count: { $sum: 1 } } }])
        .toArray();
    res.json(genres);
});

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