Create a Product Rating System with MongoDB and Node.js

By Ichinga Samuel

August 3rd, 2021

image

Introduction

In this tutorial, you will learn how to make good use of Mongoose schema mixed type, getters and, setters to manipulate data in your MongoDB database

Most product rating systems such as that of amazon make use of the weighted average system. The weighted average is calculated in almost the same way as the ‘normal’ simple average except that each unit in the summation has a multiplier which is called the weight. For example in a five-star rating system. One star could have a weight of 1, two stars will have a weight of 2, and so on.

Example

Let say a product has the following star reviews.

One Star — — 8

Two Star — — 10

Three Stars — — 7

Four Stars — — 5

Five Stars — — 3

The simple average will be:

const SA = (8+10+7+5+3) / 5
console.log(Math.round(SA)) // 2

The weighted average will be:

const WA = ((1*8) + (2*10) + (3*7) + (5*3))/(10+8+7+5+3)
console.log(Math.round(WA)) // 2

The weighted average system gives a better result as it gives a good impression of what consumers think of this product.

How do you implement this?

Implementing this on the backend means that you need a place to store not just the ratings of each product but also the number each start receives. There are different approaches to solving this problem. Personally, I prefer a combination of Mongoose schema mixed types with setters and getters.

Requirements

For this project will need only Mongoose as our MongoDB driver and a local Mongo shell and optionally Compass for nice visualization.

To start with let’s create a simple Node project and install Mongoose.

npm install -s mongoose

Create a file model.js and start the local Mongo client

const mongoose = require('mongoose');

const connectDB = async () => {
     try{
         const conn = await mongoose.connect('mongodb://localhost:27017/myapp', {useNewUrlParser: true, useFindAndModify: false})
         console.log(`MongoDb connected: ${conn.connection.host}`)
         return conn
     }
     catch (e) {
         console.error(e);
         process.exit(1);

     }
 };

Next, let’s create a basic mongoose schema for the product collection. It will have just two paths name a string path to hold the name of the product and ratings a mixed path object to store the ratings of the product.

const ProductSchema = new mongoose.Schema({
    name: String,
    ratings:{
        type: mongoose.Mixed,
 // A mixed type object to handle ratings. Each star level is represented in the ratings object
        1: Number, //  the key is the weight of that star level
        2: Number,
        3: Number,
        4: Number,
        5: Number,
    default: {1:1, 2:1, 3:1, 4:1, 5:1}}
    })

Since we are using getters lets make sure to include the options below in the schema object so that the getters can be used when obtaining JSON or JavaScript Object

{toObject:{getters: true, }, toJSON:{getters: true}}

The Getter Function

Next, we going to write a getter function for obtaining the rating of a product from the rating path. When we call a getter function on a path it accepts a single argument that represents the path. In this case, it is a plain JavaScript object that contains our star reviews. This simplifies the getter function. As shown below.

get: function(r){
    // r is the entire ratings object
    let items = Object.entries(r); // get an array of key/value pairs of the object like this [[1:1], [2:1]...]
    let sum = 0; // sum of weighted ratings
    let total = 0; // total number of ratings
    for(let [key,value] of items){
        total += value;
        sum += value * parseInt(key); // multiply the total number of ratings by it's weight in this case which is the key
    }
    return Math.round(sum / total)
},

The Setter Function

This function will handle the setting of values to the rating path. It will enable us to have a simple interface for updating the ratings of a product. Let say a user gives the product a rating of three or we want to replace the entire rating object with a new object. We want to be able to update the rating of a product with simple onliners such as this.

product.ratings = 3
product.ratings = {1:1, 2:1, 3:1, 4:1, 5:1}
Product.findByIdAndUpdate(id, {$inc:{'ratings.3': 1}})
Product.findByIdAndUpdate('61084b72b346c52e8482ed3b', {ratings: {1:3, 2:1, 3:1, 4:1, 5:1}}, {new: true})

The first two snippets will update the number of 3-star ratings by 1. While the other two will assign a new object to the ratings path.

set: function(r){
    if (!(this instanceof mongoose.Document)){
        // only call setter when updating the whole path with an object
        if(r instanceof Object) return r
        else{throw new Error('')}
    }else{
        // get the actual ratings object without using the getter which returns  an integer value
        // r is the ratings which is an integer value that represent the star level from 1 to 5
        if(r instanceof Object){
            return r    // handle setting default when creating object
        }
        this.get('ratings', null, {getters: false})[r] = 1 + parseInt(this.get('ratings', null, {getters: false})[r])
        return this.get('ratings', null, {getters: false})} // return the updated ratings object
},

The setter function is a bit complicated as we need to obtain the rating object for that document without using the getter which returns an integer value. The argument to the setter function is either the star level we want to update by one or a JavaScript object containing the star levels as keys just as specified in the schema. Mongoose setters get called when running update operations as well as when assigning directly to a path. The difference is that when running update operations “this” in the function refers to the query object and not an instance of the mongoose document we are updating this makes it difficult to carry out our custom update operation for a sub-path since we are updating a specific sub-path and not the whole path. Therefore, we will throw an error if someone tries to update the whole path with an integer value using any of the Mongoose model update methods.

The remaining logic in the setter function is simple, we will simply obtain the product rating object without using getters then use the argument to the setter function to update the specific path after which we will return the updated rating object.

Validation

It will be nice to add a validator to prevent increasing the value of a sub-path by more than one at a time but, Mongoose validators do not run when using the increment operator “$inc” so, it will be up to us to validate data before passing it to the database. Our validator function will simply prevent the addition of an extra star level outside that specified in our Schema.

validate:{
    validator: function(i){
        let b = [1, 2, 3, 4, 5] // valid star levels
        let v = Object.keys(i).sort()
        return b.every((x, j) => (v.length === b.length) && x === parseInt(v[j]))
    },
    message: "Invalid Star Level"
},

Testing

Let’s create a product and console log it with and without getters to see what it looks like. Here is what our code looks like. We can then copy the product id to test our update methods and validators.

const create = async () => {
    let prod = await product.create({name: "Product One"})
    // display the newly created object with and without getters
    console.log(prod)
    console.log(prod.get( 'ratings', null, {getters: false}))
}
create()

// result without the getter
{
    ratings: 3,
        _id: 61069af7547cd8335409a926,
    name: 'Product One',
    __v: 0,
    id: '61069af7547cd8335409a926'
}

// the ratings object
{ '1': 1, '2': 1, '3': 1, '4': 1, '5': 1 }

Having seen our getters at work. We are going to try and update the ratings of our product in a few different ways to test our setters.

Lets’s write a code to give our product a five-star rating. As you can see by simply assigning the value five to the rating object we have updated the number of five-star reviews from 1 to 2. The average rating is still three because of the weighted average computation we use in obtaining the rating.

const test1 = async () => {
    // increment a particular star level.
    // by assigning directly to the ratings object
    let prod = await product.findById('61084b72b346c52e8482ed3b')
    prod.ratings = 5
    prod.markModified('ratings')  // Add markModified because ratings is a mixed object type
    prod.save()
    console.log(prod.get( 'ratings', null, {getters: false}))
    console.log(prod)
}

test1()

{ '1': 1, '2': 1, '3': 1, '4': 1, '5': 2 }

{
    ratings: 3,
        _id: 61084b72b346c52e8482ed3b,
    name: 'Product One',
    __v: 0,
    id: '61084b72b346c52e8482ed3b'
}

Conclusion

As always, in computer science, there are many ways to solve a problem some more intuitive than others. However, finding a solution that is adaptable, maintainable and, involves a minimal amount of code is always preferable.

The complete code can be found here.



Continue Learning