Mongoose 7 was released on February 27, 2023. This major release is relatively small compared to Mongoose 6, with only 14 backwards breaking changes. The most significant backwards breaking changes are dropping support for callbacks and upgrading to MongoDB Node.js driver 5.x, but there are a few smaller changes that are worth highlighting.

Now that Mongoose 7 is out, we plan on continuing to support Mongoose 6 and backport bug fixes as necessary. We will not backport any new features to Mongoose 6, we're only shipping patch releases to Mongoose 6 only going forward. As for Mongoose 5, we will continue to merge PRs that are specific to Mongoose 5, but we will not backport any features or bug fixes to Mongoose 5 unless specifically requested.

Without further ado, here's some of the highlights from Mongoose 7. You can find the full migration guide on the Mongoose docs.

No More Callbacks

When Mongoose was first released in 2010, callbacks were the preferred concurrency primitive in Node.js. Mongoose code looked like this:

Model.findOne({ _id: _id }, function(err, doc) {
  if (err) {
    return res.status(404).json({ message: 'Not Found' });
  }
  res.json({ doc: doc });
});

However, promises in 2015 and async/await in 2017 offered numerous advantages over callbacks. Pretty soon, there was a whole generation of JavaScript developers that had never used callbacks.

  • Promises and async/await are standardized, callbacks are not
  • Promises and async/await offer better error handling - what happens if your callback function throws an error?

One async/await feature that we're particularly excited about for Mongoose is async stack traces. Without async stack traces, Node's event loop means that stack traces often contain just Node and MongoDB internals, with no reference to the point in your code that caused the error. As Mongoose 7 evolves, you should see better and better stack traces. For example, below is the stack trace if mongoose.connect() fails because the MongoDB server is down.

at _handleConnectionErrors (/node_modules/mongoose/lib/connection.js:755:11)
at NativeConnection.openUri (/node_modules/mongoose/lib/connection.js:730:11)
at async run (/app.js:8:3)
const mongoose = require('mongoose');

run().catch(err => console.log(err));

async function run() {
  await mongoose.connect('mongodb://127.0.0.1/mongoose_test');
}

Mongoose isn't fully compatible with async stack traces yet - for async stack traces, you need to use async functions all the way through. And Mongoose still has some callback-based code internally. But we're continuing to work on improving Mongoose's async stack trace support.

strictQuery is false By Default Again

In Mongoose 6, we tried to remove strictQuery and ended up having to bring strictQuery back with several workarounds to minimize the impact of this breaking change. The strictQuery option controls how Mongoose handles querying by fields that aren't in your schema.

const TestModel = mongoose.model('Test', mongoose.Schema({ name: String }));

// `otherProp` is not in the schema. What to do?
await TestModel.find({ otherProp: 'test' });

In Mongoose 6, with strictQuery = true by default, Mongoose would strip out otherProp from the query filter, making the above query equivalent to await TestModel.find({}). That would return all documents in TestModel's collection. Filtering out unknown properties was problematic for many reasons, most notably because typo-ing a property name would lead to returning all documents, leading to potential performance degradation and security issues.

In Mongoose 7, strictQuery is false by default, exactly as it was in Mongoose 5. With strictQuery = false, Mongoose will not strip out unknown fields from the query filter. So TestModel.find({ otherProp: 'test' }) will find all documents where otherProp is equal to 'test'.

orFail() and updateOne()

The orFail() function is a neat tool for triggering an error if a query doesn't find any documents. The orFail() function is often used with findOne() to throw an HTTP 404 error if no document is found.

// Throw an error if no user has an `_id` that matches `req.query._id`
await User.findById(req.query._id).setOptions({ sanitizeFilter: true }).orFail();

The orFail() function is also convenient for TypeScript, because it makes the query result non-nullable.

// `user1` has type `User | null`
const user1 = await User.findById(req.query._id);

// `user2` has type `User`
const user2 = await User.findById(req.query._id).orFail();

In Mongoose 5 and Mongoose 6, using updateOne().orFail() would throw an error if no document was modified. Unfortunately, that means updateOne().orFail() would error out if a document was found, but none of the updates made any changes to the document. For example, the following code would throw in Mongoose 6, but does not throw in Mongoose 7.

const doc = await User.create({ name: 'John Smith' });

// In Mongoose 6, throws because `name` is already 'John Smith'.
// In Mongoose 7, succeeds, because there is a doc that matches `{ _id: doc._id }`
await User.updateOne({ _id: doc._id }, { name: 'John Smith' }).orFail();

In Mongoose 7, updateOne().orFail(), updateMany().orFail(), and replaceOne().orFail() only throw if no document matched the filter. If a document was found but not modified, orFail() will not throw.

Copying Schema Options on add()

Mongoose 7 now copies schema options when you add() a schema or define a schema using an array of schemas. In Mongoose 6, add()-ing a schema only copied schema paths, not options.

const baseSchema = new Schema(
  { createdAt: Number, updatedAt: Number },
  {
    timestamps: true,
    toJSON: { virtuals: true },
    toObject: { virtuals: true }
  }
);

// Create a new schema with the same paths and options as `baseSchema`,
// plus a few more.
// In Mongoose 6, this wouldn't copy baseSchema's options.
const userSchema = new Schema([
  baseSchema,
  {
    name: String
  }
]);

userSchema.path('createdAt'); // Number
userSchema.options.timestamps; // true
userSchema.options.toJSON; // { virtuals: true }
userSchema.options.toObject; // { virtuals: true }

// Also works using `Schema.prototype.add()`
const productSchema = new Schema(
  { name: String },
  // `add()` copies options, _unless_ there's already a user-specified option
  { toJSON: { getters: true } }
);
productSchema.add(baseSchema);

productSchema.path('createdAt'); // Number
productSchema.options.timestamps; // true
productSchema.options.toObject; // { virtuals: true }
// Uses productSchema's `toJSON` option
productSchema.options.toJSON; // { getters: true }

This feature is great for schema composition. Before, you had to use either Mongoose's global configuration or class inheritance to define default options for your schema. Now, you can use a mixin-like pattern by passing an array of schemas and schema definition objects to new Schema().

Moving On

These are just a few of the improvements we've made in Mongoose 7. Check out the migration guide for a full list of all the potentially breaking changes. Please upgrade and open any issues you find on Mongoose's GitHub page!

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus