Menu
CoddyTech

Upserts

Part of the Databases with MongoDB section of Coddy's Node.js journey. Lesson 18 of 29.

Each reader rates a book once. Rating it again should change the stars, not add a second rating.

With { upsert: true }, updateOne updates the document the filter matches, and when none matches it inserts one built from the filter's fields plus the update. One call covers the first time and every time after.

// one settings document per user
await settings.updateOne(
    { user: 'ada' },
    { $set: { theme: 'dark' } },
    { upsert: true }
);

The filter says what makes a document unique; here, one document per user. The result's upsertedId is set when the call inserted. A plain insertOne cannot do this: it adds a document every time.

challenge icon

Challenge

Easy

Readers rate books from 1 to 5 stars. Each reader rates a book once: rating it again changes the stars instead of adding a second rating.

  1. In server.js, next to const books = db.collection('books');, open a second collection named ratings. Each rating is one document:
    { "bookId": "6720a1f4c2b9e81d3a5f0c11", "user": "ada", "stars": 4 }
    Store bookId as the ObjectId that parseId returns, not as the text from the URL. In JSON both look the same.
  2. Add PUT /books/:id/ratings. Its body looks like { "user": "ada", "stars": 4 }. If this user already rated this book, change their stars. If not, add a new rating. Do both with one call. Respond with status 200 and the rating: { "user": "ada", "stars": 4 }.
  3. Add GET /books/:id/ratings. It responds with status 200 and an array of that book's ratings as they are stored. Ratings of other books must not appear:
    [ { "_id": "6720a1f4c2b9e81d3a5f0c21", "bookId": "6720a1f4c2b9e81d3a5f0c11", "user": "ada", "stars": 2 } ]
  4. Both routes use parseId for a bad id, which gets 400. Try it: Send GET /books and copy Dune's _id. Send PUT /books/<Dune's _id>/ratings with { "user": "ada", "stars": 4 }: you get 200 and { "user": "ada", "stars": 4 }. Send it again with { "user": "ada", "stars": 2 }: you get 200 and { "user": "ada", "stars": 2 }. Send GET /books/<Dune's _id>/ratings: you get one rating, Ada's, with 2 stars. Rate Dune with { "user": "bo", "stars": 5 } and list again: you get two ratings. The Data tab now shows a ratings collection. GET /books/<Kindred's _id>/ratings returns [].

Try it yourself

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

// Every error answers { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// The :id as an ObjectId, or null after answering 400 for a bad id.
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');
});

All lessons in Databases with MongoDB

Practice on your own: Express playground