Pipelines de agregación
Parte de la sección Bases de datos con MongoDB del Journey de Node.js de Coddy. Lección 13 de 18.
Una lista de «los mejor valorados» necesita promedios, un número mínimo de valoraciones, un orden y un límite. Cada uno de esos elementos es una etapa.
$match conserva los documentos que pasan un filtro, como find. $sort y $limit funcionan igual que en un cursor, y $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, así que un $match después de $group filtra los grupos.
await orders.aggregate([
{ $match: { status: 'paid' } },
{ $group: { _id: '$customer', spent: { $sum: '$total' } } },
{ $sort: { spent: -1 } },
{ $limit: 5 },
]).toArray();A diferencia de un cursor, una canalización conserva el orden: $limit antes de $sort conserva cinco elementos cualesquiera y después los ordena.
Desafío
FácilGET /stats/topdevuelve los 3 libros con el promedio de estrellas más alto, primero el mejor, cada uno como{ "_id": <bookId>, "avg": 4.5, "count": 2 }.- Un libro necesita al menos 2 valoraciones para aparecer en la lista.
Valora algunos libros como dos usuarios y luego envía GET /stats/top.
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.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');
});
Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.