Mongoose 5.9.0 was released on February 13, 2020. As a semver minor version, Mongoose 5.9 introduces several important new features. In this article, I'll provide an overview of 2 of my favorite features: default options per SchemaType and the perDocumentLimit option for populate().

Default Options Per SchemaType

Mongoose schema types have several handy options. For example, Mongoose strings have a handy trim option that removes leading and trailing whitespace from the string automatically using JavaScript's built-in String#trim() method:

const mongoose = require('mongoose');

const schema = mongoose.Schema({
  email: {
    type: String,
    trim: true
  }
});
const User = mongoose.model('User', schema);

const doc = new User({
  email: 'john@mongoosejs.com  ' // Note the trailing spaces
});
doc.email; // 'john@mongoosejs.com', Mongoose trimmed the string

The trim option is useful enough that often people want to add it to every string path in their schema. Before Mongoose 5.9, you would have to add trim: true to every string path in every schema you define. With Mongoose 5.9, that is now a one liner:

const mongoose = require('mongoose');

// Set `trim: true` on every string path by default
mongoose.Schema.Types.String.set('trim', true);

const schema = mongoose.Schema({ email: String });
const User = mongoose.model('User', schema);

const doc = new User({
  email: 'john@mongoosejs.com  ' // Note the trailing spaces
});
doc.email; // 'john@mongoosejs.com', Mongoose trimmed the string

Suppose you want to trim almost every string, but have one or two exceptions where you don't want to set trim. Mongoose lets you set trim: false to overwrite the default.

// Set `trim: true` on every string path by default
mongoose.Schema.Types.String.set('trim', true);

const schema = mongoose.Schema({
  code: {
    type: String,
    trim: false
  }
});
const Solution = mongoose.model('Solution', schema);

const doc = new Solution({
  code: '  <div>Hello</div>'
});
doc.code; // '  <div>Hello</div>', Mongoose didn't trim

There are several other potential use cases for defaults per SchemaType. For example:

  • Setting required: true for all strings.
  • Setting unique: true on ObjectIds to build a unique index on every ObjectId property by default.
  • Setting default: 0 on numbers so all number paths get set to 0 by default.
  • Setting the transform option on dates so Mongoose formats all dates as unix timestamps rather than ISO date strings.

New Populate Option: perDocumentLimit

Mongoose populate() has a known issue when populating with limit. When you call Article.find().populate('authors'), Mongoose executes 2 queries: Article.find() to get all the articles, and then User.find() to find all users whose _id is in one of the articles' authors field.

Now suppose you call populate() with limit:

const res = await Article.find().populate('authors', { limit: 1 });

Mongoose still only executes one query to populate authors. It just increases the limit to match the number of articles and then applies the original limit after the fact.

const res = await Article.find().populate('authors', { limit: 1 });

// Equivalent
const res = await Article.find();

const limit = 1 * res.length;
const authorIds = res.reduce((arr, article) => arr.concat(article.authors), []);
// Note that Mongoose executes 1 query and limits across all documents!
const authors = await Author.find({ _id: { $in: authorIds } }).limit(limit);

for (const article of res) {
  article.authors = authors.
    filter(author => {
      return article.authors.map(a => a.toString()).includes(author._id.toString());
    }).
    slice(0, 1); // Mongoose ensures that there's at most 1 author per document
}

This approach sometimes works, but can cause unpredictable behavior when one article in the result has way more authors than another. If you have 2 articles, both with 2 authors, populate() with limit: 1 may end up giving 0 authors to the 2nd article.

const mongoose = require('mongoose');

const Article = mongoose.model('Article', mongoose.Schema({
  authors: [{ type: Number, ref: 'User' }]
}));
const User = mongoose.model('User', mongoose.Schema({ _id: Number }));

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

async function run() {
  await mongoose.connect('mongodb://localhost:27017/test', {
    useNewUrlParser: true,
    useUnifiedTopology: true
  });
  await Article.deleteMany({});
  await User.deleteMany({});

  await Article.create([
    { authors: [1, 2] },
    { authors: [3, 4] }
  ]);
  await User.create({ _id: 1 }, { _id: 2 }, { _id: 3 }, { _id: 4 });

  const res = await Article.find().populate({ path: 'authors', options: { limit: 1 } });
  // Prints:
  // [ { authors: [ [Object] ], _id: 5e680e7c87f66eaff5d977fe, __v: 0 },
  //   { authors: [], _id: 5e680e7c87f66eaff5d977ff, __v: 0 } ]
  // Note that the 2nd `authors` array is empty!
  console.log(res); 
}

In Mongoose 5.9, we introduced a separate option called perDocumentLimit that handles limit in a more intuitive way. If you replace limit: 1 with perDocumentLimit: 1 in the above example, both articles get exactly 1 author.

const res = await Article.find().populate({ path: 'authors', perDocumentLimit: 1 });
// Prints:
// [ { authors: [ [Object] ], _id: 5e680fb70e51b4b02d5e973d, __v: 0 },
//   { authors: [ [Object] ], _id: 5e680fb70e51b4b02d5e973e, __v: 0 } ]
console.log(res);

The difference is that Mongoose will now execute a separate query to populate() every article. Since Article.find() fetches 2 articles in the above example, under the hood Mongoose will execute 2 Author.find() queries (each with limit = 1) in parallel to populate authors for each document. For most apps, executing a separate query per document may not be a problem. But if you are populating hundreds of documents with a limit, you may end up with slow trains.

Moving On

SchemaType default options and perDocumentLimit are just 2 of the 7 new features in Mongoose 5.9. Mongoose 5.9 also includes a transform option for SchemaTypes to configure how values are represented in JSON, and a currentTime option for mongoose timestamps that lets you configure what the current time is for timestamps. You can find the full list on the Mongoose changelog. Make sure you upgrade to take advantage of all the new features!

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