Is it possible to $setOnInsert with aggregation pipeline?

Viewed 477

MongoDB has recently added an option to perform an update operation by providing an aggregation pipeline rather than the standard modifier object. Check MongoDB's docs on this topic.

The ability to use aggregation pipeline, whose statements can refer to existing document properties, can be extremely useful in situations when certain fields needs to be evaluated based on other fields, e.g. during data migration.

Moreover, most of the standard update operators like $set, $push, $inc, etc. can be successfully replicated with the aggregation expression language so in some sense this new functionality generalizes the good old modifiers technique. Though, I must admit the pipeline can become quite verbose if one tries to do things like $addToSet. This of course brings up a whole bunch of performance related questions, but let's ignore them for now.

So far, there's been just one thing which I haven't been able to fully replicate with the aggregation pipeline update, namely the $setOnInsert operator. Let's assume that I want to perform an upsert:

db.test.update(selector, pipeline, { upsert: true });

My initial intuition was that the $$ROOT variable (which I can use in the pipeline) will equal null unless there exists a document that matches selector. Unfortunately, but probably for a good reason, MongoDB developers decided that $$ROOT should be derived from selector by default. It makes sense when you think about how normal $setOnInsert works, but it also makes it practically impossible to distinguish between an update and an insert within pipeline.

I know what you're thinking. You can look at $$ROOT._id. This is a good idea, though if _id is part of the selector it doesn't work anymore. I have figured out that this can be bypassed by tricking MongoDB a little bit and doing things like:

selector = {
  _id: { $in: [value, 'fake'] },
}

instead if the simpler { _id: value }, but this doesn't look clean. Please note that if $in only contains one element, then Mongo is actually clever enough to figure out what the identifier should be and it populates $$ROOT accordingly (sic!).

I am wondering if anyone has a better idea how to approach this. Maybe there's some hidden variable that I could potentially use inside the pipeline itself to distinguish between update and insert (e.g. in $merge stage there's $$new variable which serves a similar purpose)?

1 Answers

If there is no matching documents, $$ROOT will have only _id field. So you can transform $$ROOT to array by its key/value pairs and check if the size of that array is equal to 1. If it is then create a new document, and if it is not then do nothing.

  • $objectToArray and $size to convert $$ROOT to an array by its key/value pairs and to get the size of that array
  • $cond to check if the size of the array above is equal to 1. If it is then merge current $$ROOT (which is only _id field) with the update object. If it is not, return the current $$ROOT. In both scenarios, put result in result feild.
  • $mergeObjects to merge $$ROOT and the update that you are sending, and put that in the result field
  • $replaceRoot to replace root to the result field from previous stage
db.collection.update({
  _id: 1
},
[
  {
    $set: {
      result: {
        $cond: {
          if: {
            "$eq": [
              {
                $size: {
                  $objectToArray: "$$ROOT"
                },
                
              },
              1
            ]
          },
          then: {
            $mergeObjects: [
              "$$ROOT",
              {
                key: 3
              }
            ]
          },
          else: "$$ROOT"
        },
        
      }
    }
  },
  {
    $replaceRoot: {
      newRoot: "$result"
    }
  }
],
{
  upsert: true
})

Working example

Related