click here to view as a presentation
Students Will Be Able To:
- Use Referencing to Implement 1:M & M:M Data Relationships
- Explain the Difference Between 1:M & M:M Relationships
- "Populate" Referenced Documents
- Setup
- Review the Starter Code
- Use a Node REPL session to perform CRUD using Mongoose Models
- A New Data Resource: Performers
- Create the
Performer
Model - Referencing Performers in the Movie Model
- Creating Performers
- Associating Movies and Performers
- AAU, when viewing a movie's detail page, I want to see a list of the current cast and add a new performer to the list
- Essential Questions
-
cd
tostarter-code/mongoose-movies
folder within this lesson's folder in the class repo. -
Install the node modules:
$ npm install
-
Open the
mongoose-movies
folder in your code editor. -
Use
nodemon
to start the server. -
Browse to
localhost:3000
-
Today's starter code is the final code from yesterday's Mongoose - Embedding Related Data lesson with a couple of changes...
-
The
cast
property on theMovie
model has been removed and all related forms/views and controller code have been adjusted accordingly. This was done so that in this lesson we can reference performer documents created using aPerformer
Model. -
The movies/show.ejs view shows how you can use EJS to calculate an average rating for a movie.
-
Because of the removal of the
cast
property, we will want to start fresh by deleting the existing movie documents. -
This provides another opportunity to perform CRUD operations in Terminal using a Node REPL session - something that you'll likely need to do when developing an app.
-
Start by opening a terminal session and make sure that you are in the mongoose-movies folder.
-
Start a Node REPL:
$ node >
-
Connect to the MongoDB database:
> require('./config/database') {} > Connected to MongoDB at localhost:27017 // Press enter to return to the prompt
-
Load the
Movie
Model:> const M = require('./models/movie')
We're being concise by using a variable named "M" vs. "Movie"
-
Important: If you make any changes to the Model, you'll have exit Node and start again.
-
Log all movie docs:
> M.find({}, (e, movies) => { ... console.log(movies) ... })
The
find
method returns a Query object that is first logged, followed by the movie docs. Press enter to return to the prompt.
-
Anything that can be done with a Model in the app, can be done in the REPL including all CRUD operations.
-
Next, let's remove all existing movie documents...
-
Here's a way to delete all documents from a collection:
> M.deleteMany({}, (err, result) => console.log(result)) ... > { n: 3, ok: 1, deletedCount: 3 }
-
The empty query object provided as the first argument matches all documents, so all documents were removed.
-
Press
control + C
twice to exit the REPL.
- For future reference, here's a gist that documents how to do what we just did:
Perform CRUD Using Mongoose Models in a Node REPL
-
We are going to implement the following data relationship:
A Movie has many Performers; A Performer has many MoviesMovie >--< Performer
(Many-To-Many) -
But unlike we saw with Reviews (One-To-Many), multiple Movies can reference the same Performer creating a Many-To-Many relationship. Here's a simplified example...
-
A new data resource requires new modules, etc.
-
Create the following for the new Performers resource:
- Model (empty module)
- Router (module exporting a router object)
- Controller (empty module)
- A dedicated folder for its views
-
Require and mount the new router in server.js to the path of
/
.
-
Performers will be stored in their own collection so that their
_id
(ObjectId) can be referenced by numerous movies. -
As you know, models map to collections in Mongoose...
-
We'll review the schema for the
Performer
Model as we type it:const mongoose = require('mongoose'); const Schema = mongoose.Schema; const performerSchema = new Schema({ name: {type: String, required: true, unique: true}, born: Date }, { timestamps: true }); module.exports = mongoose.model('Performer', performerSchema);
-
With the
Performer
Model created, we can now add back thecast
property inMovie
:reviews: [reviewSchema], // don't forget to add a comma above cast: [{type: Schema.Types.ObjectId, ref: 'Performer'}]
-
The property type of
ObjectId
is always used to implement referencing. -
The
ref: 'Performer'
is optional, but allows us to use the magical Mongoose method -populate
.
-
The key difference between a
1:M
and aM:M
relationship:- In a
1:M
relationship, each of the many (child) documents belongs to only one (parent) document. Each time we want add a new relationship - the child document must be created. - In a
M:M
relationship, existing documents are referenced and the same document can be referenced over and over. New documents are created only if it's the first of its kind.
- In a
-
What this means for mongoose-movies is that we only want to create a certain performer once (when they don't exist).
-
So, before a many-to-many relationship can be created between two documents (often called an association), those two documents must first exist.
-
This requires that the app first provide the functionality to create each of the two resources independently of each other.
-
mongoose-movies can already create movies, but now it needs the capability to create performers...
-
Here's the flow we've now followed several times when adding functionality to the app:
-
- Identify the "proper" Route (Method + Path)
-
- Create the UI that will send the request matching that route.
-
- Define the route on the server and map it to the proper controller action (
index
,show
,new
,create
, etc.).
- Define the route on the server and map it to the proper controller action (
-
- Code and export the controller action.
-
res.render
a view in the case of a GET request, orres.redirect
if data was changed.
-
-
We will want a dedicated view for adding a performer, thus creating a performer will require two request/response cycles: One for the
new
action and one for thecreate
action... -
πͺ YOU DO: Reply in Slack with the proper routes (Method & Path) for:
- Displaying a page with a form for entering a performer
- Creating a new performer when the form is submitted
-
We need UI that will send the request to view the form...
-
Let's add a new link in the nav bar in partials/header.js:
<img src="/images/camera.svg"> <!-- new menu link below --> <a href="/performers/new" <%- title === 'Add Performer' ? 'class="active"' : '' %>> ADD PERFORMER</a>
Yup, the same pattern as the other links.
-
Clicking the ADD PERFORMER link is going to send a
GET /performers/new
request - now we need a route to map that HTTP request to code (controller action) in routes/performers.js:const express = require('express'); const router = express.Router(); const performersCtrl = require('../controllers/performers'); router.get('/performers/new', performersCtrl.new); module.exports = router;
-
As usual, the server won't be happy until we create and export that
new
action...
-
We want to try to prevent the users from creating more than one document for a given performer, so we will display a list of existing performers (in a dropdown) and beg our users not to add a performer unless they've verified that the performer does not already exist in the list.
-
The controller action of course will need to provide an array of the exiting performers to be rendered in the dropdown...
-
Inside of controllers/performers.js we go:
const Performer = require('../models/performer'); module.exports = { new: newPerformer }; function newPerformer(req, res) { Performer.find({}, function(err, performers) { res.render('performers/new', { title: 'Add Performer', performers }); }) }
-
We'll need that
new
view that we just rendered:$ touch views/performers/new.ejs
-
The next slide has the markup...
-
Here's the markup for performers/new.ejs:
<%- include('../partials/header') %> <p>Please first ensure that the Performer is not in the dropdown <select> <% performers.forEach(function(p) { %> <option><%= p.name %></option> <% }) %> </select> </p> <form id="add-performer-form" action="/performers" method="POST"> <label>Name:</label> <input type="text" name="name"> <label>Born:</label> <input type="date" name="born"> <input type="submit" value="Add Performer"> </form> <%- include('../partials/footer') %>
-
Find and update in public/stylesheets/style.css:
#new-form *, #add-review-form *, #add-performer-form * { font-size: 20px; ... } ... #add-review-form, #add-performer-form { display: grid; ... } ... #add-review-form input[type="submit"], #add-performer-form input[type="submit"] { width: 10rem; ... }
-
Now for the second request/response cycle to handle the form submission...
-
The
action
&method
on the form look good, we just need to listen to that route. -
πͺ YOU DO: Define the route for the create action
-
In controllers/performers.js:
module.exports = { new: newPerformer, create }; function create(req, res) { // Hack to "fix" date formatting to prevent possible day off by 1 // https://stackoverflow.com/questions/7556591/is-the-javascript-date-object-always-one-day-off const s = req.body.born; req.body.born = `${s.substr(5,2)}-${s.substr(8,2)}-${s.substr(0,4)}`; Performer.create(req.body, function(err, performer) { res.redirect('/performers/new'); }); }
-
Okay, give a whirl and let's fix those typos π
-
Now that we've added the functionality to create performers, we're ready to add the functionality to associate them with movies.
-
But first, a quick refactor...
-
This user story can be accomplished with a quick refactor in the
moviesCtrl.create
action in controllers/movies/js:movie.save(function(err) { if (err) return res.redirect('/movies/new'); // res.redirect('/movies'); res.redirect(`/movies/${movie._id}`); });
-
Don't forget to replace the single-quotes with back-ticks!
-
User story done! Now for some fun!
AAU, when viewing a movie's detail page,
I want to see a list of the current cast and add a new performer to the list
-
Let's ponder what it's going to take to implement this user story:
- In movies/show.ejs, iterate over the movie's cast and use EJS to render them.
- Hold it! Because we are using referencing, there are
ObjectId
s in a movie'scast
array - not subdocs. Oh wait, this is what the magicalpopulate
method is for! - Using a form with a dropdown, we can send a request to associate a performer and movie. We will need the list of performers to build the dropdown, but only the performers not already in the cast!
-
Let's get started!
-
Let's refactor the
moviesCtrl.show
action so that it will pass the movie with the performer documents in itscast
array instead ofObjectIds
:function show(req, res) { Movie.findById(req.params.id) .populate('cast').exec(function(err, movie) { res.render('movies/show', { title: 'Movie Detail', movie }); }); }
-
populate
, the unicorn of Mongoose...
-
We can chain the
populate
method after any query. -
When we "build" queries like this, we need to call the
exec
method to actually run it (passing in the callback to it). -
β How does the
populate
method know to replace theObjectId
s withPerformer
documents?
-
While we're in
moviesCtrl.show
, let's see how we can query for just the performers that are not in the movie'scast
array. -
First, we're going to need to access the
Performer
model, so require it at the top:const Movie = require('../models/movie'); // require the Performer model const Performer = require('../models/performer');
-
Now we're ready to refactor the
show
action...
-
We'll review as we refactor the code:
function show(req, res) { Movie.findById(req.params.id) .populate('cast').exec(function(err, movie) { // Performer.find({}).where('_id').nin(movie.cast) Performer.find( {_id: {$nin: movie.cast}}, function(err, performers) { console.log(performers); res.render('movies/show', { title: 'Movie Detail', movie, performers }); } ); }); }
The log will show we are retrieving the performers - a good sign at this point.
-
The next slide has some refactored markup in movies/show.ejs.
-
It's a bit complex, so we'll review it while we make the changes.
-
We'll have to be careful though...
<div><%= movie.nowShowing ? 'Yes' : 'Nope' %></div>
<!-- start cast list -->
<div>Cast:</div>
<ul>
<%- movie.cast.map(p =>
`<li>${p.name} <small>${p.born.toLocaleDateString()}</small></li>`
).join('') %>
</ul>
<!-- end cast list -->
</section>
<!-- add to cast form below -->
<form id="add-per-to-cast" action="/movies/<%= movie._id%>/performers" method="POST">
<select name="performerId">
<%- performers.map(p =>
`<option value="${p._id}">${p.name}</option>`
).join('') %>
</select>
<button type="submit">Add to Cast</button>
</form>
-
Add this tidbit of CSS to clean up the cast list:
ul { margin: 0 0 1rem; padding: 0; list-style: none; } li { font-weight: bold; }
-
The route is RESTful, but we have to use a non-RESTful name for the controller action because we're creating an association between a movie and a performer...
-
In routes/performers.js
router.post('/movies/:id/performers', performersCtrl.addToCast);
addToCast
- not a bad name, but you can use a different one if you want to
-
Let's write that
addToCast
action in controllers/performers.js:const Performer = require('../models/performer'); // add the Movie model const Movie = require('../models/movie'); module.exports = { new: newPerformer, create, addToCast }; function addToCast(req, res) { Movie.findById(req.params.id, function(err, movie) { movie.cast.push(req.body.performerId); movie.save(function(err) { res.redirect(`/movies/${movie._id}`); }); }); }
-
That was fun!
-
A few questions, then on to the lab!
Take a couple of minutes to review...
-
What property type is used in schemas to reference other documents?
-
Describe the difference between 1:M & M:M relationships.
-
What's the name of the method used to replace an
ObjectId
with the document it references?