How can I check a document's child array to see if an entry exists, update it if it does or add it if it doesn't, in an atomic fashion? I'd also like to adjust a counter atomically as well (using $inc), depending on the result of the first operation.
I'm trying to write a method to provide atomic "upvote" or "downvote" functionality for arbitrary mongodb documents.
What I would like to have happen is the following:
The logic I'm trying to implement is:
$inc operator, and a new vote is added to the array with that user's ID and the type of the vote.voteType is "none"), or the voteType is updated in place to reflect the change.How would I go about doing this? I assume I'm going to have to use ether document.update() or model.findOneAndUpdate(), as the traditional MongooseArray methods (followed by a save()) won't be atomic.
So far I have the following code, but it is not working:
var mongoose = require('mongoose');
var voteTypes = ['up', 'down'];
var voteSchema = new mongoose.Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', },
voteType: { type: String, enum: voteTypes }
});
exports.schema = voteSchema;
exports.plugin = function votesPlugin(schema, options) {
schema.add({ voteScore: Number });
schema.add({ votes: [voteSchema] });
schema.methods.atomicVote = function(args, callback) {
var item = this;
var voteType = (args.voteType && args.voteType.toLowerCase()) || 'none';
var incrementScore, query;
switch (voteType) {
case 'none':
incrementScore = -voteTypeValue(voteRemoved.voteType);
query = this.update({ $inc: {voteScore: incrementScore}, $pull: { votes: { userId: args.userId } } });
break;
case 'up':
case 'down':
var newVoteVal = voteTypeValue(args.voteType);
if (/* Vote does not already exist in array */) {
incrementScore = newVoteVal;
query = this.update({$inc: {voteScore: incrementScore}, $addToSet: { userId: args.userId, voteType: args.voteType });
} else {
var vote = /* existing vote */;
if (vote.voteType === args.voteType) return callback(null); // no-op
var prevVoteVal = voteTypeValue(vote.voteType);
incrementScore = (-prevVoteVal) + newVoteVal;
vote.voteType = args.voteType;
// This likely won't work because document.update() doesn't have the right query param
// to infer the index of '$'
query = this.update({$inc: {voteScore: incrementScore}, $set: { 'votes.$': { userId: args.userId, voteType: args.voteType } });
}
break;
default:
return callback(new Error('Invalid or missing "voteType" argument (possible values are "up", "down", or "none").'));
}
query.exec(callback);
};
};
function voteTypeValue(voteType) {
switch (voteType) {
case 'up': return 1;
case 'down': return -1;
default: return 0;
}
}
To do this atomically you are better off separating the arrays for "up/down" votes. In this way you can either $push or $pull from each field at the same time. This is largely due to the fact that MongoDB cannot perform those operations in a single update on the same field path within the document. Trying to do so would result in an error.
A simplified schema representation might look like this:
var questionSchema = new Schema({
"score": Number,
"upVotes": [{ type: Schema.Types.ObjectId }],
"downVotes": [{ type: Schema.Types.ObjectId }]
]);
module.exports = mongoose.model( 'Question', questionSchema );
It's a general representation so don't take it too literally, but the main point is the two arrays.
When processing an "upvote" all you really need to do is make sure that you are not adding to the "upvotes" array where that "user" already exists:
Question.findOneAndUpdate(
{ "_id": question_id, "upVotes": { "$ne": user_id } },
{
"$inc": { "score": 1 },
"$push": { "upVotes": user_id },
"$pull": { "downVotes": user_id }
},
function(err,doc) {
}
);
And the reverse to process a "downvote":
Question.findOneAndUpdate(
{ "_id": question_id, "downVotes": { "$ne": user_id } },
{
"$inc": { "score": -1 },
"$push": { "downVotes": user_id },
"$pull": { "upVotes": user_id }
},
function(err,doc) {
}
);
With the logical extension on this being that you cancel out all votes by simply "pulling" from both arrays. But you can indeed be "smart" about this and maintain the "state" information in your application so you "know" if you are incrementing or decrementing the "score" as well.
So for mine I would do it that way, and also process the "array" results when sending responses to just filter out the current "user" for the state information so you can make a smart choice when cancelling and even not send a request to the server where that user has already cast their "upvote" or "downvote" as may be the case.
Note also that $addToSet is not an option here without the query as $inc operates independently of the other operators. So you don't want to "select" a document that would not be valid for update.