- 1. TypeScript Express tutorial #1. Middleware, routing, and controllers
- 2. TypeScript Express tutorial #2. MongoDB, models and environment variables
- 3. TypeScript Express tutorial #3. Error handling and validating incoming data
- 4. TypeScript Express tutorial #4. Registering users and authenticating with JWT
- 5. TypeScript Express tutorial #5. MongoDB relationships between documents
- 6. TypeScript Express tutorial #6. Basic data processing with MongoDB aggregation
- 7. TypeScript Express tutorial #7. Relational databases with Postgres and TypeORM
- 8. TypeScript Express tutorial #8. Types of relationships with Postgres and TypeORM
- 9. TypeScript Express tutorial #9. The basics of migrations using TypeORM and Postgres
- 10. TypeScript Express tutorial #10. Testing Express applications
- 11. TypeScript Express tutorial #11. Node.js Two-Factor Authentication
- 12. TypeScript Express tutorial #12. Creating a CI/CD pipeline with Travis and Heroku
- 13. TypeScript Express tutorial #13. Using Mongoose virtuals to populate documents
- 14. TypeScript Express tutorial #14. Code optimization with Mongoose Lean Queries
- 15. TypeScript Express tutorial #15. Using PUT vs PATCH in MongoDB with Mongoose
Mongoose does quite a bit of heavy-lifting for us. It is immensely useful, but not necessary in every case. In this article, we explore the Mongoose documents more and learn what we can achieve by giving up their benefits.
Mongoose Document
In the second part of this series, we’ve created our first models. They allow us to interact with our collections.
The most straightforward way of doing so is getting all the documents from our collection.
1 |
const posts = await postModel.find(); |
The above gives us an array of documents. Putting it simply, a document is an instance of a model.
It is essential to say that MongoDB is a document database, and we can call each record in the collection, a document. They are similar to JSON objects.
By default, Mongoose gives us more than just bare objects. Instead, it wraps them in Mongoose Documents. It gives us lots of features that might come in handy. For example, we have the set() and save() functions.
1 2 |
const post = await postModel.findById(postId); await post.set('content', 'A brand new content!').save(); |
The above might come in handy, but it is not always crucial. Mongoose Documents have lots of things happening under the hood, and it needs both more time and memory.
The terms document and Mongoose Document are not interchangeable, and therefore this article aims to be explicit about addressing this precisely
Lean queries
When we perform a regular query to the database, Mongoose performs an action called hydrating. It involves creating an instance of the Mongoose Document using given data.
1 2 3 4 5 |
const post = postModel.hydrate({ author: '5ca3a8d2af06a818d391040c', title: 'Lorem ipsum', content: 'Dolor sit amet' }); |
This process takes time and creates objects that weight quite a bit. To give you a better understanding, let’s investigate this handler:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private getPostById = async ( request: Request, response: Response, next: NextFunction ) => { const id = request.params.id; const post = await this.post.findById(id); if (post) { response.send(post); } else { next(new PostNotFoundException(id)); } } |
- When we call this.post.findById(id), Mongoose queries the database to find our post
- Once Mongoose finds the document, it creates an instance of a Mongoose Document by hydrating the raw data
- When we call response.send(post), Express gets the raw data from the Document instance and sends it in a response
As you can see, there is quite a lot happening in such a simple handler. The most important thing to consider whether the hydrating process is necessary. Let’s rewrite the above handler a bit:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private getPostById = async ( request: Request, response: Response, next: NextFunction ) => { const id = request.params.id; const post = await this.post.findById(id).lean(); if (post) { response.send(post); } else { next(new PostNotFoundException(id)); } } |
In the above code, we don’t perform the hydration, and therefore our handler is faster. Also, if you implement some manual cache, it might be a good idea to perform a lean query. An example of such a solution is the node-cache library.
The downsides of using lean documents
There are multiple things to consider, though. Before deciding to use lean queries, we need to be aware of the disadvantages of doing so.
No change tracking and saving
Instances of the Mongoose Document have quite a bit of functionality under the hood. One of the features is saving changes done to the documents.
1 2 3 |
const post = await postModel.findById(postId).lean(); post.content = 'A brand new content!'; await post.save(); |
Unfortunately, the above code would result in an error because there is no save function.
Also, Mongoose can perform typecasting on the fly.
1 2 |
const post = await postModel.findById(postId); post.content = 123; |
Above, our content gets stringified on the file, because post.content is a setter with additional logic built into it. That isn’t a case with a lean document.
Also, if our Post has proper typings, the above operation should not be permitted
Getters and setters
In the previous part of this series, we learn about getters and setters. For example, we add a getter for the User:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const userSchema = new mongoose.Schema( { email: String, name: String, password: { type: String, get: (): undefined => undefined, }, }, { toJSON: { getters: true, }, }, ); |
By doing the above, we can easily strip out a password from the result of a database query.
Unfortunately, this would not happen with lean queries. The above is a proper example that we need to be aware of that because we could accidentally expose some sensitive data.
Virtuals
In the previous part of the series, we also learn about virtuals. For example, we add them to the user:
1 2 3 |
userSchema.virtual('fullName').get(function () { return `${this.firstName} ${this.lastName}`; }); |
Unfortunately, virtuals are not included when using lean queries.
Default values
When using Mongoose, we can set up default values for our properties. Unfortunately, they are also not included with lean documents. The above can become troublesome if you expect a particular property to always exist in a document.
An example of the above issue are embedded documents that we mention in the fifth part of this series.
1 2 3 4 5 6 7 8 |
import { Schema }rom 'mongoose'; import order from './order.schema'; const customer = Schema({ name: String, email: String, orders: [order] }); |
Even if we added the orders property just recently, Mongoose attaches a missing empty array to the older documents on the fly. Unfortunately, this does not happen with lean queries.
Populate
Thankfully, the populate function works with lean queries without issues. With it, we can effortlessly replace the id with an actual document from the database.
If you want to know more about populate, check out TypeScript Express tutorial #5. MongoDB relationships between documents
1 2 3 4 5 6 |
private getAllPosts = async (request: Request, response: Response) => { const posts = await this.post.find() .lean() .populate('author'); response.send(posts); } |
Above, we can see how lean queries can cause an issue. Unfortunately, the above handler also returns a password because it is a getter. To deal with it, we can explicitly state that we want to strip it out.
1 2 3 |
const posts = await this.post.find() .lean() .populate('author', '-password'); |
In the previous article, we use virtual properties with the populate function. Even though regular virtual properties don’t work with lean queries, populating them is an exception.
1 2 3 4 5 |
userSchema.virtual('posts', { ref: 'Post', localField: '_id', foreignField: 'author', }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private getUserById = async (request: Request, response: Response, next: NextFunction) => { const id = request.params.id; const userQuery = this.user.findById(id).lean(); if (request.query.withPosts === 'true') { userQuery.populate('posts'); } const user = await userQuery; if (user) { response.send(user); } else { next(new UserNotFoundException(id)); } } |
Summary
As seen above, lean queries come with a lot of gotchas and pitfalls. When using them, we can see the extent of work that Mongoose does for us under the hood. We might not need lean queries at all in our API, and it probably should be our focus right away. It might be a better idea to implement them when we notice some areas that are not as performant as we need.
On the other hand, it is beneficial to know what lean queries are just in case we need them. They can serve as just another tool in our toolbox, ready to use when needed. If we decide to go with them, it is important to be aware of what they bring to the table.