This workshop is important because:
-
Real-world data usually consists of different types of things that are related to each other in some way. An invoicing app might need to track employees, customers, and accounts. A food ordering app needs to know about restaurants, menus, and its users!
-
We've seen that when data is very simple, we can combine it all into one model. When data is more complex or more loosely related, we often create two or more related models.
-
Understanding how to plan for, set up, and use related data will help us build more full-featured applications.
After this workshop, developers will be able to:
- Diagram one-to-one, one-to-many, and many-to-many data associations.
- Compare and contrast embedded & referenced data.
- Design nested server routes for associated resources.
- Build effective Mongoose queries for associated resources.
Before this workshop, developers should already be able to:
- Use Mongoose to code Schemas and Models for single resources.
- Create, Read, Update, and Delete data with Mongoose.
Each person has one brain, and each (living human) brain belongs to one person.
One-to-one relationships can sometimes just be modeled with simple attributes. A person and a brain are both complex enough that we might want to have their data in different models, with lots of different attributes on each.
Each leaf "belongs to" the one tree it grew from, and each tree "has many" leaves.
Each student "has many" classes they attend, and each class "has many" students.
Entity relationship diagrams (ERDs) represent information about the numerical relationships between data, or entities.
Note: In the example above, all of the Item1, Item2, Item3 under each heading are standing in for attributes.
Come up with an example of related data. Draw the ERD for your relationship, including a few attributes for each model.
Embedded Data is directly nested inside of other data. Each record has a copy of the data.
It is often efficient to embed data because you don't have to make a separate request or a separate database query -- the first request or query gets you all the information you need.
Referenced Data is stored as an id inside other data. The id can be used to look up the information. All records that reference the same data look up the same copy.
It is usually easier to keep referenced records consistent because the data is only stored in one place and only needs to be updated in one place.
While the question of one-to-one, one-to-many, or many-to-many is often determined by real-world characteristics of a relationship, the decision to embed or reference data is a design decision.
There are tradeoffs, such as between efficiency and consistency, depending on which one you choose.
When using Mongo and Mongoose, though, many-to-many relationships often involve referenced associations, while one-to-many often involve embedding data.
How would you design the following? Draw an ERD for each set of related data? Can you draw an ERD for each?
User
s with manyTweets
?Food
s with manyIngredients
?
var ingredientSchema = new Schema({
title: {
type: String,
default: ""
},
origin: {
type: String,
default: ""
}
});
var foodSchema = new Schema({
name: {
type: String,
default: ""
},
ingredients: [{
type: Schema.Types.ObjectId, //REFERENCING :D
ref: 'Ingredient'
}]
});
Check out the value associated with the ingredients
key inside the food schema. Here's how it's set up as an array of referenced ingredients:
[]
lets the food schema know that each food'singredients
attribute will hold an array.- The object inside the
[]
describes what kind of elements the array will hold. - Giving
type: Schema.Types.ObjectId
tells the schema theingredients
array will hold ObjectIds. That's the type of that unique_id
that Mongo automatically generates for us (something like55e4ce4ae83df339ba2478c6
). ref: Ingredient
tells the schema we will only be putting ObjectIds ofIngredient
documents inside theingredients
array.
Once schemas are defined, we can compile them all into active models so we can start creating documents!
/* Compiling models from the above schemas */
var Food = mongoose.model('Food', foodSchema);
var Ingredient = mongoose.model('Ingredient', ingredientSchema);
Here's how we'd take our models for a spin and make two objects to test out creating an Ingredient document and a Food document.
/* make a new Ingredient document */
var cheddar = new db.Ingredient ({
title: 'cheddar cheese',
origin: 'Wisconsin'
});
/* make a new Food document */
var cheesyQuiche = new db.Food ({
name: 'Quiche',
ingredients: []
});
Don't forget to save your work!
cheddar.save(function(err, savedCheese) {
if (err) {
return console.log(err);
} else {
console.log('cheddar saved successfully');
}
});
cheesyQuiche.ingredients.push(cheddar); // associated!
cheesyQuiche.save(function(err, savedCheesyQuiche) {
if (err) {
return console.log(err);
} else {
console.log('cheesyQuiche food is ', savedCheesyQuiche);
}
});
Note that we push the cheddar
ingredient document into the cheesyQuiche
ingredients array. We already told the Food Schema that we will only be storing ObjectIds, though, so cheddar
gets converted to its unique _id
when it's pushed in!
This is the log text after executing the code we've written thus far:
cheesyQuiche food is { __v: 0,
name: 'Quiche',
_id: 55e4eb857d6157f4d41a2981,
ingredients: [ 55e4eb857d6157f4d41a2980 ] }
cheesy quiche saved successfully
What are we looking at?
click for line-by-line explanation
-
Line 1:
__v
represents the number of times the document has been accessed. -
Line 2: The
name
property of theFood
document we have created. -
Line 4: The unique
_id
created by Mongo for ourFood
document. -
Line 5: The
ingredients
array, with a singleObjectId
that is associated with ourIngredient
document.
Mongoose is happy to show just the ObjectId
associated with each ingredient in the food's ingredients
array. When we need the Ingredient
document data, we have to ask for it explicitly.
When we want to get full information from an Ingredient
document we have inside the Food
document ingredients
array, we use a method called .populate()
.
db.Food.findOne({ name: 'Quiche' })
.populate('ingredients') // <- pull in ingredient data
.exec(function(err, food) {
if (err){
console.log(err);
}
if (food.ingredients.length > 0) {
console.log('/nI love ' + food.name + ' for the '+ food.ingredients[0].title);
}
else {
console.log(food.name + ' has no ingredients.');
}
console.log('what was that food?', food);
});
Click to go over this method call line by line:
-
Line 1: We call a method to find only one
Food
document that matches the name:Quiche
. -
Line 2: We ask the ingredients array within that
Food
document to fetch the actualIngredient
document instead of just itsObjectId
. -
Line 3: When we use
find
without a callback, thenpopulate
, like here, we can put a callback inside an.exec()
method call. Technically we have made a query withfind
, but only executed it when we call.exec()
. -
Lines 4-15: If we have any errors, we will log them. Otherwise, we can display the entire
Food
document including the populatedingredients
array. -
Line 9 demonstrates that we are able to access both data from the original
Food
document we found and the referencedIngredient
document we summoned.
Click to see the output from the above findOne()
method call with populate
.
{
_id: 55e4eb857d6157f4d41a2981,
name: 'Quiche',
__v: 1,
ingredients: [
{
_id: 55e4eb857d6157f4d41a2980,
title: 'cheddar cheese',
origin: 'Wisconson',
__v: 0
}
]
}
I love Quiche for the cheddar cheese
Now, instead of seeing only the ObjectId
that pointed us to the Ingredient
document, we can see the entire Ingredient
document.
Get it:
- fork and clone this repo
- start up mongoDB with
mongod
cd
into the folderstarter-code
in this directorynpm install
to install all the dependencies frompackage.json
node console.js
to enter into a REPL where you can interact with your DB. All the models will be nested inside an object calleddb
.
Tips:
- save your successful code into a file for each step
<command>
+<up>
will bring you to the last thing you entered in the repl
Tasks:
- Create 3 ingredients.
- Create a food that references those ingredients.
- List all the foods.
- List all the ingredient data for a food.
When you need full information about a food, remember to pull ingredient data in with populate
. Here's an example:
index of all foods
// send all information for all foods
app.get('/api/foods/', function (req, res) {
Food.find({ })
.populate('ingredients')
.exec(function(err, foods) {
if (err) {
res.status(500).send(err);
return;
}
console.log('found and populated all foods: ', foods);
res.json(foods);
});
});
Many APIs don't populate all referenced information before sending a response. For instance, the Spotify API is riddled with ids that developers can use to make a second request if they want more of the information.
On which of the following routes are you most likely to populate
all the ingredients of a food you look up?
HTTP Verb | Path | Description |
GET | /foods | Get all foods |
POST | /foods | Create a food |
GET | /foods/:id | Get a food |
DELETE | /foods/:id | Delete a food |
GET | /foods/:food_id/ingredients | Get all ingredients from a food |
Imagine you have a database of User
s, each with many embedded Tweet
s. If you needed to update or delete a tweet, you would first need to find the correct user, then the tweet to update or delete.
var tweetSchema = new Schema({
text: String,
date: Date
});
var userSchema = new Schema({
name: String,
// embed tweets in user
tweets: [tweetSchema]
});
The tweets: [tweetSchema]
line sets up the embedded data association. The []
tells the schema to expect a collection, and tweetSchema
(or Tweet.schema
if you had a Tweet
model defined already) tells the schema that the collection will hold embedded documents of type Tweet
.
var User = mongoose.model("User", userSchema);
var Tweet = mongoose.model("Tweet", tweetSchema);
-
Create a user.
-
Create tweets embedded in that user.
-
List all the users.
-
List all tweets of a specific user.
create tweet
// create tweet embedded in user
app.post('/api/users/:userId/tweets', function (req, res) {
// set the value of the user id
var userId = req.params.userId;
// store new tweet in memory with data from request body
var newTweet = new Tweet(req.body.tweet);
// find user in db by id and add new tweet
User.findOne({_id: userId}, function (err, foundUser) {
foundUser.tweets.push(newTweet);
foundUser.save(function (err, savedUser) {
res.json(newTweet);
});
});
});
update tweet
// update tweet embedded in user
app.put('/api/users/:userId/tweets/:id', function (req, res) {
// set the value of the user and tweet ids
var userId = req.params.userId;
var tweetId = req.params.id;
// find user in db by id
User.findOne({_id: userId}, function (err, foundUser) {
// find tweet embedded in user
var foundTweet = foundUser.tweets.id(tweetId);
// update tweet text and completed with data from request body
foundTweet.text = req.body.tweetText;
foundTweet.date = new Date(req.body.tweetDate);
foundUser.save(function (err, savedUser) {
res.json(foundTweet);
});
});
});
Remember RESTful routing? It's the most popular modern convention for designing resource paths for nested data. Here is an example of an application that has routes for Store
and Item
models:
HTTP Verb | Path | Description | Key Mongoose Method(s) |
GET | /stores | Get all stores | click for ideas.find |
POST | /stores | Create a store | click for ideasnew , .save |
GET | /stores/:id | Get a store | click for ideas.findOne |
DELETE | /stores/:id | Delete a store | click for ideas.findOne , .remove , .findOneAndRemove |
GET | /stores/:store_id/items | Get all items from a store | click for ideas.findOne , (.populate if referenced) |
POST | /stores/:store_id/items | Create an item for a store | click for ideas.findOne , new , .save |
GET | /stores/:store_id/items/:item_id | Get an item from a store | click for ideas.findOne |
DELETE | /stores/:store_id/items/:item_id | Delete an item from a store | click for ideas.findOne , .remove |
In routes, avoid nesting resources more than one level deep.