Mastering Mongoose
Common misconception is that mongoose is a thin abstraction on Mongo driver but it's more powerful than we had thought.
On July 11, I decided to read the Mongoose documentation after using it for more than 2 years. I don’t know why it took me so long, but here I am. I summarise a 7+ hour read and give you the things you need to know.
My misconception was that mongoose is a very thin abstraction on mongo driver. I had no idea it involved so much. I had trouble navigating the doc every time I encountered a problem and had to check the doc. Now that I’ve gotten through it, I understand how powerful, simple, and crucial it is to use Mongoose properly.
The document assumes you understand the fundamentals of mongoose. This document does not explain the basics, like models, documents, virtuals, and schema. This document is for those who don't want to read the entire Mongoose documentation but still want to use Mongoose more effectively and learn something new.
Let's train under master Mongoose.
Subdocuments
Mongoose allows you to nest schemas into another schema. As defined, it sounds like it should be called "subschemas," but it’s called "subdocuments." Subdocuments ensure that each level of schema can have and enforce its own validations. This is particularly useful for setting a strict rule on nested elements.
const childSchema = new Schema({ name: 'string' });
const parentSchema = new Schema({
child: childSchema
})
Subdocuments are also useful for reusing the validation of a commonly shared property across schemas. For example, you can define an address schema and use it across different schemas while it handles its own validation.
However, be careful, as subdocument defaults are undefined. Using the defaultkey, you can set defaults in subdocuments.
const parentSchema = new mongoose.Schema({
child: {
type: subDocumentSchema,
default: () => ({})
}
});
Also, subdocuments have mongoose _id
by default, but you can disable it. It should be disabled only for subdocuments and not for documents. With documents, it gets complicated. Basically, Mongoose won’t accept a document that doesn’t have an _id
, so you'll be responsible for setting _id
if you define your _id
path.
const subdocumentSchema = new Schema({ name: String }, { _id: false });
Methods and Statics
To understand methods and statics, let’s use the OO paradigm. Methods are functions you execute on instances of a class (objects), while statics are methods of the class itself, independent of the object. In mongoose, the same applies: methods are functions on a document, whereas statics are functions on a model.
const productSchema = new Schema({ name: String, type: String });
const Product = mongoose.model('Product', productSchema);
// creates a Product collection
Method can be defined
productSchema.methods.findSimilarProducts = function() {
return Product.find({ type: this.type });
};
const product = await Product.create({ type: 'shoes', name: 'Nike jordan' });
const similarProducts = await product.findSimilarProducts();
// notice findSimilarProducts is executed on the newly created product (a document)
Static can be defined as
productSchema.statics.findProducts = function search (name, cb) {
return this.where({name};
}
const products = await Product.findProducts()
// notice findProducts is executed on the Product model
Do not use arrow function when defining statics and methods as this
will refer to something else. You can read up on this on MDN to understand what this
refers to.
Inbuilt Methods
save
const product = await Product.findOne({});
Object.assign(product,{name:'Forum mid'});
// or
product.name = 'Forum mid';
await product.save();
overwrite overwrites the values of a document and unsets all other properties of this object except for immutable properties.
const doc = await Product.findById(id);
// Sets `name` and unsets all other properties e.g type
doc.overwrite({ name: 'Nike jordan 1 High' });
await doc.save();
Id
id and _id are unique identifiers for each document in a collection. I made the mistake to think that they are the same but that’s not the case.
The difference between mongoose _id
and id
Lean
Lean is a technique used to optimize your query results. Query results are originally retrieved as Plain Old Javascript Objects (POJOs). However, Mongoose performs an operation on query requests to convert the results from POJOs to Mongoose documents. Using lean means that mongoose will skip that operation.
const products = await Product.findOne({}).lean();
Lean results are efficient and lighter. In some cases, they are 10 times smaller than Mongoose documents. You should use this when you want faster and less memory-intensive queries.
Although a lean result doesn’t have
- casting,
- validation,
- virtual,
- save and
- change tracking.
Plugins exist to solve some of these problems, like mongoose-lean-virtuals enables virtuals in lean query results.
Populate
Populate tells Mongoose to replace an identifier with data from another collection. This is particularly helpful when you need to get data from another collection in relation to the current document.
The identifier can be of type ObjectId
, Number
, String
, and Buffer
. Although mongoose advise against using Number
, String
or Buffer
as ref identifier unless you’re an advance user with very good reasons for it.
const vendorSchema = new Schema({ firstname: String, lastname:String });
const Vendor = mongoose.model('Vendor', productSchema);
const productSchema = new Schema({
name: String,
type: String,
vendor: { type: Schema.Types.ObjectId, ref:'Vendor'} // add a ref to the collection the id should be populated from
});
const Product = mongoose.model('Product', productSchema);
We need to create a vendor and a product so that we can add the vendor._id
to the product.
const vendor = await Vendor.create({firstname:'Locksi',lastname:'Desmond'});
const product = await Product.create({
type: 'shoes',
name: 'Nike jordan',
vendor:vendor._id
});
now we populate
const product = await Product.findOne({type:'shoes'}).populate('vendor').exec();
console.log(product.vendors)
// { firstname:'Locksi', lastname:'Desmond'}
some pro commands
// To check if a path is populated
// instead of
if( product.vendor.firstname === undefined )
// use
if( product.populated('vendor') ) // -> truthy
// or
if ( product.$isEmpty('vendor.firstname'));
// to make **vendor** path no longer populated
product.depopulate('vendor')
There’s more you can do with populate, like using match
and select
. Read more on mongoose official documentation.
Ref-path
It’s for dynamic referencing in populate. This is a feature I didn’t know of until recently. It’s important that you check it out.
Discriminators
Discriminators allow a collection to have different models with overlapping schemas. It allows schemas to inherit from another schema. Think of it like OOP class inheritance at the database level.
Say our products have third-party sellers from another website, and we intend to store the data in the product collection while adding new information to the product. The discriminator extends the product schema to a new schema that stores its value in the same collection as the product.
We can use discriminators to inherit the default product schema and create a new schema for third-party sellers that validates and saves their products.
const options = {discriminatorKey:'kind'};
const productSchema = new Schema({ name: String, type: String }, options);//notice the option is added here
const Product = mongoose.model('Product', productSchema);
const ThirdPartyProduct = Event.discriminator('ThirdPartyProduct',
new mongoose.Schema({ originWebsite: String, }, options))
// if we mistakenly pass originWebsite to our product model it won't be stored
const product = await Product.create({
type: 'shoes',
name: 'Nike jordan',
originWebsite:'locksi.hashnode.dev'
});
console.log(product.originWebsite) // undefined
// while thirdpartyProduct can
const thirdPartyProduct = await ThirdPartyProduct.create({
type: 'shoes',
name: 'Nike jordan',
originWebsite:'locksi.hashnode.dev'
});
console.log(thirdPartyProduct.originWebsite) // locksi.hashnode.dev
// Remember that thirdPartyProduct and Product save in the Product collection and that's
// possible because of discriminator and schemaless database (Mongo db).
Special mention
- Mongoose converts yes/no to boolean values.
- The unique option on a schema is not a validation.
- QueryHelper is a type of mongoose method.
- orFail to handle not found on empty query response.
const product = await Product.findOne({}).orFail(()=> new Error('Product not found'))
There’s so much productivity and effectiveness you can get from learning to use any product properly. I’ve made it an habit to read docs every now and then. In this article, we covered the use of Mongoose subdocuments, methods, discriminator, populate, and lean. Proper use of this knowledge will make you a 1.5x dev and improve the quality of your codebase.
Keep learning ! LD