The upsert
option in MongoDB is pretty useful.
When you use the upsert
option when you do an update, MongoDB will either update the document if it finds a match for your query or create a new one.
For example, in my Node.js rate limit function, I give each user a certain number of tokens for an action. Let's say it's the first time they do an action that has 10 tokens, I create a new document that says they have 9 tokens left.
But the next time the same user performs that action, I don't want to insert a new document in MongoDB, I want to update the existing document, now with 8 tokens.
// update the db (or insert if the limit doesn't exist)
await db.collection('rate_limits').updateOne(findQuery, {
$set: limit
}, { upsert: true });
So I use upsert: true
. The first time the rate limit is used, a new document is created. After that, the existing document is updated.
And you can know if a document was upserted with updateOne
by checking for an upsertedId on the result.
// update the db (or insert if the limit doesn't exist)
let result = await db.collection('rate_limits').updateOne(findQuery, { $set: limit }, { upsert: true });
if (result.upsertedId) {
// new document upserted!
}
So far, so good.
But what if you need to use upsert
with findOneAndUpdate
? Your response is the existing document, and there's no obvious way to know if your update command with upsert
created a new document (upserted), or updated an existing document.
Here's another example with findOneAndUpdate
:
// update the db (or insert if the limit doesn't exist)
let result = await db.collection('rate_limits').findOneAndUpdate({ user: userId }, {
$setOnInsert: { tokens: 10 },
$inc: { tokens: -1 },
}, { upsert: true });
findOneAndUpdate
does the update, but also returns the document.
Let's say when a new rate limit is created, we want to email our users and let them know they have X number of tokens for the next hour. But we only want to do this the first time a rate limit is created.
We only want to perform this action on upsert.
lastErrorObject
(deprecated)
I'm starting with lastErrorObject
because it was previously a method you could use (and still can use). But I believe it uses getLastError
which is deprecated in MongoDB 5.0 so I don't recommend using it going forward.
But for completeness of this post, here's how you could use the lastErrorObject
from the result to know if an update has upserted.
// update the db (or insert if the limit doesn't exist)
let result = await db.collection('rate_limits').findOneAndUpdate({ user: userId }, {
$setOnInsert: { tokens: 10 },
$inc: { tokens: -1 },
}, { upsert: true });
if (result.lastErrorObject.upserted) {
// new document upserted!
}
result.value
is null
In findOneAndUpdate
, the returned document can be found at result.value
. And a nice little trick to know if your update was an upsert, is that result.value
will be null!
// update the db (or insert if the limit doesn't exist)
let result = await db.collection('rate_limits').findOneAndUpdate({ user: userId }, {
$setOnInsert: { tokens: 10 },
$inc: { tokens: -1 },
}, { upsert: true });
if (!result.value) {
// new document upserted!
}
That's because findOneAndUpdate
returns the document before the update by default. And since there was no document before the update on an upsert, you get null!
Adding a date if returnDocument: "after"
By default findOneAndUpdate
returns the document before the update. But that might always be what you want. In this example I've created, we might want to tell the user how many tokens they have left, and I can only get that if I return the document after the update.
// update the db (or insert if the limit doesn't exist)
let result = await db.collection('rate_limits').findOneAndUpdate({ user: userId }, {
$setOnInsert: { tokens: 10 },
$inc: { tokens: -1 },
}, { upsert: true, returnDocument: "after" });
if (!result.value) {
// new document upserted! ❌
}
The problem now is that result.value
wont be null when a new document is upserted, because we will get the result after the update - so we get the new document instead!
This was the situation I found myself in, so how can we know if the new document is an upsert?
How I got around this was by adding a date to my documents on insert (or in this case - on upsert!).
I create a timestamp let timestamp = Date.now();
and then add that to my MongoDB document with $setOnInsert
.
// update the db (or insert if the limit doesn't exist)
let timestamp = Date.now();
let result = await db.collection('rate_limits').findOneAndUpdate({ user: userId }, {
$setOnInsert: { tokens: 10, insertedAt: timestamp },
$inc: { tokens: -1 },
}, { upsert: true, returnDocument: "after" });
if (result.value.insertedAt === timestamp) {
// new document upserted!
}
So insertedAt
(or whatever you decide to call the field) will only be set when a new document is created on upsert.
Now you can check if the document was upserted because the timestamp will match the insertedAt
field if it was.
if (result.value.insertedAt === timestamp) {
// new document upserted!
}