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
- Setup
- Review the Starter Code
- The
Movie --< Review
Relationship - Subdocuments
- Define a Schema for Review Subdocuments
- Creating Reviews
- Displaying Reviews
- Essential Questions
- 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...
-
Move into the
mongoose-movies
project we started in the Intro to Mongoose lesson:cd ~/code/mongoose-movies
-
Sync your code with the starter code for this lesson (mandatory):
git reset --hard origin/sync-7-embedding-starter
-
Open the project's folder in VS Code:
code .
-
Open an integrated terminal session:
control + backtick
-
Start the Express server:
nodemon
-
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 thehref
attribute. -
The
moviesCtrl.show
action is using theMovie.findById
method andreq.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 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 theMovie --< 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:
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:
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 incontrollers/reviews.js
- No
views/reviews/new.ejs
template. In fact, in mongoose-movies, there's no need to even create aviews/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:
</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:
#new-form *, #add-review-form * {
...
}
and add this new CSS to the bottom:
#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:
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)
-
Require the reviews controller (yet to be created).
-
Define the route we just identified for creating a review.
Now let's require the new router in 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 /
:
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:
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:
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 -->
...
</form>
<!-- New markup below -->
<% if (movie.reviews.length) { %>
<% } else { %>
<% } %>
There's nothing new between the if..else
, so let's copy/paste it:
</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.
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 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!
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!