MongoDB Embedded Documents

MongoDB can embed documents within other documents in the same way that JSON objects can be embedded within other JSON objects. Embedding a mongoDB document within another mongoDB document can be used to achieve a one-to-many relationship.

MongoDB allows efficient querying of embedded documents. Compared to SQL relational databases, embedded documents can require fewer queries and updates.

The code below allows a cars document to hold zero or more photos.

We need a separate schema for both the main carsSchema and the carPhotosSchema.

const mongoose = require(`mongoose`)

let carPhotosSchema = new mongoose.Schema(
    {
       filename:{type:String}
    })


let carsSchema = new mongoose.Schema(
    {
        model: {type: String},
        colour: {type: String},
        year: {type: Number},
        price: {type: Number},
        photos:[carPhotosSchema]
    },
    {
       collection: `cars`
    })

module.exports = mongoose.model(`cars`, carsSchema)

Any data that is only useful as part of its parent document should be embedded inside its parent document.

Data that will be referred to from multiple places should be placed in its own collection.

Changes to a single document are always atomic. Changes to multiple documents are not atomic. Transactions can be used to ensure that changes to data are consistent across documents.

Open the image_files project from the previous section in these notes. Change the code so that an administrator can add zero, one, two or three photos when they add a new car to the cars collection. The photos should be displayed on the main table so that they can be viewed by all users (including people who are not logged in), as shown below:

"Cars" Worked Example

The full project code for the "Cars" Worked Example that is described below can be downloaded from this link.

In this example, we shall allow the user to add zero or more photos when they create a new car document.

 

Client-Side

client/src/components/CarTableRow.js

import React, {Component} from "react"
import {Link} from "react-router-dom"

import axios from "axios"

import {ACCESS_LEVEL_GUEST, ACCESS_LEVEL_ADMIN, SERVER_HOST} from "../config/global_constants"


export default class CarTableRow extends Component 
{    
    componentDidMount() 
    {
        this.props.car.photos.map(photo => 
        {
            return axios.get(`${SERVER_HOST}/cars/photo/${photo.filename}`)
            .then(res => 
            {
                if(res.data)
                {            
                    if (res.data.errorMessage)
                    {
                        console.log(res.data.errorMessage)    
                    }
                    else
                    {           
                        document.getElementById(photo._id).src = `data:;base64,${res.data.image}`                                                         
                    }   
                }
                else
                {
                    console.log("Record not found")
                }
            })
        })
    }
    
    
    render() 
    {
        return (
            <tr>
                <td>{this.props.car.model}</td>
                <td>{this.props.car.colour}</td>
                <td>{this.props.car.year}</td>
                <td>{this.props.car.price}</td>
                <td className="carPhotos">
                    {this.props.car.photos.map(photo => <img key={photo._id} id={photo._id} alt=""/>)}
                </td>           
                <td>
                    {localStorage.accessLevel > ACCESS_LEVEL_GUEST ? <Link className="green-button" to={"/EditCar/" + this.props.car._id}>Edit</Link> : null}
                    
                    {localStorage.accessLevel >= ACCESS_LEVEL_ADMIN ? <Link className="red-button" to={"/DeleteCar/" + this.props.car._id}>Delete</Link> : null}   
                </td>  
                
            </tr>
        )
    }
}

When the component mounts, independently download each of the photos. To do this, we use a map() method to pass each photo's filename to the photo router. The router will return a base64 image. We place this inside the src of the <img> that has been created with an id of photo._id.

    componentDidMount() 
    {
        this.props.car.photos.map(photo => 
        {
            return axios.get(`${SERVER_HOST}/cars/photo/${photo.filename}`)
            .then(res => 
            {
                if(res.data)
                {            
                    if (res.data.errorMessage)
                    {
                        console.log(res.data.errorMessage)    
                    }
                    else
                    {           
                        document.getElementById(photo._id).src = `data:;base64,${res.data.image}`                                                         
                    }   
                }
                else
                {
                    console.log("Record not found")
                }
            })
        })
    }

In the render() method, we include a cell that will hold the car's photos. We use a map() method to create an image placeholder for each of the car's photos. Each placeholder <img> tag is uniquely identified by its id photo._id. The images will be loaded when the componentDidMount() method is fired.

    render()
    {
                ...

                <td className="carPhotos">
                    {this.props.car.photos.map(photo => <img key={photo._id} id={photo._id} alt=""/>)}
                </td>  

client/src/components/AddCar.js

import React, {Component} from "react"
import {Redirect, Link} from "react-router-dom"
import Form from "react-bootstrap/Form"

import axios from "axios"

import Button from "../components/Button"

import {ACCESS_LEVEL_ADMIN, SERVER_HOST} from "../config/global_constants"


export default class AddCar extends Component
{
    constructor(props)
    {
        super(props)

        this.state = {
            model:"",
            colour:"",
            year:"",
            price:"",
            selectedFiles:null,
            redirectToDisplayAllCars:localStorage.accessLevel < ACCESS_LEVEL_ADMIN
        }
    }


    componentDidMount() 
    {     
        this.inputToFocus.focus()        
    }
 
 
    handleChange = (e) => 
    {
        this.setState({[e.target.name]: e.target.value})
    }


    handleFileChange = (e) => 
    {
        this.setState({selectedFiles: e.target.files})
    }
    
    
    handleSubmit = (e) => 
    {
        e.preventDefault()

        let formData = new FormData()  

        formData.append("model", this.state.model)
        formData.append("colour", this.state.colour)
        formData.append("year", this.state.year)
        formData.append("price", this.state.price) 

        if(this.state.selectedFiles)
        {
            for(let i = 0; i < this.state.selectedFiles.length; i++)
            {
                formData.append("carPhotos", this.state.selectedFiles[i])
            }
        }

        axios.post(`${SERVER_HOST}/cars`, formData, {headers:{"authorization":localStorage.token, "Content-type": "multipart/form-data"}})
        .then(res => 
        {   
            if(res.data)
            {
                if (res.data.errorMessage)
                {
                    console.log(res.data.errorMessage)    
                }
                else
                {   
                    console.log("Record added")
                    this.setState({redirectToDisplayAllCars:true})
                } 
            }
            else
            {
                console.log("Record not added")
            }
        })
    }


    render()
    {        
        return (
            <div className="form-container"> 
                {this.state.redirectToDisplayAllCars ? <Redirect to="/DisplayAllCars"/> : null}                                            
                    
                <Form>
                    <Form.Group controlId="model">
                        <Form.Label>Model</Form.Label>
                        <Form.Control ref = {(input) => { this.inputToFocus = input }} type="text" name="model" value={this.state.model} onChange={this.handleChange} />
                    </Form.Group>
    
                    <Form.Group controlId="colour">
                        <Form.Label>Colour</Form.Label>
                        <Form.Control type="text" name="colour" value={this.state.colour} onChange={this.handleChange} />
                    </Form.Group>
    
                    <Form.Group controlId="year">
                        <Form.Label>Year</Form.Label>
                        <Form.Control type="text" name="year" value={this.state.year} onChange={this.handleChange} />
                    </Form.Group>
    
                    <Form.Group controlId="price">
                        <Form.Label>Price</Form.Label>
                        <Form.Control type="text" name="price" value={this.state.price} onChange={this.handleChange} />
                    </Form.Group> 
                    
                    <Form.Group controlId="photos">
                    <Form.Label>Photos</Form.Label>
                    <Form.Control          
                        type = "file" multiple onChange = {this.handleFileChange}
                    /></Form.Group> <br/><br/>
            
                    <Button value="Add" className="green-button" onClick={this.handleSubmit}/>            
            
                    <Link className="red-button" to={"/DisplayAllCars"}>Cancel</Link>
                </Form>
            </div>
        )
    }
}

Save the details of all of the selected files in this.state.selectedFiles.

    handleFileChange = (e) => 
    {
        this.setState({selectedFiles: e.target.files})
    }

Append all of the selected files to formData.

Because we are uploading images, we need to set the axios() method's headers to include "Content-type": "multipart/form-data".

The if(this.state.selectedFiles) is needed to catch the situation where the user has not selected any car photo files to upload.

        let formData = new FormData()  

        formData.append("model", this.state.model) 
        formData.append("colour", this.state.colour) 
        formData.append("year", this.state.year) 
        formData.append("price", this.state.price) 

        if(this.state.selectedFiles)
        {
            for(let i = 0; i < this.state.selectedFiles.length; i++)
            { 
                formData.append("carPhotos", this.state.selectedFiles[i])
            }
        }

        axios.post(`${SERVER_HOST}/cars`, formData, {headers:{"authorization":localStorage.token, "Content-type": "multipart/form-data"}})

        ...

Set the file input to allow multiple file selection.

                    <Form.Group controlId="photos">
                    <Form.Label>Photos</Form.Label>
                    <Form.Control          
                        type = "file" multiple onChange = {this.handleFileChange}
                    /></Form.Group>

Server-Side

server/config/.env

# This file holds global constants that are visible on the Server-side

# Database
DB_NAME = D01234567
DB_HOST = localhost
DB_USER = root
DB_PASS = yourDBpassword


# Access Levels
ACCESS_LEVEL_GUEST = 0
ACCESS_LEVEL_NORMAL_USER = 1
ACCESS_LEVEL_ADMIN = 2


# Keys
JWT_PRIVATE_KEY_FILENAME = ./config/jwt_private_key.pem
JWT_EXPIRY = "7d"


# Salt length of encryption of user passwords
# The salt length should be 16 or higher for commercially released code
# It has been set to 3 here, so that the password will be generated faster
PASSWORD_HASH_SALT_ROUNDS = 3


# Uploaded images folder, which holds any files that are uploaded by the user, such as their profile photo
UPLOADED_FILES_FOLDER = ./uploads
MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED = 3


# Port
SERVER_PORT = 4000


# Local Host
LOCAL_HOST = http://localhost:3000

Limit to 3 the number of car photo files that can be uploaded.

MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED = 3

server/models/cars.js

const mongoose = require(`mongoose`)

let carPhotosSchema = new mongoose.Schema(
    {
       filename:{type:String}
    })


let carsSchema = new mongoose.Schema(
    {
        model: {type: String},
        colour: {type: String},
        year: {type: Number},
        price: {type: Number},
        photos:[carPhotosSchema]
    },
    {
       collection: `cars`
    })

module.exports = mongoose.model(`cars`, carsSchema)

Create a new schema to hold the names of the uploaded car photo files.

let carPhotosSchema = new mongoose.Schema(
    {
       filename:{type:String}
    })

Add the carPhotosSchema as an embedded schema inside the carsSchema.

Note that carPhotosSchema is placed inside an [] array, because there can be many car photos for each car.

        photos:[carPhotosSchema]

server/routes/cars.js

const router = require(`express`).Router()

const carsModel = require(`../models/cars`)

const jwt = require('jsonwebtoken')
const fs = require('fs')
const JWT_PRIVATE_KEY = fs.readFileSync(process.env.JWT_PRIVATE_KEY_FILENAME, 'utf8')

const multer  = require('multer')
var upload = multer({dest: `${process.env.UPLOADED_FILES_FOLDER}`})


// read all records
router.get(`/cars`, (req, res) => 
{   
    //user does not have to be logged in to see car details
    carsModel.find((error, data) => 
    {        
        res.json(data)
    })
})

router.get(`/cars/photo/:filename`, (req, res) => 
{   
    fs.readFile(`${process.env.UPLOADED_FILES_FOLDER}/${req.params.filename}`, 'base64', (err, fileData) => 
        {        
            if(fileData)
            {  
                res.json({image:fileData})                           
            }   
        else
        {
            res.json({image:null})
        }
    })             
})


// Read one record
router.get(`/cars/:id`, (req, res) => {
    jwt.verify(req.headers.authorization, JWT_PRIVATE_KEY, {algorithm: "HS256"}, (err, decodedToken) => 
    {
        if (err) 
        { 
            res.json({errorMessage:`User is not logged in`})
        }
        else
        {
            carsModel.findById(req.params.id, (error, data) => 
            {
                res.json(data)
            })
        }
    })
})


// Add new record
router.post(`/cars`, upload.array("carPhotos", parseInt(process.env.MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED)),(req, res) => 
{
    jwt.verify(req.headers.authorization, JWT_PRIVATE_KEY, {algorithm: "HS256"}, (err, decodedToken) => 
    {
        if (err) 
        { 
            res.json({errorMessage:`User is not logged in`})
        }
        else
        {
            if(decodedToken.accessLevel >= process.env.ACCESS_LEVEL_ADMIN)
            {                
                // Use the new car details to create a new car document                
let carDetails = new Object()
carDetails.model = req.body.model
carDetails.colour = req.body.colour
carDetails.year = req.body.year
carDetails.price = req.body.price
// add the car's photos to the carDetails JSON object carDetails.photos = []
req.files.map((file, index) =>
{
carDetails.photos[index] = {filename:`${file.filename}`}
})
carsModel.create(carDetails, (error, data) => { res.json(data) }) } else { res.json({errorMessage:`User is not an administrator, so they cannot add new records`}) } } }) }) // Update one record router.put(`/cars/:id`, (req, res) => { jwt.verify(req.headers.authorization, JWT_PRIVATE_KEY, {algorithm: "HS256"}, (err, decodedToken) => { if (err) { res.json({errorMessage:`User is not logged in`}) } else { carsModel.findByIdAndUpdate(req.params.id, {$set: req.body}, (error, data) => { res.json(data) }) } }) }) // Delete one record router.delete(`/cars/:id`, (req, res) => { jwt.verify(req.headers.authorization, JWT_PRIVATE_KEY, {algorithm: "HS256"}, (err, decodedToken) => { if (err) { res.json({errorMessage:`User is not logged in`}) } else { if(decodedToken.accessLevel >= process.env.ACCESS_LEVEL_ADMIN) { carsModel.findByIdAndRemove(req.params.id, (error, data) => { res.json(data) }) } else { res.json({errorMessage:`User is not an administrator, so they cannot delete records`}) } } }) }) module.exports = router

As we are uploading files, we need to use the multer package.

const multer  = require('multer')
var upload = multer({dest: `${process.env.UPLOADED_FILES_FOLDER}`})

We need a new route that will download a single car photo.

router.get(`/cars/photo/:filename`, (req, res) => 
{   
    fs.readFile(`${process.env.UPLOADED_FILES_FOLDER}/${req.params.filename}`, 'base64', (err, fileData) => 
        {        
            if(fileData)
            {  
                res.json({image:fileData})                           
            }   
        else
        {
            res.json({image:null})
        }
    })             
})

When we add a new car, the photos are passed to upload.array("carPhotos", parseInt(process.env.MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED)).

A maximum of process.env.MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED files can be uploaded.

The string "carPhotos" must match the first parameter of the formData.append() method that was used to append the car photo files to the formData in the AddCar component.

The model, colour, year, price and photos that were passed to the router are added to carDetails, which is then used to create a new document in the cars collection.

router.post(`/cars`, upload.array("carPhotos", parseInt(process.env.MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED)),(req, res) => 
{
    jwt.verify(req.headers.authorization, JWT_PRIVATE_KEY, {algorithm: "HS256"}, (err, decodedToken) => 
    {
        if (err) 
        { 
            res.json({errorMessage:`User is not logged in`})
        }
        else
        {
            if(decodedToken.accessLevel >= process.env.ACCESS_LEVEL_ADMIN)
            {                
                // Use the new car details to create a new car document                
let carDetails = new Object()
carDetails.model = req.body.model
carDetails.colour = req.body.colour
carDetails.year = req.body.year
carDetails.price = req.body.price
// add the car's photos to the carDetails JSON object carDetails.photos = []
req.files.map((file, index) =>
{
carDetails.photos[index] = {filename:`${file.filename}`}
})
carsModel.create(carDetails, (error, data) => { res.json(data) }) } else { res.json({errorMessage:`User is not an administrator, so they cannot add new records`}) } } }) })

Write code on the server-side to return an error message when the number of car photos is greater than process.env.MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED.

Write code on the client-side to check that the number of car photos being uploaded is not greater than the value stored in the server-side value process.env.MAX_NUMBER_OF_UPLOAD_FILES_ALLOWED.

Write code to allow the administrator to add new photos or delete existing photos for an existing car. Remember, to remove any deleted photo files from the uploads folder.

By not embeddeding documents, MongoDB can be made to be a relational database. To achieve this in the "Cars" example above, instead of storing the carPhotosSchema as an embedded document inside the carsSchema, we can store the _id of each car photo document in the carsSchema instead. HINT: Use an array of Schema.Types.ObjectId to hold all of the photos' _id for a given car.

 
<div align="center"><a href="../versionC/index.html" title="DKIT Lecture notes homepage for Derek O&#39; Reilly, Dundalk Institute of Technology (DKIT), Dundalk, County Louth, Ireland. Copyright Derek O&#39; Reilly, DKIT." target="_parent" style='font-size:0;color:white;background-color:white'>&nbsp;</a></div>