Menu
CoddyTech

Upserts

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

Cada lector califica un libro una vez. Si vuelve a calificarlo, deben cambiar las estrellas, no añadirse una segunda calificación.

Con { upsert: true }, updateOne actualiza el documento que coincide con el filtro y, si ninguno coincide, inserta uno creado a partir de los campos del filtro y de la actualización. Una sola llamada cubre la primera vez y todas las veces posteriores.

// un documento de configuración por usuario
await settings.updateOne(
    { user: 'ada' },
    { $set: { theme: 'dark' } },
    { upsert: true }
);

El filtro indica qué hace que un documento sea único; en este caso, un documento por usuario. El campo upsertedId del resultado se establece cuando la llamada insertó un documento. Un insertOne normal no puede hacer esto: añade un documento cada vez.

challenge icon

Desafío

Fácil

Los lectores califican los libros con entre 1 y 5 estrellas. Cada lector califica un libro una sola vez: si lo califica de nuevo, cambia las estrellas en lugar de añadir una segunda calificación.

  1. En server.js, junto a const books = db.collection('books');, abre una segunda colección llamada ratings. Cada calificación es un documento:
    { "bookId": "6720a1f4c2b9e81d3a5f0c11", "user": "ada", "stars": 4 }
    Almacena bookId como el ObjectId que devuelve parseId, no como el texto de la URL. En JSON, ambos se ven igual.
  2. Añade PUT /books/:id/ratings. Su cuerpo tiene este aspecto: { "user": "ada", "stars": 4 }. Si este usuario ya calificó este libro, cambia sus estrellas. Si no, añade una nueva calificación. Haz ambas cosas con una sola llamada. Responde con el estado 200 y la calificación: { "user": "ada", "stars": 4 }.
  3. Añade GET /books/:id/ratings. Responde con el estado 200 y un arreglo con las calificaciones de ese libro tal como están almacenadas. No deben aparecer calificaciones de otros libros:
    [ { "_id": "6720a1f4c2b9e81d3a5f0c21", "bookId": "6720a1f4c2b9e81d3a5f0c11", "user": "ada", "stars": 2 } ]
  4. Ambas rutas usan parseId si el identificador no es válido; en ese caso, devuelven el estado 400. Pruébalo: Envía GET /books y copia el _id de Dune. Envía PUT /books/<Dune's _id>/ratings con { "user": "ada", "stars": 4 }: obtienes el estado 200 y { "user": "ada", "stars": 4 }. Envíalo de nuevo con { "user": "ada", "stars": 2 }: obtienes el estado 200 y { "user": "ada", "stars": 2 }. Envía GET /books/<Dune's _id>/ratings: obtienes una calificación, la de Ada, con 2 estrellas. Califica Dune con { "user": "bo", "stars": 5 } y vuelve a listar las calificaciones: obtienes dos. La pestaña Datos ahora muestra una colección ratings. GET /books/<Kindred's _id>/ratings devuelve [].

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');

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