68

I have a document structure that is deeply nested, like this:

{id: 1, 
 forecasts: [ { 
             forecast_id: 123, 
             name: "Forecast 1", 
             levels: [ 
                { level: "proven", 
                  configs: [
                            { 
                              config: "Custom 1",
                              variables: [{ x: 1, y:2, z:3}]
                            }, 
                            { 
                              config: "Custom 2",
                              variables: [{ x: 10, y:20, z:30}]
                            }, 
                    ]
                }, 
                { level: "likely", 
                  configs: [
                            { 
                              config: "Custom 1",
                              variables: [{ x: 1, y:2, z:3}]
                            }, 
                            { 
                              config: "Custom 2",
                              variables: [{ x: 10, y:20, z:30}]
                            }, 
                    ]
                }
            ]
        }, 
    ]

}

I'm trying to update the collection to insert a new config, that looks like this:

newdata =  {
  config: "Custom 1", 
  variables: [{ x: 111, y:2222, z:3333}]
}

I'm trying something like this in mongo (in Python):

db.myCollection.update({"id": 1, 
                        "forecasts.forecast-id": 123, 
                        "forecasts.levels.level": "proven", 
                        "forecasts.levels.configs.config": "Custom 1"
                         },
                         {"$set": {"forecasts.$.levels.$.configs.$": newData}}
                      )

I'm getting "Cannot apply the positional operator without a corresponding query field containing an array" error though. What is the proper way to do this in mongo? This is mongo v2.4.1.

reptilicus
  • 10,290
  • 6
  • 55
  • 79
  • Are you trying to replace the data that's in there, or add another index to the array with that new data? – tymeJV Aug 11 '13 at 15:48
  • Replace the data thats already there. – reptilicus Aug 11 '13 at 15:53
  • Try ditching that last positional operator: `$set": {"forecasts.$.levels.$.configs" : newData` – tymeJV Aug 11 '13 at 15:54
  • Interesting.. I'm pretty new to mongo, had a similar issue last week. Checking out my code to see if anything jumps at me. +1 and fav'd for now. – tymeJV Aug 11 '13 at 16:00
  • Right on, any help appreciated. This has been bothering me. . . – reptilicus Aug 11 '13 at 16:01
  • Unfortunately, you can't use the `$` operator more than once per key. – JohnnyHK Aug 11 '13 at 16:10
  • @JohnnyHK -- So would you actually have to specify the index, `forecasts.0` etc? – tymeJV Aug 11 '13 at 16:14
  • 1
    Thats what I'm gathering. So whats the workaround for now? This is lame, whats the point of nested documents if you can't do things like this. – reptilicus Aug 11 '13 at 16:16
  • 2
    @tymeJV Yep, you need to use numeric values for the other indexes in your key. Mongo's support for updating nested arrays is poor. – JohnnyHK Aug 11 '13 at 16:22
  • @JohnnyHK -- Thanks for that...didn't know that and I'm sure I would've run into it tomorrow or so :\. Should post this as an answer for reptilicus.\ – tymeJV Aug 11 '13 at 16:23
  • Thanks @JohnnyHK. How are we supposed to know the indexes beforehand though? – reptilicus Aug 11 '13 at 16:27
  • @reptilicus You'd have to make separate queries to determine the indexes. Yes, it's lame. – JohnnyHK Aug 11 '13 at 16:31
  • Can you post an example of doing that? I'm befuddled. – reptilicus Aug 11 '13 at 16:33
  • @reptilicus It's so painful it's barely worth considering. You have to query for the whole doc and then search it in code to figure out the indexes of the array elements you're targeting. – JohnnyHK Aug 11 '13 at 16:41
  • Seems like something that should be updated in the new releases... – tymeJV Aug 11 '13 at 16:55
  • 13
    Its shameful that this is not possible. Whats the point of mongo if you can't create nested docs. You have to create multiple collections, and at that point you are back to a relational db! – reptilicus Aug 11 '13 at 16:59
  • 1
    @reptilicus -- agreed. – tymeJV Aug 11 '13 at 17:00
  • @reptilicus Nested docs are fine in all respects; it's just that nested arrays are not supported well for updates. See my updated answer for an alternative approach. – JohnnyHK Aug 11 '13 at 19:10
  • https://jira.mongodb.org/browse/SERVER-831 please upvote guys – anvarik Jun 13 '14 at 09:52
  • I got rid of mongo,its a terrible thing – reptilicus May 24 '15 at 21:35
  • It's fixed. https://jira.mongodb.org/browse/SERVER-831 But this feature is available starting with the MongoDB 3.5.12 development version. – 6339 Sep 01 '17 at 10:43

11 Answers11

54

Unfortunately, you can't use the $ operator more than once per key, so you have to use numeric values for the rest. As in:

db.myCollection.update({
    "id": 1, 
    "forecasts.forecast-id": 123, 
    "forecasts.levels.level": "proven", 
    "forecasts.levels.configs.config": "Custom 1"
  },
  {"$set": {"forecasts.$.levels.0.configs.0": newData}}
)

MongoDB's support for updating nested arrays is poor. So you're best off avoiding their use if you need to update the data frequently, and consider using multiple collections instead.

One possibility: make forecasts its own collection, and assuming you have a fixed set of level values, make level an object instead of an array:

{
  _id: 123,
  parentId: 1,
  name: "Forecast 1", 
  levels: {
    proven: { 
      configs: [
        { 
          config: "Custom 1",
          variables: [{ x: 1, y:2, z:3}]
        }, 
        { 
          config: "Custom 2",
          variables: [{ x: 10, y:20, z:30}]
        }, 
      ]
    },
    likely: {
      configs: [
        { 
          config: "Custom 1",
          variables: [{ x: 1, y:2, z:3}]
        }, 
        { 
          config: "Custom 2",
          variables: [{ x: 10, y:20, z:30}]
        }, 
      ]
    }
  }
}

Then you can update it using:

db.myCollection.update({
    _id: 123,
    'levels.proven.configs.config': 'Custom 1'
  },
  { $set: { 'levels.proven.configs.$': newData }}
)
JohnnyHK
  • 305,182
  • 66
  • 621
  • 471
  • Hi @JohnnyHK, I've posted a [question](http://stackoverflow.com/questions/34604069/issue-updating-value-inside-the-subdocument-of-a-subdocument) where I have a similar issue. I've tried using `$set` like you showed, but with no success. Any help will be much appreciated! – charliebrownie Jan 05 '16 at 15:33
  • This particular approach to pivoting the data suffers from additional issues, such as index fragmentation. – amcgregor Apr 25 '16 at 18:21
  • 2
    @JohnnyHK, I think there is an error in the first part of your answer. In '{"$set": {"forecasts.0.levels.0.configs.$": newData}}' , the $ operator is placed wrong, since the operator is returning the index of the object in the first nested list, that means it will always be 0 in the current query. You can test this, by changing your query to use '"forecasts.levels.configs.config": "Custom 2"' and you will see that the doc that is updated is the one having "config" : "Custom 1". Correct should be '{"$set": {"forecasts.$.levels.0.configs.0": newData}}' – sergiuz Jul 04 '16 at 11:47
  • This seems like a nice workaround if you use unique ids for all array items. `forecasts.$.levels.0.configs.0` should be always **Custom 1**. Any reason not to use it? – user2491336 Oct 12 '16 at 10:13
  • @user2491336 It works fine as long as you know the index of the `levels` and `configs` elements you want ahead of time, but that's not always the case. – JohnnyHK Oct 12 '16 at 12:33
  • Concise. Very nice. In action here https://mongoplayground.net/p/Lmy-ZStKCdo – FranCarstens Dec 21 '22 at 16:53
20

Managed to solve it with using mongoose:

All you need to know is the '_id's of all of the sub-document in the chain (mongoose automatically create '_id' for each sub-document).

for example -

  SchemaName.findById(_id, function (e, data) {
      if (e) console.log(e);
      data.sub1.id(_id1).sub2.id(_id2).field = req.body.something;

      // or if you want to change more then one field -
      //=> var t = data.sub1.id(_id1).sub2.id(_id2);
      //=> t.field = req.body.something;

      data.save();
  });

More about the sub-document _id method in mongoose documentation.

explanation:_id is for the SchemaName, _id1 for sub1 and _id2 for sub2 - you can keep chaining like that.

*You don't have to use findById method, but it's seem to me the most convenient as you need to know the rest of the '_id's anyway.

ips
  • 111
  • 7
Noam El
  • 225
  • 2
  • 8
17

MongoDB has introduced ArrayFilters to tackle this issue in Version 3.5.2 and later.

New in version 3.6.

Starting in MongoDB 3.6, when updating an array field, you can specify arrayFilters that determine which array elements to update.

[https://docs.mongodb.com/manual/reference/method/db.collection.update/#specify-arrayfilters-for-an-array-update-operations][1]

Let's say the Schema design as follows :

var ProfileSchema = new Schema({
    name: String,
    albums: [{
        tour_name: String,
        images: [{
            title: String,
            image: String
        }]
    }]
});

And Document created looks like this :

{
   "_id": "1",
   "albums": [{
            "images": [
               {
                  "title": "t1",
                  "url": "url1"
               },
               {
                  "title": "t2",
                  "url": "url2"
               }
            ],
            "tour_name": "london-trip"
         },
         {
            "images": [.........]: 
         }]
}

Say I want to update the "url" of an image. Given - "document id", "tour_name" and "title"

For this the update query :

Profiles.update({_id : req.body.id},
    {
        $set: {

            'albums.$[i].images.$[j].title': req.body.new_name
        }
    },
    {
        arrayFilters: [
            {
                "i.tour_name": req.body.tour_name, "j.image": req.body.new_name   // tour_name -  current tour name,  new_name - new tour name 
            }]
    })
    .then(function (resp) {
        console.log(resp)
        res.json({status: 'success', resp});
    }).catch(function (err) {
    console.log(err);
    res.status(500).json('Failed');
})
NIKHIL C M
  • 3,873
  • 2
  • 28
  • 35
5

This is a very OLD bug in MongoDB

https://jira.mongodb.org/browse/SERVER-831

shA.t
  • 16,580
  • 5
  • 54
  • 111
victor sosa
  • 899
  • 13
  • 27
  • 1
    it seems that mongodb doesn't support this feature yet. – victor sosa Jun 01 '15 at 14:21
  • It's fixed. https://jira.mongodb.org/browse/SERVER-831 But this feature is available starting with the MongoDB 3.5.12 development version. – 6339 Sep 01 '17 at 10:43
5

I was facing same kind of problem today, and after lot of exploring on google/stackoverflow/github, I figured arrayFilters are the best solution to this problem. Which would work with mongo 3.6 and above. This link finally saved my day: https://thecodebarbarian.com/a-nodejs-perspective-on-mongodb-36-array-filters.html

const OrganizationInformationSchema = mongoose.Schema({
user: {
    _id: String,
    name: String
},
organizations: [{
    name: {
        type: String,
        unique: true,
        sparse: true
    },
    rosters: [{
        name: {
            type: String
        },
        designation: {
            type: String
        }
    }]
}]
}, {
    timestamps: true
});

And using mongoose in express, updating the name of roster of given id.

const mongoose = require('mongoose');
const ControllerModel = require('../models/organizations.model.js');
module.exports = {
// Find one record from database and update.
findOneRosterAndUpdate: (req, res, next) => {
    ControllerModel.updateOne({}, {
        $set: {
            "organizations.$[].rosters.$[i].name": req.body.name
        }
    }, {
        arrayFilters: [
            { "i._id": mongoose.Types.ObjectId(req.params.id) }
        ]
    }).then(response => {
        res.send(response);
    }).catch(err => {
        res.status(500).send({
            message: "Failed! record cannot be updated.",
            err
        });
    });
}
}
THE INN-VISIBLE
  • 155
  • 1
  • 8
4

It's fixed. https://jira.mongodb.org/browse/SERVER-831

But this feature is available starting with the MongoDB 3.5.12 development version.

Note: This question asked on Aug 11 2013 and it's resolved on Aug 11 2017

6339
  • 475
  • 3
  • 16
3

Given how MongoDB doesn't appear to provide a good mechanism for this, I find it prudent to use mongoose to simply extract the element from the mongo collection using .findOne(...), run a for-loop search on its relevant subelements (seeking by say ObjectID), modify that JSON, then do Schema.markModified('your.subdocument'); Schema.save(); It's probably not efficient, but it is very simple and works fine.

Engineer
  • 8,529
  • 7
  • 65
  • 105
2

I searched about this for about 5 hours and finally found the best and easiest solution: HOW TO UPDATE NESTED SUB-DOCUMENTS IN MONGO DB

{id: 1, 
forecasts: [ { 
         forecast_id: 123, 
         name: "Forecast 1", 
         levels: [ 
            { 
                levelid:1221
                levelname: "proven", 
                configs: [
                        { 
                          config: "Custom 1",
                          variables: [{ x: 1, y:2, z:3}]
                        }, 
                        { 
                          config: "Custom 2",
                          variables: [{ x: 10, y:20, z:30}]
                        }, 
                ]
            }, 
            { 
                levelid:1221
                levelname: "likely", 
                configs: [
                        { 
                          config: "Custom 1",
                          variables: [{ x: 1, y:2, z:3}]
                        }, 
                        { 
                          config: "Custom 2",
                          variables: [{ x: 10, y:20, z:30}]
                        }, 
                ]
            }
        ]
    }, 
]}

Query:

db.weather.updateOne({
                "_id": ObjectId("1"), //this is level O select
                "forecasts": {
                    "$elemMatch": {
                        "forecast_id": ObjectId("123"), //this is level one select
                        "levels.levelid": ObjectId("1221") // this is level to select
                    }
                }
            },
                {
                    "$set": {
                        "forecasts.$[outer].levels.$[inner].levelname": "New proven",
                    }
                },
                {
                    "arrayFilters": [
                        { "outer.forecast_id": ObjectId("123") }, 
                        { "inner.levelid": ObjectId("1221") }
                    ]
                }).then((result) => {
                    resolve(result);
                }, (err) => {
                    reject(err);
                });
1

Sharing my lessons learned. I faced the same requirement recently where i need to update a nested array item. My structure is as follows

  {
    "main": {
      "id": "ID_001",
      "name": "Fred flinstone Inc"
    },
    "types": [
      {
        "typeId": "TYPE1",
        "locations": [
          {
            "name": "Sydney",
            "units": [
              {
                "unitId": "PHG_BTG1"
              }
            ]
          },
          {
            "name": "Brisbane",
            "units": [
              {
                "unitId": "PHG_KTN1"
              },
              {
                "unitId": "PHG_KTN2"
              }
            ]
          }
        ]
      }
    ]
  }

My requirement is to add some fields in a specific units[]. My solution is first to find the index of the nested array item (say foundUnitIdx) The two techniques I used are

  1. use the $set keyword
  2. specify the dynamic field in $set using the [] syntax

                query = {
                    "locations.units.unitId": "PHG_KTN2"
                };
                var updateItem = {
                    $set: {
                        ["locations.$.units."+ foundUnitIdx]: unitItem
                    }
                };
                var result = collection.update(
                    query,
                    updateItem,
                    {
                        upsert: true
                    }
                );
    

Hope this helps others. :)

erickyi2006
  • 216
  • 2
  • 8
  • i think u need at least v3.4 (although doco said it was bug that was fixed in v3.5) fyi I was using v3.4 successfully – erickyi2006 Jan 03 '18 at 19:33
  • Thank you. Do you know How can we implement in 3.2 and older versions? – NIKHIL C M Jan 04 '18 at 04:51
  • sorry for the belated reply. in v3.2, it supports $position (since v2.6) in update. you may try to use it. I have not tried it meself. good luck – erickyi2006 Jan 08 '18 at 05:56
0

EASY SOLUTION FOR Mongodb 3.2+ https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/

I had a similar situation and solved it like this. I was using mongoose, but it should still work in vanilla MongoDB. Hope it's useful to someone.

const MyModel = require('./model.js')
const query = {id: 1}

// First get the doc
MyModel.findOne(query, (error, doc) => {

    // Do some mutations
    doc.foo.bar.etc = 'some new value'

    // Pass in the mutated doc and replace
    MyModel.replaceOne(query, doc, (error, newDoc) => {
         console.log('It worked!')
    })
}

Depending on your use case, you might be able to skip the initial findOne()

AaronCoding
  • 158
  • 1
  • 6
-1

Okkk.we can update our nested subdocument in mongodb.this is our schema.

var Post = new mongoose.Schema({
    name:String,
    post:[{
        like:String,
        comment:[{
            date:String,
            username:String,
            detail:{
                time:String,
                day:String
            }
        }]
    }]
})

solution for this schema

  Test.update({"post._id":"58206a6aa7b5b99e32b7eb58"},
    {$set:{"post.$.comment.0.detail.time":"aajtk"}},
          function(err,data){
//data is updated
})
coder
  • 540
  • 1
  • 5
  • 17