0
votes

I'm trying to implement a rating system and I'm struggling to only allow one rating per user in a reasonable way.

Simply put, i have an array of ratings in my schema, containing the "rater" and the rating, as such:

var schema = new Schema({
//...
    ratings: [{
        by: {
            type: Schema.Types.ObjectId
        },
        rating: {
            type: Number,
            min: 1,
            max: 5,
            validate: ratingValidator
        }
   }],
//...
});

var Model = mongoose.model('Model', schema);

When i get a request, i wish to add the users rating to the array if the user has not already voted this document, otherwise i wish to update the rating (you should not be able to give more than one rating)

One way to do this is to find the document, "loop through" the array of ratings and search for the user. If the user has got already a rating in the array, the rating is changed, otherwise a new rating is pushed. As such:

Model.findById(id)
    .select('ratings')
    .exec(function(err, doc) {
        if(err) return next(err);

        if(doc) {
            var rated = false;
            var ratings = doc.ratings;
            for(var i = 0; i < ratings.length; i++) {
                if(ratings[i].by === user.id) {
                    ratings[i].rating = rating;
                    rated = true;
                    break;
                }
            }

            if(!rated) {
                ratings.push({
                    by: user.id,
                    rating: rating
                });
            }

            doc.markModified('ratings');
            doc.save();
        } else {
            //Not found
        }
    });

Is there an easier way? A way to let mongodb do this automatically?

The mongodb $addToSet operator could be an alternative, however i have not managed to use it for this, since that could allow two ratings with different scores from the same user.

1

1 Answers

0
votes

As you note the $addToSet operator will not work in this case as indeed a userId with a different vote value would be a different value and it's own unique member of the set.

So the best way to do this is to actually issue two update statements with complementary logic. Only one will actually be applied depending on the state of the document:

async.series(
    [
        // Try to update a matching element
        function(callback) {
            Model.update(
                { "_id": id, "ratings.by": user.id },
                { "$set": { "ratings.$.rating": rating } },
                callback
            );
        },
        // Add the element where it does not exist
        function(callback) {
            Model.update(
                { "_id": id, "ratings.by": { "$ne": user.id } },
                { "$push": { "ratings": { "by": user.id, "rating": rating } }},
                callback
            );
        }
    ],
    function(err,result) {
      // all done
    }

);

The principle is simple, try to match the userId present in the ratings array for the document and update the entry. If that condition is not met then no document is updated. In the same way, try to match the document where there is no userId present in the ratings array, if there is a match then add the element, otherwise there will be no update.

This does bypass the built in schema validation of mongoose, so you would have to apply your constraints manually ( or inspect the schema validation rules and apply manually ) but it is better than you current approach in one very important aspect.

When you .find() the document and call it back to your client application to modify using code as you are, then there is no guarantee that the document has not changed on the server from another process or request. So when you issue .save() the document on the server may no longer be in the state that it was when it was read and any modifications can overwrite the changes made there.

Hence while there are two operations to the server and not one ( and your current code is two operations anyway ), it is the lesser of two evils to manually validate than to possibly cause a data inconsistency. The two update approach will respect any other updates issued to the document possibly occurring at the same time.