Monday, 20 April, 2020 UTC


Summary

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.
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.
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.
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:
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:
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.
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.
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:
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:
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.
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
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.
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.
userSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'author',
});
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.
xkcd.com
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.
The post TypeScript Express tutorial #14. Code optimization with Mongoose Lean Queries appeared first on Marcin Wanago Blog - JavaScript, both frontend and backend.