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
EasyReaders 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.
- In
server.js, next toconst books = db.collection('books');, open a second collection namedratings. Each rating is one document:Store{ "bookId": "6720a1f4c2b9e81d3a5f0c11", "user": "ada", "stars": 4 }bookIdas the ObjectId thatparseIdreturns, not as the text from the URL. In JSON both look the same. - 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 }. - 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 } ] - Both routes use
parseIdfor a bad id, which gets 400. Try it: SendGET /booksand copy Dune's_id. SendPUT /books/<Dune's _id>/ratingswith{ "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 }. SendGET /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 aratingscollection.GET /books/<Kindred's _id>/ratingsreturns[].
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