MongoDB Many-to-Many Relationship with Mongoose examples

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

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.

// 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.

// 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.

// 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:

npm install mongoose

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.

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.

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

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?

The Tutorial has only ObjectId in tutorials array. ref helps us get full fields of Tutorial when we call populate() method. I will show you how to do it later.

In models/Tutorial.js, define Tutorial with 3 fields: title, author, tags

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:

  • createTutorial

  • createTag

  • addTagToTutorial

  • addTutorialToTag

Open server.js, add the code below:

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.

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:

  • getTutorialWithPopulate

  • getTagWithPopulate

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-

>> 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.

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.

>> 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 }

Last updated