Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
204 lines (151 sloc) 8.17 KB

Mongoose 4.10.0 just landed and brings with it several powerful features and bug fixes. The most +1-ed feature in this supporting unique in array definitions via the mongoose-unique-array plugin. This feature is implemented as a separate plugin because mongoose-unique-array does much more than simply create a unique index, it also ties in to validators and versioning. In this article, I'll explain how to use mongoose-unique-array and the caveats you need to be aware of when using it.

Why a Separate Plugin?

In mongoose 4.10.0, you can create a model with an array of strings that are supposed to be unique. You don't need the mongoose-unique-array plugin to create a unique index on an array:

const mongoose = require('mongoose');

mongoose.connect('mongodb://localhost:27017/test');
mongoose.set('debug', true);

async function run() {
  await mongoose.connection.dropDatabase();

  const mySchema = new mongoose.Schema({
    names: [{ type: String, unique: true }]
  });
  const M = mongoose.model('Test', mySchema);

  // Need to wait for indexes to build, otherwise unique won't work!
  await new Promise((resolve, reject) => {
    M.once('index', err => err ? reject(err) : resolve());
  });

  // Will throw an error, because multiple documents have 'Test' in
  // their `names` array.
  await M.create([{ names: ['Test'] }, { names: ['Test'] }]);
  console.log('done');
}

run().catch(error => console.error(error.stack));

However, MongoDB unique indexes do not prevent saving a document with duplicate values in the names array. MongoDB unique indexes prevent multiple documents from having the value 'Test' in their names arrays, but a single document can have duplicate values in names.

const mongoose = require('mongoose');

mongoose.connect('mongodb://localhost:27017/test');
mongoose.set('debug', true);

async function run() {
  await mongoose.connection.dropDatabase();

  const mySchema = new mongoose.Schema({
    names: [{ type: String, unique: true }]
  });
  const M = mongoose.model('Test', mySchema);

  // Need to wait for indexes to build, otherwise unique won't work!
  await new Promise((resolve, reject) => {
    M.once('index', err => err ? reject(err) : resolve());
  });

  // Works! Unique indexes do not prevent a single doc from having
  // duplicate values
  await M.create([{ names: ['Test', 'Test'] }]);
  console.log('done');
}

run().catch(error => console.error(error.stack));

The mongoose-unique-array plugin adds a custom validator to the names array that makes sure the array has no duplicate values.

const mongoose = require('mongoose');
const uniqueArrayPlugin = require('mongoose-unique-array');

mongoose.connect('mongodb://localhost:27017/test');
mongoose.set('debug', true);

async function run() {
  await mongoose.connection.dropDatabase();

  const mySchema = new mongoose.Schema({
    names: [{ type: String, unique: true }]
  });
  // Add the unique array plugin
  mySchema.plugin(uniqueArrayPlugin);
  const M = mongoose.model('Test', mySchema);

  // Need to wait for indexes to build, otherwise unique won't work!
  await new Promise((resolve, reject) => {
    M.once('index', err => err ? reject(err) : resolve());
  });

  // Throws a ValidationError. The plugin adds a validator to `names` that will
  // fail if there are duplicates.
  await M.create([{ names: ['Test', 'Test'] }]);
  console.log('done');
}

run().catch(error => console.error(error.stack));

This also works for document arrays:

async function run() {
  await mongoose.connection.dropDatabase();

  const mySchema = new mongoose.Schema({
    names: [{ username: { type: String, unique: true } }]
  });
  // Add the unique array plugin
  mySchema.plugin(uniqueArrayPlugin);
  const M = mongoose.model('Test', mySchema);

  // Need to wait for indexes to build, otherwise unique won't work!
  await new Promise((resolve, reject) => {
    M.once('index', err => err ? reject(err) : resolve());
  });

  // Throws a ValidationError. The plugin adds a validator to `names`
  await M.create([{ names: [{ username: 'Test' }, { username: 'Test' }] }]);
  console.log('done');
}

run().catch(error => console.error(error.stack, error.errors));

Concurrency Caveats

In the above cases, the entire array is in memory, so checking the array for duplicates is easy: mongoose just loops through the array. However, mongoose converts .push() into a $push operation in MongoDB. In other words, if you use push() instead of overwriting the array each time, mongoose might not actually have the entire array in memory. In this case, the custom validator will not work.

async function run() {
  await mongoose.connection.dropDatabase();

  const mySchema = new mongoose.Schema({
    names: [String]
  });
  const M = mongoose.model('Test', mySchema);

  await M.create([{ names: [] }]);
  const [doc1, doc2] = await Promise.all([M.findOne(), M.findOne()]);

  doc1.names.push('Test');
  await doc1.save();
  // Will print `[]`, doc2.names is empty even though doc1.names is not
  console.log('Before push', doc2.names);
  doc2.names.push('Test');
  await doc2.save();

  // The `names` array has 2 elements, `['Test', 'Test']`
  console.log(await M.findById(doc1._id));
}

run().catch(error => console.error(error.stack, error.errors));

To cope with this edge case, mongoose-unique-array modifies the query mongoose uses to save() the document. It also sets the saveErrorIfNotFound option on your schema so a save() that doesn't successfully modify a document throws an error.

async function run() {
  await mongoose.connection.dropDatabase();

  const mySchema = new mongoose.Schema({
    names: [{ type: String, unique: true }]
  });
  // Make sure to add the plugin
  mySchema.plugin(uniqueArrayPlugin);
  const M = mongoose.model('Test', mySchema);

  // Need to wait for indexes to build, otherwise unique won't work!
  await new Promise((resolve, reject) => {
    M.once('index', err => err ? reject(err) : resolve());
  });

  await M.create([{ names: [] }]);
  const [doc1, doc2] = await Promise.all([M.findOne(), M.findOne()]);

  doc1.names.push('Test');
  await doc1.save();
  // Will print `[]`, doc2.names is empty even though doc1.names is not
  console.log('Before push', doc2.names);
  doc2.names.push('Test');
  // This will throw an error:
  // VersionError: No matching document found for id "591f5f7301c3d066b57abe0a"
  await doc2.save();

  console.log(await M.findById(doc1._id));
}

run().catch(error => console.error(error));

With debug mode on, you can see the query that mongoose uses to update doc2. Notice that this query only pushes if every element of names is not equal to 'Test'. Check out the docs on MongoDB's $nin operator for more information.

Mongoose: tests.update({ _id: ObjectId("591f5f93b6966a66c59355fc"), names: { '$nin': [ 'Test' ] } }, { '$pushAll': { names: [ 'Test' ] }, '$inc': { __v: 1 } })

The mongoose-unique-array plugin only affects save(). If you use update validation, mongoose-unique-array will also help you if you $set an array. However, mongoose-unique-array does not help you if you use update() and $push.

Moving On

Mongoose 4.10 has 8 powerful new features and several small bug fixes, including aliasing and the ability to modify function parameters in pre hooks. Look forward to more articles on these new features, and make sure you upgrade to take advantage of these new features.