Model Many-to-Many Relationships in MongoDB
Think about a Tutorial Blog with the relationship between Tutorial and Tag that goes in both directions:
A Tutorial can have many Tags
A Tag can point to many Tutorials
mongodb-many-to-many-relationship-mongoose-example We call them Many-to-Many relationships.
Let’s explore how they look like after we implement two way of modeling these datasets.
Embedded Data Models (Denormalization)
We can denormalize data into a denormalized form simply by embedding the related documents right into the main document.
Let’s put all the relevant data about Tags right in one Tutorial document without the need to separate documents, collections, and IDs.
Copy // Tutorial
{
_id: "5db579f5faf1f8434098f123"
title: "Tut #1",
author: "bezkoder"
tags: [
{
name: "tagA",
slug: "tag-a"
},
{
name: "tagB",
slug: "tag-b"
}
]
}
// Tag
{
_id: "5db579f5faf1f84340abf456"
name: "tagA",
slug: "tag-a"
tutorials: [
{
title: "Tut #1",
author: "bezkoder"
},
{
title: "Tut #2",
author: "zkoder"
}
]
}
Reference Data Models (Normalization)
Now for MongoDB referenced form – ‘normalized’ means ‘separated’. We need to separate documents, collections, and IDs.
Because Tutorials and Tags are all completely different document, the Tutorial need a way to know which Tags it has, so does a Tag that will know which Tutorials contain it. That’s why the IDs come in.
We’re gonna use Tutorials’ IDs for references on Tag document.
Copy // Tags
// tagA: [Tut #1, Tut #2]
{
_id: "5db57a03faf1f8434098ab01",
name: "tagA",
slug: "tag-a",
tutorials: [ "5db579f5faf1f8434098f123", "5db579f5faf1f8434098f456" ]
}
// tagB: [Tut #1]
{
_id: "5db57a04faf1f8434098ab02",
name: "tagB",
slug: "tag-b",
tutorials: [ "5db579f5faf1f8434098f123" ]
}
You can see that in the Tag document, we have an array where we stored the IDs of all the Tutorials so that when we request a Tag data, we can easily identify its Tutorials.
This type of referencing is called Child Referencing: the parent (Tag) references its children (Tutorials).
Nown how about Parent Referencing?
Each child document keeps a reference to the parent element.
We have Tutorials get references to its Tags’ Ids.
Copy // Tutorial
// Tut #1: [tagA, tagB]
{
_id: "5db579f5faf1f8434098f123"
title: "Tut #1",
author: "bezkoder"
tags: [ "5db57a03faf1f8434098ab01", "5db57a04faf1f8434098ab02" ],
}
// Tut #2: [tagA]
{
_id: "5db579f5faf1f8434098f456"
title: "Tut #2",
author: "zkoder"
tags: [ "5db57a03faf1f8434098ab01" ],
}
What we’ve just done is called Two-way Referencing where Tags and Tutorials are connected in both directions:
– In each Tag, we keep references to all Tutorials that are tagged.
– In each Tutorial, we also keep references to its Tags.
References or Embedding for MongoDB Many-to-Many Relationships
For Embedded Data Models , you can see that we can get all the data about Tutorial with its Tags (or Tag with its Tutorials) at the same time, our application will need few queries to the database. It will increase our performance.
But when the data become larger and larger, duplicates is inevitable.
And the risk is so serious in case we want to update document, even just a field, we have to find and update two place.
For example, if you want to change the name of tagA
, you must have to change not only that Tag’s document but also find the Tutorial that contains the Tag, find that Tag exactly, then update the field. So terrible!
Hence, with Many-to-Many relationship, we always use Data References or Normalizing the data.
Mongoose Many-to-Many Relationship example
Setup Node.js App
Install mongoose with the command:
Create project structure like this:
src
models
Tag.js
Tutorial.js
index.js
server.js
package.json
Open server.js , we import mongoose
and connect the app to MongoDB database.
Copy const mongoose = require("mongoose");
mongoose
.connect("mongodb://localhost/bezkoder_db", {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log("Successfully connect to MongoDB."))
.catch(err => console.error("Connection error", err));
Next, export the models in index.js .
Copy module.exports = {
Tag: require("./Image"),
Tutorial: require("./Tutorial")
};
That’s the first step, now we’re gonna create 2 main models: Tag
& Tutorial
, then use mongoose to interact with MongoDB database.
Define Mongoose data models
In models /Tag.js , define Tag
with 3 fields: name
, slug
, tutorials
–
Copy const mongoose = require("mongoose");
const Tag = mongoose.model(
"Tag",
new mongoose.Schema({
name: String,
slug: String,
tutorials: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Tutorial"
}
]
})
);
module.exports = Tag;
In the code above, we set each item’s type
of tutorials
array to ObjectId
and ref
to Tutorial
. Why we do it?
In models /Tutorial.js , define Tutorial with 3 fields: title
, author
, tags
–
Copy const mongoose = require("mongoose");
const Tutorial = mongoose.model(
"Tutorial",
new mongoose.Schema({
title: String,
author: String,
tags: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Tag"
}
]
})
);
module.exports = Tutorial;
Use Mongoose Model functions to create Documents
We’re gonna define some functions:
Open server.js, add the code below:
Copy const mongoose = require("mongoose");
const db = require("./models");
const createTutorial = function(tutorial) {
return db.Tutorial.create(tutorial).then(docTutorial => {
console.log("\n>> Created Tutorial:\n", docTutorial);
return docTutorial;
});
};
const createTag = function(tag) {
return db.Tag.create(tag).then(docTag => {
console.log("\n>> Created Tag:\n", docTag);
return docTag;
});
};
const addTagToTutorial = function(tutorialId, tag) {
return db.Tutorial.findByIdAndUpdate(
tutorialId,
{ $push: { tags: tag._id } },
{ new: true, useFindAndModify: false }
);
};
const addTutorialToTag = function(tagId, tutorial) {
return db.Tag.findByIdAndUpdate(
tagId,
{ $push: { tutorials: tutorial._id } },
{ new: true, useFindAndModify: false }
);
};
const run = async function() {
var tut1 = await createTutorial({
title: "Tut #1",
author: "bezkoder"
});
var tagA = await createTag({
name: "tagA",
slug: "tag-a"
});
var tagB = await createTag({
name: "tagB",
slug: "tag-b"
});
var tutorial = await addTagToTutorial(tut1._id, tagA);
console.log("\n>> tut1:\n", tutorial);
var tag = await addTutorialToTag(tagA._id, tut1);
console.log("\n>> tagA:\n", tag);
tutorial = await addTagToTutorial(tut1._id, tagB);
console.log("\n>> tut1:\n", tutorial);
tag = await addTutorialToTag(tagB._id, tut1);
console.log("\n>> tagB:\n", tag);
var tut2 = await createTutorial({
title: "Tut #2",
author: "zkoder"
});
tutorial = await addTagToTutorial(tut2._id, tagB);
console.log("\n>> tut2:\n", tutorial);
tag = await addTutorialToTag(tagB._id, tut2);
console.log("\n>> tagB:\n", tag);
};
mongoose
.connect("mongodb://localhost/bezkoder_db", {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log("Successfully connect to MongoDB."))
.catch(err => console.error("Connection error", err));
run();
Run the app with command: node src/server.js
.
You can see the result in Console.
Copy Successfully connect to MongoDB.
>> Created Tutorial:
{ tags: [],
_id: 5e417363316cab53182888d8,
title: 'Tut #1',
author: 'bezkoder',
__v: 0 }
>> Created Tag:
{ tutorials: [],
_id: 5e417363316cab53182888d9,
name: 'tagA',
slug: 'tag-a',
__v: 0 }
>> Created Tag:
{ tutorials: [],
_id: 5e417363316cab53182888da,
name: 'tagB',
slug: 'tag-b',
__v: 0 }
>> tut1:
{ tags: [ 5e417363316cab53182888d9 ],
_id: 5e417363316cab53182888d8,
title: 'Tut #1',
author: 'bezkoder',
__v: 0 }
>> tagA:
{ tutorials: [ 5e417363316cab53182888d8 ],
_id: 5e417363316cab53182888d9,
name: 'tagA',
slug: 'tag-a',
__v: 0 }
>> tut1:
{ tags: [ 5e417363316cab53182888d9, 5e417363316cab53182888da ],
_id: 5e417363316cab53182888d8,
title: 'Tut #1',
author: 'bezkoder',
__v: 0 }
>> tagB:
{ tutorials: [ 5e417363316cab53182888d8 ],
_id: 5e417363316cab53182888da,
name: 'tagB',
slug: 'tag-b',
__v: 0 }
>> Created Tutorial:
{ tags: [],
_id: 5e417363316cab53182888db,
title: 'Tut #2',
author: 'zkoder',
__v: 0 }
>> tut2:
{ tags: [ 5e417363316cab53182888da ],
_id: 5e417363316cab53182888db,
title: 'Tut #2',
author: 'zkoder',
__v: 0 }
>> tagB:
{ tutorials: [ 5e417363316cab53182888d8, 5e417363316cab53182888db ],
_id: 5e417363316cab53182888da,
name: 'tagB',
slug: 'tag-b',
__v: 0 }
Populate referenced documents
You can see that the tutorials
/tags
array field contains reference IDs.
This is the time to use populate()
function to get full data. Let’s create two functions:
Copy const getTutorialWithPopulate = function(id) {
return db.Tutorial.findById(id).populate("tags");
};
const getTagWithPopulate = function(id) {
return db.Tag.findById(id).populate("tutorials");
};
const run = async function() {
// ...
// add this
tutorial = await getTutorialWithPopulate(tut1._id);
console.log("\n>> populated tut1:\n", tutorial);
tag = await getTagWithPopulate(tag._id);
console.log("\n>> populated tagB:\n", tag);
};
Run again, the result will look like this-
Copy >> populated tut1:
{ tags:
[ { tutorials: [Array],
_id: 5e417363316cab53182888d9,
name: 'tagA',
slug: 'tag-a',
__v: 0 },
{ tutorials: [Array],
_id: 5e417363316cab53182888da,
name: 'tagB',
slug: 'tag-b',
__v: 0 } ],
_id: 5e417363316cab53182888d8,
title: 'Tut #1',
author: 'bezkoder',
__v: 0 }
>> populated tagB:
{ tutorials:
[ { tags: [Array],
_id: 5e417363316cab53182888d8,
title: 'Tut #1',
author: 'bezkoder',
__v: 0 },
{ tags: [Array],
_id: 5e417363316cab53182888db,
title: 'Tut #2',
author: 'zkoder',
__v: 0 } ],
_id: 5e417363316cab53182888da,
name: 'tagB',
slug: 'tag-b',
__v: 0 }
If you don’t want to get _id
& __v
in the arrays result, just add second parameters to populate()
function.
Copy const getTutorialWithPopulate = function(id) {
return db.Tutorial.findById(id).populate("tags", "-_id -__v -tutorials");
};
const getTagWithPopulate = function(id) {
return db.Tag.findById(id).populate("tutorials", "-_id -__v -tags");
};
The result is different now.
Copy >> populated tut1:
{ tags:
[ { name: 'tagA', slug: 'tag-a' },
{ name: 'tagB', slug: 'tag-b' } ],
_id: 5e417363316cab53182888d8,
title: 'Tut #1',
author: 'bezkoder',
__v: 0 }
>> populated tagB:
{ tutorials:
[ { title: 'Tut #1', author: 'bezkoder' },
{ title: 'Tut #2', author: 'zkoder' } ],
_id: 5e417363316cab53182888da,
name: 'tagB',
slug: 'tag-b',
__v: 0 }