MongoDB One-to-Many Relationship tutorial with Mongoose examples

Model One-to-Many Relationships in MongoDB

Assume that you want to design a Tutorial Blog data model. Here are some relationships that you can think about:

  • A Tutorial has some Images (15 or less)

  • A Tutorial has many Comments

  • A Category has a lot of Tutorials

We call them One-to-Many relationships.

With the difference based on the quantity, we can distinguish between three types of One-to-Many relationships:

  • One-to-Few

  • One-to-Many

  • One-to-aLot

Depending on the types of relationships, on data access patterns, or on data cohesion, we will decide how to implement the data model, in other words, decide if we should denormalize or normalize data.

Let’s go to the next section, I will show you how to represent related data in a reference (normalized) form or in an embedded (denormalized) form.

Reference Data Models (Normalization)

In the MongoDB referenced form, we keep all the documents ‘separated’ which is exactly what ‘normalized’ means.

For example, we have documents for Tutorials and Comments. Because they are all completely different document, the Tutorial need a way to know which Comments it contains. That’s why the IDs come in. We’re gonna use the Comments’ IDs to make references on Tutorial document.

// Tutorial
{
  _id: "5db579f5faf1f8434098f7f5"
  title: "Tutorial #1",
  author: "bezkoder"
  comments: [ "5db57a03faf1f8434098f7f8", "5db57a04faf1f8434098f7f9" ],
}

// Comments
{
  _id: "5db57a03faf1f8434098f7f8",
  username: "jack",
  text: "This is a great tutorial.",
  createdAt: 2019-10-27T11:05:39.898Z
}

{
  _id: "5db57a04faf1f8434098f7f9",
  username: "mary",
  text: "Thank you, it helps me alot.",
  createdAt: 2019-10-27T11:05:40.710Z
}

You can see that in the Tutorial document, we have an array where we stored the IDs of all the Comments so that when we request Tutorial data, we can easily identify its Comments.

This type of referencing is called Child Referencing: the parent references its children.

Let’s think about the array with all the IDs. The problem here is that this array of IDs can become very large if there are lots of children. This is an anti-pattern in MongoDB that we should avoid at all costs.

That’s why we have Parent Referencing. In each child document we keep a reference to the parent element.

For example, a Category could have a lot of Tutorials, we don’t want to make a categories array with 200-500 items, so we normalize data with Parent Referencing.

// Category
{
  _id: "5db66dd1f4892d34f4f4451a",  
  name: "Node.js",
  description: "Node.js tutorial",
}

// Tutorials
{ _id: "5db66dcdf4892d34f4f44515",
  title: "Tutorial #1",  
  author: "bezkoder",
  category_id: "5db66dd1f4892d34f4f4451a"
}

{ 
  _id: "5db66dd3f4892d34f4f4451b",
  title: "Tutorial #2",
  author: "bezkoder",
  category_id: "5db66dd1f4892d34f4f4451a"
}

...

Embedded Data Models (Denormalization)

We can also denormalize data into a denormalized form simply by embedding the related documents right into the main document.

So now we have all the relevant data about Comments right inside in one Tutorial document without the need to separate documents, collections, and IDs.

// Tutorial
{
  _id: "5db579f5faf1f8434098f7f5"
  title: "Tutorial #1",
  author: "bezkoder"
  comments: [
			  {
			    username: "jack",
			    text: "This is a great tutorial.",
			    createdAt: 2019-10-27T11:05:39.898Z
			  },
			  {
			    username: "mary",
			    text: "Thank you, it helps me alot.",
			    createdAt: 2019-10-27T11:05:40.710Z
			  }
			]
}

Because we can get all the data about Tutorial and Comments at the same time, our application will need fewer queries to the database which will increase our performance.

So how do we actually decide if we should normalize or denormalize the data, keep them separated and reference them or embed the data?

When to use References or Embedding for MongoDB One-to-Many Relationships

As I’ve said before, we will decide how to implement the data model depending on the types of relationships that exists between collections, on data access patterns, or on data cohesion.

To actually take the decision, we need to combine all of these three criteria, not just use one of them in isolation.

Types of Relationships

– Usually when we have one-to-few relationship, we will embed the related documents into the parent documents. For example, a Tutorial has some Images (15 or less):

{
  _id: "5db579f5faf1f8434098f7f5"
  title: "Tutorial #1",
  author: "bezkoder"
  images: [
            {
              url: "/images/mongodb.png",  
              caption: "MongoDB Database"
            },
            {
              url: "/images/one-to-many.png",
              caption: "One to Many Relationship"
            }
          ]
}

– For a one-to-many relationship, we can either embed or reference according to the other two criteria.

– With one-to-aLot relationship, we always use data references or normalizing the data. That’s because if we actually did embed a lot of documents inside one document, we could quickly make document become too large. For example, you can imagine that a Category has 300 Tutorials.

{
  _id: "5db639ddbad61428189b16fb",  
  name: "Node.js",
  description: "Node.js tutorial"
  tutorials:[
              { 
                title: "Tutorial #1",  
                author: "bezkoder"
              },
              // a lot item here
              ...
              { 
                title: "Tutorial #2",
                author: "bezkoder"
              }
            ]
}

So the solution for that is, of course, referencing.

Data access patterns

Now with one-to-many relationship, are we gonna embed the documents or should we rather use data references? We will consider how often data is read and written along with read/write ratio.

– If the collections that we’re deciding about is mostly read and the data is not updated a lot, there is a lot more reading than writing (a high read/write ratio), then we should probably embed the data.

The reason is that by embedding we only need one trip to the database per query while for referencing we need two trips. In each query, we save one trip to the database, it makes the entire process way more effective.

For example, a blog Post has about 20-30 Images would actually be a good candidate for embedding because once these Images are saved to the database they are not really updated anymore.

– On the other hand, if our data is updated a lot then we should consider referencing (normalizing) the data. That’s because the database engine does more work to update and embed a document than a standalone document, our main goal is performance so we just use referencing for data model.

Now let’s assume that each Tutorial has many Comments. Each time someone posts a Comment, we need to update the corresponding Tutorial document. The data can change all the time, so this is a great candidate for referencing.

Data cohesion

The last criterion is just a measure for how much the data is related.

If two collections really intrinsically belong together then they should probably be embedded into one another. In our example, all Tutorials can have many Images, every Image intrinsically belongs to a Tutorial. So Images should be embedded into the Tutorial document.

If we frequently need to query both of collections on their own, we should normalize the data into two separate collections, even if they are closely related.

Imagine that in our Tutorial Blog, we have a widget called Recent Images, and Images could belong to separated Tutorials. This means that we’re gonna query Images on their own collections without necessarily querying for the Tutorials themselves.

So, apply this third criterion, we come to the conclusion that we should actually normalize the data. Another way is still embed Images (with appropriate fields) in Tutorial document, but also create Images collection.

All of this shows that we should really look all the three criteria together rather than just one of them in isolation. They are not really completely right or completely wrong ways of modeling our data.

Let’s implement each of them in a Node.js app using Mongoose.

Mongoose One-to-Many Relationship example

Setup Node.js App

Install mongoose with the command:

npm install mongoose

Create project structure like this:

src

models

Category.js

Tutorial.js

Image.js

Comment.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 = {
  Tutorial: require("./Tutorial"),
  Image: require("./Image"),
  Comment: require("./Comment"),
  Category: require("./Category")
};

That’s the first step, now we’re gonna create appropriate models and use mongoose to interact with MongoDB database. There are three cases that we will apply three types of one-to-many relationships:

  • Tutorial-Images: One-to-Few

  • Tutorial-Comments: One-to-Many

  • Category-Tutorials: One-to-aLot

Case 1: Mongoose One-to-Many (Few) Relationship

Now we will represent the relationship between Tutorial and its Images. Let’s create Tutorial model with mongoose.Schema() constructor function.

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

const mongoose = require("mongoose");

const Tutorial = mongoose.model(
  "Tutorial",
  new mongoose.Schema({
    title: String,
    author: String,
    images: []
  })
);

module.exports = Tutorial;

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 createImage = function(tutorialId, image) {
  console.log("\n>> Add Image:\n", image);
  return db.Tutorial.findByIdAndUpdate(
    tutorialId,
    {
      $push: {
        images: {
          url: image.url,
          caption: image.caption
        }
      }
    },
    { new: true, useFindAndModify: false }
  );
};

const run = async function() {
  var tutorial = await createTutorial({
    title: "Tutorial #1",
    author: "bezkoder"
  });

  tutorial = await createImage(tutorial._id, {
    path: "sites/uploads/images/mongodb.png",
    url: "/images/mongodb.png",
    caption: "MongoDB Database",
    createdAt: Date.now()
  });
  console.log("\n>> Tutorial:\n", tutorial);

  tutorial = await createImage(tutorial._id, {
    path: "sites/uploads/images/one-to-many.png",
    url: "/images/one-to-many.png",
    caption: "One to Many Relationship",
    createdAt: Date.now()
  });
  console.log("\n>> Tutorial:\n", tutorial);
};

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:
 { images: [],
  _id: 5db6ab3fd9fbef1c6861b978,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Add Image:
 { path: 'sites/uploads/images/mongodb.png',
  url: '/images/mongodb.png',
  caption: 'MongoDB Database',
  createdAt: 1572252481890 }

>> Tutorial:
 { images:
   [ { url: '/images/mongodb.png',
       caption: 'MongoDB Database' } ],
  _id: 5db6ab3fd9fbef1c6861b978,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Add Image:
 { path: 'sites/uploads/images/one-to-many.png',
  url: '/images/one-to-many.png',
  caption: 'One to Many Relationship',
  createdAt: 1572252483834 }

>> Tutorial:
 { images:
   [ { url: '/images/mongodb.png',
       caption: 'MongoDB Database' },
     { url: '/images/one-to-many.png',
       caption: 'One to Many Relationship' } ],
  _id: 5db6ab3fd9fbef1c6861b978,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

Now, if we want to embed Images (with appropriate fields) in Tutorial document, but also want to query Images on their own collections without necessarily querying for the Tutorials themselves, we can define Image model in Image.js like this:

const mongoose = require("mongoose");

const Image = mongoose.model(
  "Image",
  new mongoose.Schema({
    path: String,
    url: String,
    caption: String,
    createdAt: Date
  })
);

module.exports = Image;

We also need to change createImage() function in server.js:

const createImage = function(tutorialId, image) {
  return db.Image.create(image).then(docImage => {
    console.log("\n>> Created Image:\n", docImage);
    return db.Tutorial.findByIdAndUpdate(
      tutorialId,
      {
        $push: {
          images: {
            _id: docImage._id,
            url: docImage.url,
            caption: docImage.caption
          }
        }
      },
      { new: true, useFindAndModify: false }
    );
  });
};

Run the app again, the result looks like this.

>> Created Tutorial:
 { images: [],
  _id: 5db6af65c90cdd3a2c3038aa,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Created Image:
 { _id: 5db6af68c90cdd3a2c3038ab,
  path: 'sites/uploads/images/mongodb.png',
  url: '/images/mongodb.png',
  caption: 'MongoDB Database',
  createdAt: 2019-10-28T09:05:44.894Z,
  __v: 0 }

>> Tutorial:
 { images:
   [ { _id: 5db6af68c90cdd3a2c3038ab,
       url: '/images/mongodb.png',
       caption: 'MongoDB Database' } ],
  _id: 5db6af65c90cdd3a2c3038aa,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Created Image:
 { _id: 5db6af6ac90cdd3a2c3038ac,
  path: 'sites/uploads/images/one-to-many.png',
  url: '/images/one-to-many.png',
  caption: 'One to Many Relationship',
  createdAt: 2019-10-28T09:05:46.427Z,
  __v: 0 }

>> Tutorial:
 { images:
   [ { _id: 5db6af68c90cdd3a2c3038ab,
       url: '/images/mongodb.png',
       caption: 'MongoDB Database' },
     { _id: 5db6af6ac90cdd3a2c3038ac,
       url: '/images/one-to-many.png',
       caption: 'One to Many Relationship' } ],
  _id: 5db6af65c90cdd3a2c3038aa,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

If you check MongoDB database, you can see images collection with 2 documents.

Case 2: Mongoose One-to-Many (Many) Relationship

Let’s define Tutorial to make One-to-Many Relationship with Comments using References Data Model (Normalization).

First, we define Comment model in Comment.js.

const mongoose = require("mongoose");

const Comment = mongoose.model(
  "Comment",
  new mongoose.Schema({
    username: String,
    text: String,
    createdAt: Date
  })
);

module.exports = Comment;

In Tutorial.js, add comments array like this:

const mongoose = require("mongoose");

const Tutorial = mongoose.model(
  "Tutorial",
  new mongoose.Schema({
    title: String,
    author: String,
    images: [],
    comments: [
      {
        type: mongoose.Schema.Types.ObjectId,
        ref: "Comment"
      }
    ]
  })
);

module.exports = Tutorial;

In the code above, set each item’s type of comments array to ObjectId and ref to Comment. Why we do it?

The Tutorial has only ObjectId in comments array. ref helps us get full fields of Comment when we call populate() method.

Let me show you how to do it.

In server.js, add createComment function.

const createComment = function(tutorialId, comment) {
  return db.Comment.create(comment).then(docComment => {
    console.log("\n>> Created Comment:\n", docComment);

    return db.Tutorial.findByIdAndUpdate(
      tutorialId,
      { $push: { comments: docComment._id } },
      { new: true, useFindAndModify: false }
    );
  });
};

Then modify run() function as the code below,


const run = async function() {
  var tutorial = await createTutorial({
    title: "Tutorial #1",
    author: "bezkoder"
  });

  tutorial = await createComment(tutorial._id, {
    username: "jack",
    text: "This is a great tutorial.",
    createdAt: Date.now()
  });
  console.log("\n>> Tutorial:\n", tutorial);

  tutorial = await createComment(tutorial._id, {
    username: "mary",
    text: "Thank you, it helps me alot.",
    createdAt: Date.now()
  });
  console.log("\n>> Tutorial:\n", tutorial);
};

Run the app again and look at the result in Console:

>> Created Tutorial:
 { images: [],
  comments: [],
  _id: 5db6baf8186b350fc0f2a88a,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Created Comment:
 { _id: 5db6bafa186b350fc0f2a88b,
  username: 'jack',
  text: 'This is a great tutorial.',
  createdAt: 2019-10-28T09:55:06.127Z,
  __v: 0 }

>> Tutorial:
 { images: [],
  comments: [ 5db6bafa186b350fc0f2a88b ],
  _id: 5db6baf8186b350fc0f2a88a,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Created Comment:
 { _id: 5db6bafb186b350fc0f2a88c,
  username: 'mary',
  text: 'Thank you, it helps me alot.',
  createdAt: 2019-10-28T09:55:07.150Z,
  __v: 0 }

>> Tutorial:
 { images: [],
  comments: [ 5db6bafa186b350fc0f2a88b, 5db6bafb186b350fc0f2a88c ],
  _id: 5db6baf8186b350fc0f2a88a,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

The comments array field of Tutorial document contains reference IDs to Comments now. This is the time to use populate() function to get full Tutorial data. Let’s create getTutorialWithPopulate() function like this:

const getTutorialWithPopulate = function(id) {
  return db.Tutorial.findById(id).populate("comments");
};

const run = async function() {
  // ...

  // add this
  tutorial = await getTutorialWithPopulate(tutorial._id);
  console.log("\n>> populated Tutorial:\n", tutorial);
};

Run again, the result will contain this.

>> populated Tutorial:
 { images: [],
  comments:
   [ { _id: 5db6bc1630d4f50840fd552b,       
       username: 'jack',
       text: 'This is a great tutorial.',   
       createdAt: 2019-10-28T09:59:50.642Z, 
       __v: 0 },
     { _id: 5db6bc1630d4f50840fd552c,       
       username: 'mary',
       text: 'Thank you, it helps me alot.',
       createdAt: 2019-10-28T09:59:50.676Z, 
       __v: 0 } ],
  _id: 5db6bc1430d4f50840fd552a,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

If you don’t want to get comment._id & comment.__v in the result, just add second parameters to populate() function.

const getTutorialWithPopulate = function(id) {
  return db.Tutorial.findById(id).populate("comments", "-_id -__v");
};

The result is different now.

>> populated Tutorial:
 { images: [],
  comments:
   [ { username: 'jack',
       text: 'This is a great tutorial.',
       createdAt: 2019-10-28T10:04:18.281Z },
     { username: 'mary',
       text: 'Thank you, it helps me alot.',
       createdAt: 2019-10-28T10:04:18.555Z } ],
  _id: 5db6bd2050a0e5067089fe90,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

Case 3: Mongoose One-to-Many (aLot) Relationship

The last example is creating relationship between Category and its Tutorials.

First we define Category model with 2 fields: name & description.

const mongoose = require("mongoose");

const Category = mongoose.model(
  "Category",
  new mongoose.Schema({
    name: String,
    description: String
  })
);

module.exports = Category;

Next, we add a Parent Reference to Category in Tutorial model.

const mongoose = require("mongoose");

const Tutorial = mongoose.model(
  "Tutorial",
  new mongoose.Schema({
    title: String,
    author: String,
    images: [],
    comments: [
      {
        type: mongoose.Schema.Types.ObjectId,
        ref: "Comment"
      }
    ],
    category: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Category"
    }
  })
);

module.exports = Tutorial;

We also need to define functions for creating Category and adding Tutorial to a Category.

const createCategory = function(category) {
  return db.Category.create(category).then(docCategory => {
    console.log("\n>> Created Category:\n", docCategory);
    return docCategory;
  });
};

const addTutorialToCategory = function(tutorialId, categoryId) {
  return db.Tutorial.findByIdAndUpdate(
    tutorialId,
    { category: categoryId },
    { new: true, useFindAndModify: false }
  );
};

To test it, change run() function to this.

const run = async function() {
  var tutorial = await createTutorial({
    title: "Tutorial #1",
    author: "bezkoder"
  });

 var category = await createCategory({
    name: "Node.js",
    description: "Node.js tutorial"
  });

  tutorial = await addTutorialToCategory(tutorial._id, category._id);
  console.log("\n>> Tutorial:\n", tutorial);
};

Look at the result, you can see category ID inside Tutorial document.

>> Created Tutorial:
 { images: [],
  comments: [],
  _id: 5db6c27ed15b6649e8efe3e3,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Created Category:
 { _id: 5db6c280d15b6649e8efe3e4,
  name: 'Node.js',
  description: 'Node.js tutorial',
  __v: 0 }

>> Tutorial:
 { images: [],
  comments: [],
  _id: 5db6c27ed15b6649e8efe3e3,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0,
  category: 5db6c280d15b6649e8efe3e4 }

Now let’s create a function for retrieving all Tutorials in a Category (with showing the Category’s name).

const getTutorialsInCategory = function(categoryId) {
  return db.Tutorial.find({ category: categoryId })
    .populate("category", "name -_id")
    .select("-comments -images -__v");
};

const run = async function() {
  var tutorial = await createTutorial({
    title: "Tutorial #1",
    author: "bezkoder"
  });

  var category = await createCategory({
    name: "Node.js",
    description: "Node.js tutorial"
  });

  await addTutorialToCategory(tutorial._id, category._id);

  var newTutorial = await createTutorial({
    title: "Tutorial #2",
    author: "bezkoder"
  });

  await addTutorialToCategory(newTutorial._id, category._id);

  var tutorials = await getTutorialsInCategory(category._id);
  console.log("\n>> all Tutorials in Cagetory:\n", tutorials);
};

Run the app again and check the result in Console.

>> Created Tutorial:
 { images: [],
  comments: [],
  _id: 5db6c3e5d601484404b92b17,
  title: 'Tutorial #1',
  author: 'bezkoder',
  __v: 0 }

>> Created Category:
 { _id: 5db6c3e7d601484404b92b18,
  name: 'Node.js',
  description: 'Node.js tutorial',
  __v: 0 }

>> Created Tutorial:
 { images: [],
  comments: [],
  _id: 5db6c3e7d601484404b92b19,
  title: 'Tutorial #2',
  author: 'bezkoder',
  __v: 0 }

>> all Tutorials in Cagetory:
 [ { _id: 5db6c3e5d601484404b92b17,
    title: 'Tutorial #1',
    author: 'bezkoder',
    category: { name: 'Node.js' } },
  { _id: 5db6c3e7d601484404b92b19,
    title: 'Tutorial #2',
    author: 'bezkoder',
    category: { name: 'Node.js' } } ]

Last updated