Conjuring unicorns
Gradient background

Implement a real database using MongoDB

Page 4 out of 9

MongoDB is a popular NoSQL database that is commonly used with Node.js. In this chapter, you will install and run your own copy of MongoDB and read from and write to it.

Clarice Bouwer

Software Engineering Team Lead and Director of Cloudsure

Thursday, 3 November 2022 · Estimated 6 minute read
Modified on Monday, 14 November 2022

Objectives

  1. Learn some MongoDB.
  2. Install and run your own copy of MongoDB community.
  3. Populate the database with in-memory data.
  4. Learn how to read and write from and to MongoDB.

Get started

Installation

Install and run the MongoDB community service (version 6.0). This is the actual database and its service that will run on your machine or on a server somewhere.

You are going to make a directory inside the server to store all documents to while in development mode.

>./
Copy
mkdir server/db

Git mustn't know about this addition to your project so you can ignore the directory from Git.

.gitignore
Copy
server/db/

Start the service

If you don't have the service running globally then you can start the service and point it to your DB path to the newly created directory.

>./
Copy
mongod --dbpath ./server/db/

REPL environment

The MongoDB Shell, mongosh, is a fully functional JavaScript and Node.js 16.x REPL environment for interacting with MongoDB deployments. You can use the MongoDB Shell to test queries and operations directly with your database.

>./
Copy
mongosh

Create your database with the use keyword.

mongosh
Copy
use stargazers-db

Populate some values using the in-memory data file from earlier.

mongosh
Copy
db.reviews.insertMany([
  {
    "slug": "joes-snack-shop",
    "title": "Joe's Snack Shop",
    "abstract": "Lorem ipsum, dolor sit amet consectetur adipisicing elit.",
    "rating": 3.5,
    "ratings": [
      {
        "total": 3.5
      }
    ]
  },
  {
    "slug": "gerrys-tv",
    "title": "Gerry's TV",
    "abstract": "Lorem ipsum, dolor sit amet consectetur adipisicing elit.",
    "rating": 3.5,
    "ratings": [
      {
        "total": 4
      },
      {
        "total": 3
      }
    ]
  },
  {
    "slug": "pieters-flower-shack",
    "title": "Pieter's Flower Shack",
    "abstract": "Lorem ipsum, dolor sit amet consectetur adipisicing elit."
  }
])

The following code will get all reviews and format the JSON because of the pretty() function.

mongosh
Copy
db.reviews.find({}).pretty()

Integrate with Express

Install the mongodb npm package.

>./server
Copy
npm install mongodb

Create a new file to generically connect to the MongoDB instance and execute queries. The collection returned from the db is passed into the function so that data is available to the calling code.

😱 Note that the hardcoded values will be removed later.

>./server
Copy
touch src/db.js
./server/src/db.js
Copy
import { MongoClient } from 'mongodb';

// 😱 Fret not, it will be configured later on
const url = 'mongodb://127.0.0.1:27017';
const dbName = 'stargazers-db';
const client = new MongoClient(url);

const closeConnection = () => {
  client.close();
  console.info(`Successfully closed MongoDB instance to ${url}`);
};

const openConnection = async () => {
  await client.connect();
  console.info(`Successfully connected to MongoDB instance at ${url}`);
};

const withCollection = async (name, executeQuery) => {
  try {
    const db = client.db(dbName);
    const collection = db.collection(name);
    return await executeQuery(collection);
  } catch (e) {
    closeConnection();
    throw e;
  }
};

export { openConnection, closeConnection, withCollection };

Integrate with Reviews collection

A review is a piece of data (perhaps a company or thing) that can be rated and commented on.

This file will be responsible for all things review related like getting a review by its slug, getting all reviews, calculating the average when rating the review and commenting on a review.

./server/src/reviews.js
Copy
import { withCollection } from './db.js';

const collectionName = 'reviews';

// Calculates the average rating from the ratings list in the collection.
// Each rating in the array will have a total that needs to be used in the calculation.
const calculateAverage = (ratings) => {
  if (!ratings || ratings.length === 0) return 0;

  // Calculate the total from the ratings in the array by reducing the sum of each
  // total as a float value.
  const total = ratings.reduce((acc, { total }) => {
    return acc + parseFloat(total);
  }, 0);

  const average = total / ratings.length;

  // Only whole and half numbers are used in the rating.
  // If the modulus of 0.5 of the average is 0 then the average valid.
  if (average % 0.5 === 0) {
    return average;
  }

  // This calculation is somewhat more complicated.
  // Get the modulus of 1 from the average to get the fraction.
  // Example 2.25 % 1 = 0.25 and 5.75 % 1 = 0.75
  // Multiply the modulus result by 10 to get a number greater than 0.
  // Floor the number to remove the fraction bits.
  // Now we can see if the average must be rounded up or down by checking if the
  // result is >= 5 or < 5.
  return Math.floor((average % 1) * 10) >= 5
      // Remove the result of the 0.5 modulus calculation from the average
      //          5.75 % 0.5 = 0.25
      // 5.75 - (5.75 % 0.5) = 5.5
    ? average - (average % 0.5)
    : Math.floor(average);
};

// Returns a single review from the collection
const getReviewBySlug = (slug) => {
  return withCollection(collectionName, async (collection) => {
    return await collection.findOne({ slug });
  });
};

// Returns all reviews from the collection
const getAllReviews = () => {
  return withCollection(collectionName, async (collection) => {
    return await collection.find({}).toArray();
  });
};

// Rates a specific review
const rateReview = (slug, rating) => {
  return withCollection(collectionName, async (collection) => {
    const total = parseFloat(rating, 0);
    const review = await getReviewBySlug(slug);
    const average = calculateAverage([...(review?.ratings || []), { total }]);
    return await collection.updateOne(
      {
        slug,
      },
      {
        // set the average on the rating field in the document
        $set: {
          rating: average,
        },
        // push the total field and value to the ratings array
        $push: {
          ratings: {
            total,
          },
        },
      },
    );
  });
};

// Comment on a specific review
const commentOnReview = (slug, { name, email, comment }) => {
  // Indicate which fields should be marked and checked as mandatory
  const requiredFields = [
    { field: 'Name', value: name },
    { field: 'Comment', value: comment },
  ];

  // If every mandatory field has a value then the form is valid
  const isValid = requiredFields.every((field) => field.value);
  if (!isValid) {
    // Concatenate a string of missing fields to be sent as an error message downstream
    const fields = requiredFields
      .reduce((acc, prev) => {
        return `${acc} ${prev.value ? '' : `[${prev.field}]`}`;
      }, '')
      .trim();
    throw new Error(`We are missing values for ${fields}.`);
  }

  return withCollection(collectionName, async (collection) => {
    return await collection.updateOne(
      { slug },
      {
        // push the comment and timestamp to the comments field in the document
        $push: {
          comments: {
            name,
            email,
            comment,
            timestamp: new Date().getTime(),
          },
        },
      },
    );
  });
};

// Named exports for publicly exposed functionality
export { getReviewBySlug, getAllReviews, rateReview, commentOnReview };

Create endpoints

Note that you need to specify the file extension when importing db because you set the type to "module" in package.json.

Now that you have a database, you can create functional production-ready endpoints. You will be creating endpoints to:

  • get a list of all the reviews in the database
  • get a single review
  • rate a review
  • leave a comment on a review
./server/src/server.js
Copy
import express from 'express';
import { openConnection as openMongoDbConnection } from './db.js';
import {
  commentOnReview,
  getAllReviews,
  getReviewBySlug,
  rateReview,
} from './reviews.js';

const app = express();
app.use(express.json());

app.get('/api/reviews', async (_, res) => {
  const reviews = await getAllReviews();
  res.json(reviews);
});

app.get('/api/reviews/:slug', async (req, res) => {
  const { slug } = req.params;
  const review = await getReviewBySlug(slug);
  if (review) {
    res.json(review);
  } else {
    res.status(404).json({
      query: req.params,
      error: `Review could not be found in the database.`,
    });
  }
});

app.put('/api/review/:slug/rate/:rating', async (req, res) => {
  const { slug, rating } = req.params;
  await rateReview(slug, rating);
  const review = await getReviewBySlug(slug);
  if (review) {
    res.json(review);
  } else {
    res.status(404).json({
      query: req.params,
      error: `Review could not be rated because it cannot be found in the database.`,
    });
  }
});

// Comment on a review. Validation takes place inside the commentOnReview
// so when an exception is caught, a bad request is returned to the client
// along with the assumed validation error message.
app.post('/api/review/:slug/comment', async (req, res) => {
  const { slug } = req.params;
  try {
    await commentOnReview(slug, req.body);
  } catch (e) {
    res.status(400).json({
      query: req.params,
      error: e.message,
    });
    return;
  }

  const review = await getReviewBySlug(slug);
  if (review) {
    res.json(review);
  } else {
    res.status(404).json({
      query: req.params,
      error: `Review could not be commented on because it cannot be found in the database.`,
    });
  }
});

// Only open the Mongo DB connection once
app.listen('3001', async () => {
  console.log('Listening on http://localhost:3001');
  await openMongoDbConnection();
});

Delete documents

If you need to, you can always delete the files and create them again.

mongosh
Copy
db.reviews.deleteMany({})

Next steps

In the next chapter, you will get the frontend to communicate with the backend by exposing an API.

References