Problem Solving: Mongoose Validators Don’t Run on Update Queries

Jul 19, 2022 | By Ben Pernick

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.

i am not a control freak im a control enthusiast

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.

Homer Simpson

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.

All the things

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!

Ben Pernick
Ben is a Software Engineer II at FloQast who loves thinking about complex problems across the stack and coming up with solutions to real-world problems. When not coding he enjoys playing music and learning languages.

Check out research, videos, case studies, and more!

Learn more about working at FloQast!

X

Schedule a Personalized Demo