Skip to main content

Mongoose Embedding Related Data

Learning Objectives

Students Will Be Able To:
Define Schemas for Embedding Subdocuments
Embed a Subdocument in its Related Document

Road Map

  1. Setup
  2. Review the Starter Code
  3. The Movie --< Review Relationship
  4. Subdocuments
  5. Define a Schema for Review Subdocuments
  6. Creating Reviews
  7. Displaying Reviews
  8. Essential Questions
  9. Further Study
    • Retrieve a Subdocument from a Mongoose Array
    • Remove a Subdocument from a Mongoose Array
    • Query for a Document that Contains a Certain Subdocument
SKIP Setup

1. Setup

The starter code for this lesson has been updated from where we left off last lesson, so be sure to follow the following to sync your code...

  1. Move into the mongoose-movies project we started in the Intro to Mongoose lesson:

    cd ~/code/mongoose-movies
  2. Sync your code with the starter code for this lesson (mandatory):

    git reset --hard origin/sync-7-embedding-starter
  3. Open the project's folder in VS Code:

    code .
  4. Open an integrated terminal session:

    control + backtick
  5. Start the Express server:

    nodemon
  6. Browse to localhost:3000

2. Review the Starter Code

As you can see, a bit of styling has been added since the previous lesson.

There's no more "Home" page - the root route defined in routes/index.js is redirecting to /movies (movies index functionality).

All res.render() calls are passing in a title property for the page title and to dynamically add an active class to the currently active link in the nav bar.

Like we did in the express-todos app, show functionality has been added using a show route definition that maps to a show controller action for viewing a single movie:

  • views/movies/index.ejs shows a "DETAILS" link that will send a request to the proper show route: GET /movies/:id.

  • EJS tags write the movie's _id into the href attribute.

  • The moviesCtrl.show action is using the Movie.findById method and req.params.id.

3. The Movie --< Review Relationship

Movie --< Review

  • A Movie has many Reviews
  • A Review belongs to a Movie

It may be helpful to think of one-to-many relationships as a parent/child relationship.

Embed or Reference Reviews?

If mongoose-movies was using a SQL Database there would have to be both a Movie and a Review model used to perform CRUD data operations in a "movies" and "reviews" table.

However, when implementing related data using MongoDB/Mongoose, we usually have a choice between embedding and referencing...

Referencing Reviews

Referencing allows us to "link" related documents by storing one of the document's ObjectId in the other document.

Here's what using referencing to relate a movie and its reviews might look like:

// A movie document stored
// in the movies collection
{
nowShowing: false,
_id: new ObjectId("633e094fa9ba1e2600e6c9f6"),
title: 'Star Wars - A New Hope',
releaseYear: 1977,
cast: ['Mark Hamill', 'Carrie Fisher'],
createdAt: 2021-10-05T22:46:39.860Z,
updatedAt: 2021-10-05T22:46:39.860Z,
__v: 0
}

and its reviews might look something like this:

// A review document stored
// in the reviews collection
{
_id: new ObjectId("605d17435dc2532a429a17d0"),
// Store/reference the ObjectId
// of the movie doc that it belongs to
movie: new ObjectId("633e094fa9ba1e2600e6c9f6"),
content: 'Still my favorite!',
rating: 5,
createdAt: 2022-11-03T13:00:44.130Z,
updatedAt: 2022-11-03T13:00:44.130Z,
}
// Another review document
{
_id: new ObjectId("605d17435dc2532a429a17d0"),
movie: new ObjectId("633e094fa9ba1e2600e6c9f6"),
content: 'Entertaining...',
rating: 4,
createdAt: 2022-04-01T11:20:44.130Z,
updatedAt: 2022-04-01T11:20:44.130Z,
}
note

Note above how each review document has a movie property that holds the ObjectId of the movie document that it "belongs to".

When using referencing, typically the two types of documents, in this case review and movie documents, are stored in separate collections using separate models.

Because only a single collection can be queried at a time, referencing requires two separate database queries to access both a movie and its reviews.

This is the reason embedding is considerably more efficient...

Embedding Reviews

In MongoDB/Mongoose, one-to-many relationships can be implemented using embedding.

Embedding is when a child subdocument (review) is embedded within its parent document (movie).

For example:

{
nowShowing: false,
_id: new ObjectId("633e094fa9ba1e2600e6c9f6"),
title: 'Star Wars - A New Hope',
releaseYear: 1977,
cast: ['Mark Hamill', 'Carrie Fisher'],
reviews: [
{
_id: new ObjectId("605d17435dc2532a429a17d0"),
content: 'Still my favorite!',
rating: 5,
createdAt: 2022-11-03T13:00:44.130Z,
updatedAt: 2022-11-03T13:00:44.130Z,
},
{
_id: new ObjectId("605d17435dc2532a429a17d0"),
content: 'Entertaining...',
rating: 4,
createdAt: 2022-04-01T11:20:44.130Z,
updatedAt: 2022-04-01T11:20:44.130Z,
},
],
createdAt: 2021-10-05T22:46:39.860Z,
updatedAt: 2021-10-05T22:46:39.860Z,
__v: 0
}

This Movie --< Review relationship is a perfect use case for using MongoDB/Mongoose embedding!

ERD for mongoose-movies

Here's what an ERD that models the
Movie --< Review
relationship might look like:

Subdocuments

When we embed related data, we refer to that embedded data as a subdocument.

Subdocuments are very similar to regular documents.

The key difference being that they themselves are not saved directly to the database - they are saved when the document they are embedded within is saved.

Subdocuments still require that a schema be defined so that the data can be validated and/or the application can depend upon the "shape" of its data.

However, because subdocuments are not saved to their own collection, there is no model, just a schema!

5. Define a Schema for Review Subdocuments

Okay, so we need to define a reviewSchema.

Only the movieSchema code needs to reference reviewSchema, so a great place to define it is just above the movieSchema in models/movie.js:

models/movie.js
const reviewSchema = new Schema({
content: {
type: String,
required: true
},
rating: {
type: Number,
min: 1,
max: 5,
default: 5
}
}, {
timestamps: true
});

const movieSchema = new Schema({

With reviewSchema defined, we can now use it within the movieSchema as follows:

models/movie.js
const movieSchema = new Schema({
...
nowShowing: { type: Boolean, default: false },
// reviews is an array of review subdocs!
reviews: [reviewSchema]
}, {
timestamps: true
});

👀 Do you need to sync your code?


git reset --hard origin/sync-8-review-schema


6. Creating Reviews

Here's the User Story:

AAU, when viewing the detail page for a movie, I want to see a form for adding a new review

Since we will be displaying the form for creating a new review on each movie's detail page (show.ejs), we won't need to implement new functionality for the reviews resource, thus:

  • No route in routes/reviews.js for showing a page with a form.
  • No new controller action in controllers/reviews.js
  • No views/reviews/new.ejs template. In fact, in mongoose-movies, there's no need to even create a views/reviews folder.

Cool, so there's no new functionality code for reviews, but we certainly need to implement the create functionality.

Let's get coding!

Step 1 - Determine the Proper Route

Routing for a related/nested resource can be a bit different because, in some cases, we need to "inform" the server of the nested resource's parent resource.

Let's review the Routing for Nested Resources section of our Routing Guide.

Using the chart, we find that the proper route for creating a review is:

POST /movies/:id/reviews

and we will be sending the following JSON as the body of the POST:

{ 
"content": "Good Show!",
"rating": 4
}

Note how the path of the route will provide the server with the _id of the movie that the review is being created for!

SKIP creating the UI

Step 2 - Create the UI that Sends the Request

❓ What UI did we use to create a To Do?


A <form> element


Cool, so let's add the form to movies/show.ejs right under the current </section> tag:

views/movies/show.ejs
</section>
<!-- new markup below -->
<br><br><h2>Reviews</h2>
<form id="add-review-form" method="POST"
action="/movies/<%= movie._id %>/reviews">
<label>Review:</label>
<textarea name="content"></textarea>
<label>Rating:</label>
<select name="rating">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5" selected>5</option>
</select>
<input type="submit" value="Add Review">
</form>

Nothing new above, but be sure to review how the value for the action is being written.

A touch of styling. Update this existing CSS rule on line 69 of public/stylesheets/style.css:

public/stylesheets/style.css
#new-form *, #add-review-form * {
...
}

and add this new CSS to the bottom:

public/stylesheets/style.css
#add-review-form {
display: grid;
grid-template-columns: auto auto;
gap: 1rem;
}

#add-review-form input[type="submit"] {
width: 8rem;
grid-column: 2 / 3;
margin-bottom: 2rem;
}

Browse to the "details" of a movie.

Yeah, not exactly gorgeous but the form's action attribute looks pretty sweet!

Step 3 - Define the Route on the Server

As a best practice, let's create a dedicated router module for the reviews resource:

touch routes/reviews.js

and start with the typical router boilerplate:

routes/reviews.js
const express = require('express');
const router = express.Router();
// You Do - Require the yet to be created reviews controller


// You Do - Define the Route below


module.exports = router;

👉 You Do - Finish the Router Code Above (1 min)

  1. Require the reviews controller (yet to be created).

  2. Define the route we just identified for creating a review.


Now let's require the new router in server.js:

server.js
const moviesRouter = require('./routes/movies');
// new reviews router
const reviewsRouter = require('./routes/reviews');

Before we mount the new router in server.js, let's take another look at the paths in Routing for Nested Resources section of our Routing Guide.

Notice how some paths need to start with the parent resource (posts) and others with the nested resource comments?

Because the "starts with" path varies, we won't be able to mount the reviews router in server.js to any particular path.

Instead, to achieve the flexibility we need, we have to mount to a starts with path of /:

server.js
app.use('/movies', moviesRouter);
// Mount the reviews router to root because not all
// paths for a nested resource begin the same
app.use('/', reviewsRouter);

As you know, the server won't be happy until we create and export that create action from the controller...

Step 4 - Create and Code the Controller Action

Let's create the controller module:

touch controllers/reviews.js

Open the new controllers/reviews.js and let the coding commence:

controllers/reviews.js
const Movie = require('../models/movie');

module.exports = {
create
};

Note that although we don't have a Review model thanks to using embedding, we will certainly need to require the Movie model because we will need it to read and update the movie document that we're adding a review to.

Let's write the create function:

controllers/reviews.js
async function create(req, res) {
const movie = await Movie.findById(req.params.id);
// We can push (or unshift) subdocs into Mongoose arrays
movie.reviews.push(req.body);
try {
// Save any changes made to the movie doc
await movie.save();
} catch (err) {
// console.log(err); - SKIP old code
res.status(500).json({ err })
}
// Step 5: Respond to the Request (redirect if data has been changed)
// res.redirect(`/movies/${movie._id}`); - SKIP old code
res.status(201).json(movie)
}

Yes, we simply push in an object that's compatible with the embedded document's schema, call save on the parent doc, and redirect to wherever makes sense for the app.

Before we start adding reviews, let's update the show.ejs template to render a movie's reviews...

👀 Do you need to sync your code?


git reset --hard origin/sync-9-create-review


SKIP displaying reviews

7. Displaying Reviews

Here's the User Story:

AAU, when viewing the detail page for a movie, I want to see a list of the movie's reviews

Displaying a Movie's Reviews

All that's left is to update movies/show.ejs to render the movie's reviews.

The only thing new below is the use of a if..else to render differently if there's no reviews yet.

Let's stub up that if..else so that we get some practice using EJS:

views/movies/show.ejs
<!-- views/movies/show.ejs -->

...

</form>

<!-- New markup below -->

<% if (movie.reviews.length) { %>

<% } else { %>

<% } %>

There's nothing new between the if..else, so let's copy/paste it:

views/movies/show.ejs
</form>

<!-- New markup below -->

<% if (movie.reviews.length) { %>
<table>
<thead>
<tr>
<th>Date</th>
<th>Review</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<% movie.reviews.forEach(function(r) { %>
<tr>
<!-- .toLocaleDateString() provides a more friendly date format -->
<td><%= r.createdAt.toLocaleDateString() %></td>
<td><%= r.content %></td>
<td><%= r.rating %></td>
</tr>
<% }); %>
</tbody>
</table>
<% } else { %>
<h5>No Reviews Yet</h5>
<% } %>

Note how we access the reviews array using movie.reviews.

Try Adding Reviews

Assuming no typos, you should be able to add reviews - congrats!

Let's wrap up with some essential questions before you start on the lab to practice this stuff!

Also, don't forget to check out the
Further Study section which shows you how to:

  • Retrieve a subdocument embedded in a Mongoose array
  • Remove a subdocument from a Mongoose array, and
  • Query for a document that contains a certain subdocument!

👀 Do you need to sync your code?


git reset --hard origin/sync-10-finish-embedding


8. ❓ Essential Questions

(1) True or False: All schemas must be compiled into a Model.


False. We just used reviewSchema for the purpose of embedding reviews, however, we never compiled it into a model.


(2) Is it more efficient to embed or reference related data?


Embed because we can retrieve a document and its related data in a single query.


(3) True or False: An embedded subdocument must have its save() method called to be persisted to the database.


False. Tricky question because embedded subdocs are saved when the top-level document they are embedded within is saved.


9. Further Study

Retrieve a Subdocument from a Mongoose Array

Mongoose arrays have an id() method used to find a subdocument based on the subdoc's _id:

// Assume reviewId is an ObjectId or its string representation
const reviewDoc = movieDoc.reviews.id(reviewId);
note

Note that reviewId would represent the _id of the embedded review subdoc, not the movie doc.

Remove a Subdocument from a Mongoose Array

Mongoose arrays have a remove() method that can be used to remove subdocuments by their _id:

movieDoc.reviews.remove(reviewId);

Subdocuments themselves use their deleteOne() method to remove them from the array:

// Remove the embedded review subdoc that has an `_id` equal to `reviewId`
movieDoc.reviews.id(reviewId).deleteOne();
// Don't forget to save `movieDoc` to persist the deletion!

Query for a Document that Contains a Certain Subdocument

There's an amazing syntax that you can use to query documents based upon the properties of subdocs.

// Find the movie that contains a certain review
const movie = await Movie.findOne({ 'reviews._id': req.params.reviewId });
// Wow, `movie` will be the doc that contains the review with an `_id`
// that equals that of the `req.params.reviewId` route parameter!
caution

Note that the dot property syntax must be enclosed in quotes.

For another example, let's say you wanted to find all movies with at least one review with a 5 rating:

const highlyRatedMovies = await Movie.find({ 'reviews.rating': 5 });
console.log('Great Movies:', highlyRatedMovies); // wow!

reviews.rating represents the rating property on the subdocs withing the reviews array!

References