1

I needed to run a quick and (I thought) trivial query to update some incorrect data. I'm trying to update the very first entry of an array in a document.

Note: My posted queries run against db instead of a collection, and use $documents to generate test data in the pipline so you can evaluate them straight away. No need to create a collection and seed it with test data.

I thought this would be utterly trivial:

db.aggregate([
  { $documents: [{ values: [-354, 52, 234, 1] }] },
  { $set: { 'values.0': 12 } }
]);

However the result of that is bizarre:

{
  values: [
    {
      '0': 12
    },
    {
      '0': 12
    },
    {
      '0': 12
    },
    {
      '0': 12
    }
  ]
}

I finally managed to get it working, but with a convoluted query with some interesting caveats:

db.aggregate([
  { $documents: [{ values: [-354, 52, 234, 1] }] },
  {
    $set: {
      values: {
        $concatArrays: [[12], { $slice: ['$values', 1, 2147483647] }],
      }7
    },
  },
]);

This gives the correct result:

{
  values: [
    12,
    52,
    234,
    1
  ]
}

I must first build a single value array for the new first value. Then concatenate the values in the array, skipping the first. MongoDB doesn't seem to have a proper operator for that, so as a tenuous stand-in, I'm using $slice with the start parameter set to 1, and the number of entries to 2³¹−1, which is the maximum value for that parameter. It could be that there are more array entries than that. But at that point I'm pretty you're going to run into bigger problems, since many more operation parameters are 32-bit integers, and all kinds of operations will fail.

Now this seems utterly ridiculous to me, I think I must be doing something simple wrong. There's no way this janky method is the normal way to to update an array value with a known index in MongoDB.

Since I couldn't find a method that worked online, I'll post this here, and hope someone can tell me how it's really supposed to be done.

10
  • Maybe do it in a plain update, instead of aggregation, like this?
    – ray
    Commented Jun 19 at 12:21
  • From my experience, we usually do not work with array elements by index. We usually use array comprehension like $map/$filter/$reduce or arrayFilters to locate array elements and manipulate them.
    – ray
    Commented Jun 19 at 12:23
  • Yeah, that's pretty much what I experience too. It just feels like this specific case is overcomplicated. Since here I'm manipulating a single known index. Which ought to be accessible using the simple dot notation.
    – Ghostbird
    Commented Jun 22 at 11:06
  • The max. size of documents in MongoDB is 16 MiByte. Thus, it could not be that there are more array entries than 2³¹−1 Commented Jun 22 at 12:33
  • @WernfriedDomscheit I assume that MongoDB supports sparse arrays. E.g. if you in JavaScript run: new Array(Math.pow(2,31)) in your browser console, it'll happily make an array of 2³¹ entries. That's why if you want to populate an array with a mapping, you'll have to use Array.from({length: Math.pow(2,31)}) instead. Note: Do not run the latter in you browser. You'll eat your system memory.
    – Ghostbird
    Commented Jun 24 at 7:33

2 Answers 2

0

The { $set: { 'values.x': ... } } syntax works only in update but not in aggregation pipeline.

More precisely, you mistake Aggregation Stage $set (aggregation) and Field Update Operator $set

Array Expression Operators are not as complete as in Javascript Array. You may use $function and implement your requirement in Javascript.

I would use

values: {
    $concatArrays: [
      [12], 
      { $slice: ['$values', 1, {$subtract: [{ $size: "$values" }, 1] } ] }
    ],
}

Operator $reduce would be even worse, see Performance issue for $reduce vs. $map

With JavaScript it would be

db.aggregate([
   { $documents: [{ values: [-354, 52, 234, 1] }] },
   {
      $set: {
         values: {
            $function: {
               body: function (arr, ind, val) {
                  arr[ind] = val;
                  return arr;
               },
               args: ["$values", 0, -12],
               lang: "js"
            }
         }
      }
   }
]);

You have test if it performs better than native aggregation pipeline functions.

2
  • I purposely use aggregation stage $set since the update $set cannot update using a value from the document. Since that part isn't relevant to the array access in dot notation problem, it's omitted in the question. But rest assured I'm NOT confusing those two. - Overwrite a specific existing field using dot notation: mongodb.com/docs/manual/reference/operator/aggregation/set/… - Update a field with an existing value: mongodb.com/docs/manual/reference/operator/aggregation/set/…
    – Ghostbird
    Commented Jun 22 at 10:43
  • Note that function is normally not a good option, since it's not supported everywhere, and the performance is terrible. In this case I could even have written everything totally in Javascript. I'm not looking for a working solution, which I already have. I'm pretty sure there should be a less convoluted way to write this as a query and I'm trying to learn the correct way. The answer of course might be: "No there is no better query", but I suspect that the answer is: "You did X wrong, and you can write a simple query: Y".
    – Ghostbird
    Commented Jun 22 at 10:48
0

There are 2 alternatives that I can think of to achieve what you want, though I doubt that they will be simpler than your current approach.

  1. also $concatArrays, but use $lastN to construct "remaining" part of the array(i.e. other than the first entry). The size of "remaining" array is calculated through $subtract 1 from $size of values.
db.collection.update({},
[
  {
    "$set": {
      "values": {
        "$concatArrays": [
          [
            12
          ],
          {
            "$lastN": {
              "n": {
                "$subtract": [
                  {
                    "$size": "$values"
                  },
                  1
                ]
              },
              "input": "$values"
            }
          }
        ]
      }
    }
  }
])

Mongo Playground

  1. $unwind with includeArrayIndex. Perform conditional replacement based on the index value and $group to revert the original structure.
db.collection.aggregate([
  {
    "$unwind": {
      path: "$values",
      includeArrayIndex: "idx"
    }
  },
  {
    "$set": {
      "values": {
        "$cond": {
          "if": {
            $eq: [
              "$idx",
              0
            ]
          },
          "then": 12,
          "else": "$values"
        }
      }
    }
  },
  {
    "$group": {
      "_id": "$_id",
      "someOtherField": {
        $first: "$someOtherField"
      },
      "values": {
        "$push": "$values"
      }
    }
  },
  {
    "$merge": {
      "into": "collection"
    }
  }
])

Mongo Playground

1
  • Nice solutions to the array slice problem. Though, I'd hoped for something simpeler :D
    – Ghostbird
    Commented Jun 25 at 7:09

Not the answer you're looking for? Browse other questions tagged or ask your own question.