Blog -
Problem Solving: Mongoose Validators Don’t Run on Update Queries
Let’s face it – the flexibility of Mongo / mongoose is part of what makes it great. But sometimes it can be a little bit too flexible, which why mongoose schemas have tools to control what sort of data can be written to your collection.
Here at FloQast we have some conditional validators and even some partial (conditional) compound unique indexes. This gives us really granular control over our more more complex collections to make development easier and avoid writing bugs. The problem is it doesn’t always work.
The Problem
Trouble arose when we needed to update (or upsert) our collections. Left to its own devices, mongoose’s native findOneAndUpdate
won’t run validators at all. It does accept a {runValidators: true}
option, and a {context: ‘query'}
option if you want the this
keyword to function properly in your validator functions, but even after trying that we still had trouble with our partialFilterExpressions.
More importantly, we’re relying on schema validation to help prevent human error — so if it is confusing to use or has weird behavior, that totally defeats the point. Also the mongoose docs say to avoid using findOneAndUpdate
at all! They recommend document.save
wherever possible. But the nice clean interface of an upsert function is great, so we wanted to find a solution that gave use the best of all worlds.
The Solution
What we really wanted was a function with the interface we expect from findOneAndUpdate
, but that respects all validators and indexes and only uses stable APIs (like create
or save
) under the hood. So we went ahead and implemented one ourselves, and we’ll quickly show you how to do it too!
Getting Started
Let’s try to model the original findOneAndUpdate
interface as closely as is reasonable, with some simplifications of course. To be as generic as possible we’ll just accept the model to update as a parameter. We’ll do a couple of steps but let’s start by just writing a function that literally does what it says (find something and update it).
exports.findOneAndUpdate = async (model, query, update) => { const setUpdate = update.$set || {}; const document = await model.findOne(query); if (!document) { return null; } const updatedDocument = Object.assign(document, setUpdate); return updatedDocument.save(); };
To get the updatedDocument
to save, we are using Object.assign
with document
as the first argument to mutate it, not make a clone. Often this isn’t best practice, but we need to call document.save()
which is accessed through the prototype chain. Even deep cloning does not preserve that, so in this case mutation is the way to go.
Upserts
But wait, we want the option to create a collection if it doesn’t exist! findOneAndUpdate
has an options
parameter which can have an upsert
flag, so let’s do the same thing.
exports.findOneAndUpdate = async (model, query, update, options = {}) => { const upsert = options.upsert || false; const setUpdate = update.$set || {}; // Insert case const document = await model.findOne(query); if (!document) { return upsert ? model.create({ ...query, ...setUpdate, }) : null; } // Update case const updatedDocument = Object.assign(document, setUpdate); return updatedDocument.save(); };
Pretty straightforward, right? If the upsert flag is true, we update the existing documents and create the ones that don’t. If the document doesn’t exist, we create it with keys from both the update and the query to mimic native findOneAndUpdate
behavior as closely as possible.
Unset
Native findOneAndUpdate
also accepts an $unset
object as part of the update
argument. All keys in the $unset
object will be completely removed from the collection regardless of their value. Let’s try implementing this operator too!
exports.findOneAndUpdate = async (model, query, update, options = {}) => { const upsert = options.upsert || false; const setUpdate = update.$set || {}; const unsetUpdate = update.$unset || {}; Object.keys(unsetUpdate).forEach((key) => { unsetUpdate[key] = undefined; // setting a key to undefined removes it from collection }); // Insert case const document = await model.findOne(query); if (!document) { return upsert ? model.create({ ...query, ...setUpdate, ...unsetUpdate, }) : null; } // Update case const updatedDocument = Object.assign(document, setUpdate, unsetUpdate); return updatedDocument.save(); };
Notice that we unset keys by setting them to undefined
, rather than setting them to null
or using the delete
keyword. This is because we want to remove the key from the document completely. Setting a value to null
keeps they key on the document (with a literal value of null
). Using the delete
keyword on a mongoose document actually does nothing, so don’t do that. Other than that, it’s pretty simple!
Outcome
We started out with a big headache because our updates weren’t respecting our carefully crafted mongoose validators. But now have a reliable, flexible solution where we can be confident that the restrictions we put in place will be enforced on updates, without a lot of spaghetti code in our controller files. We only implemented the parts of findOneAndUpdate
that we needed, but now that you have a template to start from, you can implement any flags or operators that you find useful!
Back to Blog