<-- /notes<-- /notes/learn-node

Auto-populating Hooks

There's an inconvenience you might encounter with constantly having to populate your ref'd fields, because an ObjectId does you no good. Well, if it's something that you do for every time this model is put into use, you can specify this behavior via an auto-populating hook. These are just normal hooks which invoke the .populate method ahead of time:

// In Review.js, after the reviewSchema declaration

// Define our hook
function autopopulate(next) {
  this.populate("author")
  next()
}

// Automatically populate author before 'find' and 'findOne'
reviewSchema.pre("find", autopopulate)
reviewSchema.pre("findOne", autopopulate)

Now whenever we invoke .find or .findOne, we will get the author data along with the request!


Interpreting Virtual Fields

By default, MongoDB doesn't actually give us the virtual fields when we ask for them. It's hard to describe, but let's say we had the virtual field of isRookie on our User schema. If we logged one of the documents it would do the following:

const user = User.findOne({}) // Get a random user
console.log(user)
/* -> {
  name: ...
  age: ...
  email: ...  
}*/

// NOTE: we don't get the `favColour` field

This can be a bit confusing, but don't worry, our data isn't lost. Instead MongoDB hides it from us unless we explicitly ask for it. So, this would work:

const user = User.findOne({}) // Get a random user
console.log(user.isRookie) // --> 'true'

We can change this behavior by specifying it in our actual schema:

const userSchema = new mongoose.Schema(
  {
    // ...
    // ...
    // ...
  },
  {
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  },
)

Now if we ever invoke our entire user as JSON or as an object, we will see the virtual field, since MongoDB knows to give it to us!

const user = User.findOne({}) // Get a random user
console.log(user)
/* -> {
  name: ...
  age: ...
  email: ...  
  isRookie: true
}*/

Virtual Population

In a lot of cases you're going to have multiple models which contain information about each other, and you're going to need a way to link them. We already know about using the ref attribute to refer to other models within our database, but there's a niftier solution in order to keep our data one directional.

Think about this situation. If you have a bunch of stores in a Store model, and a bunch of reviews for those stores in a Review model, how would you show the review information on the store page? Well, you could just query all the stores and filter by whichever store matches with the store ID, but this could have issues. If later on, you'd like to give the store an overall rating based on these individual review, how would you assign that value to our Store model? Another ref? Well no, because you'd be putting ref's back and forth, and man oh man is that gonna get complicated.

Instead, mongoose has the ability to do Virtual Population. Similar to a normal virtual field, except we can actually we're pre-populating the ref outside of our schema, but before it's ever called. This is done by specifying field name, and the following values:

storeSchema.virtual("reviews", {
  ref: "Review", // Which model to JOIN with
  localField: "_id", // Which field on THIS model
  foreignField: "store", // Which field on THAT model
})

Aggregating Virtuals

Whenever you're aggregating data for complex queries, its better to offload the work to the database. So, we perform the aggregation in the schema file itself, in this case, Store.js. This aggregation is pretty complicated but well commented, so look through and I'll explain afterwards:

storeSchema.statics.getTopStores = function() {
  return this.aggregate([
    // 1. Lookup stores and populate their reviews (essentially create our virtual again)
    {
      $lookup: {
        from: "reviews", // MongoDB makes our Model lowercase and adds an 's', Review -> reviews
        localField: "_id",
        foreignField: "store",
        as: "reviews",
      },
    },
    // 2. Filter for only items with 2 or more reviews
    { $match: { "reviews.1": { $exists: true } } },
    // 3. Create the averageRating field
    {
      /* IF ON MONGODB <3.2
      $project: {
        // We have to specify the fields we want because $project removes all other data 
        photo: "$$ROOT.photo", // $$ROOT -> represents our original document
        name: "$$ROOT.name",
        reviews: "$$ROOT.reviews",
        averageRating: { $avg: "$reviews.rating" } // $ -> represents a field we've just made
      }
      */

      // IF ON MONGODB >3.2, use $addFields -> Note: this returns excess data
      $addFields: {
        averageRating: { $avg: "$reviews.rating" }, // $ -> represents a field we've just made
      },
    },

    // 4. Sort it by our new field, highest first
    {
      $sort: {
        averageRating: -1,
      },
    },
    // 5. Limit to 10 results
    { $limit: 10 },
  ])
}

So the reason I'm calling this section Aggregating Virtuals is because it is a required side-step from the last note, specifically when it comes to creating an aggregate pipeline. We can create the fields with that handy join function because mongoose allows us to, but it isn't baked into MongoDB. We have to manually create the virtual field in our pipeline, so that is specified as step 1.

// 1. Lookup stores and populate their reviews (essentially create our virtual again)
{
  $lookup: {
    from: "reviews", // MongoDB makes our Model lowercase and adds an 's', Review -> reviews
    localField: "_id",
    foreignField: "store",
    as: "reviews"
  }
},

Then we perform a filter to see the data we want, that's step 2:

{ $match: { "reviews.1": { $exists: true } } },

Next, we perform we want to create a new pseudo-field, which we can do via projection. Problem is, that will get rid of the old data. If using MongoDB v3.2 or less, you must manually add it back it, otherwise, you can just use $addFields to specify the new fields. Step 3:

{
  /* IF ON MONGODB <v3.2
  $project: {
    // We have to specify the fields we want because $project removes all other data
    photo: "$$ROOT.photo", // $$ROOT -> represents our original document
    name: "$$ROOT.name",
    reviews: "$$ROOT.reviews",
    averageRating: { $avg: "$reviews.rating" } // $ -> represents a field we've just made
  }
  */

  // IF ON MONGODB >v3.2, use $addFields -> Note: this returns excess data
  $addFields: {
    averageRating: { $avg: "$reviews.rating" } // $ -> represents a field we've just made
  }
},

Lastly we sort and filter:

// 4. Sort it by our new field, highest first
{
  $sort: {
    averageRating: -1
  }
},
// 5. Limit to 10 results
{ $limit: 10 }

That's it. Remember to create these aggregation pipelines within the schema file to offload the work and await times. Use schema.statics.functionName to create them!