Mongoose 5.5 was released earlier this week. This release includes 12 new features and a performance improvement. The two features I'm most excited about are hooks for user-defined static functions and the ability to pass a function to populate's match option. In this article, I'll introduce these two new features and show how they can save you some design headache.

Hooks for Custom Statics

Statics are Mongoose's implementation of OOP static functions. You add a static function to your schema, and Mongoose attaches it to any model you compile with that schema.

const mongoose = require('mongoose');

const schema = new mongoose.Schema({ name: String });

// `findByName` is a static function. `this` is the model in this context.
schema.statics.findByName = function(name) {
  return this.find({ name });
};

const User = mongoose.model('User', schema);
User.findByName('test'); // Equivalent to `User.find({ name: 'test' })`

Mongoose 5.5 introduces the ability to add hooks for custom statics, like schema.pre('findByName'). In a custom static's middleware function, this is the model, like insertMany middleware.

// Can define pre and post hooks for `findByName` in Mongoose 5.5
schema.pre('findByName', function(next, name) {
  // Prints "User.findByName('test')"
  console.log(`${this.modelName}.findByName('${name}')`);
  next();
});
schema.post('findByName', function(docs) {
  // Prints "Found 0 docs"
  console.log('Found', docs.length, 'docs');
});

// Compile the model and run `findByName()`
const User = mongoose.model('User', schema);
User.findByName('test').
  then(() => console.log('done')); // Hooked `findByName` returns a promise

Statics help you consolidate core business logic into reusable functions. Hooks for statics lets you handle cross-cutting concerns that affect all your statics. This is especially powerful when combined with plugins. For example, here's how you can add debug logging to all your statics:

const mongoose = require('mongoose');
const debug = require('debug')('mongoose:statics');

// Create schema
const schema = new mongoose.Schema({ name: String, email: String });

schema.statics.findByName = function(name) {
  return this.find({ name });
};
schema.statics.findByEmail = function(email) {
  return this.find({ email });
};

// Add debug hooks for every static
Object.keys(schema.statics).forEach(name => {
  schema.pre(name, function(next, arg) {
    debug(`${this.modelName}.${name}(${arg})`);
    next();
  });
});

// Use the model
(async () => {
  await mongoose.connect('mongodb://localhost:27017/test', { useNewUrlParser: true });
  const User = mongoose.model('User', schema);

  await User.findByName('test'); // Prints "mongoose:statics User.findByName(test) +0ms"
  await User.findByEmail('test@test.co'); // Prints "mongoose:statics User.findByEmail(test@test.co) +10ms"
})();

Populate Match Function

The match option for populate lets you add an additional filter to populate(). For example, suppose you have two models, BlogPost and Comment, and you're using soft deletes for comments. When you load a blog post, you want to load the corresponding comments that do not have the deleted property set. Here's how you would ensure that populate() always ignores deleted comments.

// Create schemas
const blogPostSchema = new mongoose.Schema({
  title: String,
  authorId: Number
});
blogPostSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'blogPostId',
  // When populating comments, exclude comments that have `deleted`
  // set to `true`
  options: { match: { deleted: { $ne: true } } },
});

const commentSchema = new mongoose.Schema({
  _id: Number,
  blogPostId: mongoose.ObjectId,
  authorId: Number,
  deleted: Boolean
});

The match option lets you filter out deleted comments. But how about a trickier challenge: finding all self-comments. That is, comments whose authorId is equal to the corresponding blog post's authorId? Below is an example of populating comments, excluding soft-deleted comments and comments by anyone other than the author of the blog post.

const BlogPost = mongoose.model('BlogPost', blogPostSchema);
const Comment = mongoose.model('Comment', commentSchema);

let post = await BlogPost.create({ title: 'Mongoose 5.5.0', authorId: 1 });
await Comment.create([
  { _id: 1, blogPostId: post._id, authorId: 2 },
  { _id: 2, blogPostId: post._id, authorId: 1 },
  { _id: 3, blogPostId: post._id, authorId: 1, deleted: true }
]);

post = await BlogPost.findOne().populate({
  path: 'comments',
  // If match is a function, it receives the blog post doc as a parameter
  match: doc => ({ authorId: doc.authorId, deleted: { $ne: true } })
});
console.log(post.comments.length); // 1

Internally, Mongoose relies on the excellent Sift library to power match functions.

Match functions are particularly useful when it comes to roles and hiding results the end user doesn't have permission to see. For example, suppose you want only the author of the blog post to see deleted comments. Here's how you can do that with match functions:

const authorId = 1
post = await BlogPost.find().populate({
  path: 'comments',
  match: doc => (doc.authorId === authorId ? {} : { deleted: { $ne: true } })
});

Moving On

Hooks for custom statics and populate match functions are just two of the 12 new features in Mongoose 5.5. Some of the other new features are connection-scoped plugins, hooks for Query#distinct(), and the new Query#projection() function. Make sure you upgrade and take advantage of all the new features in Mongoose 5.5!

Are you new to Mongoose and looking to learn? Sign up for a free trial of Pluralsight and check out Mark Scott's excellent video course Introduction to Mongoose for Node.js and MongoDB.

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