Search code examples
mongodbmongoosenosqlbson

Mongoose to get the exact object from a nested array of objects


I want to retrieve the exact object from an array of nested objects using a mongoose query. I have tried some queries using $elemMatch but not getting the exact object I am targeting rather I get the array of objects back.

My BSON is

{
  "_id": {
    "$oid": "622bcb7a1091ddd45201258e"
  },
  "type": "Humans",
  "person": [
    {
      "name": "Name Person 1",
      "visited": [],
      "_id": {
        "$oid": "622bdjnmyi30e62d6d166ebd"
      }
    },
    {
      "name": "Name Person 2",
      "visited": [
        {
          "country": "Country 1",
          "year": "2022",
          "id": "zuPks8cv3n"
        }
      ],
      "_id": {
        "$oid": "622bdopmLks0e62d6d166ebe"
      }
    },
    {
      "name": "Name Person 3",
      "_id": {
        "$oid": "622bdpo8bnj0e62d6d166ebf"
      },
      "visited": [
        {
          "country": "Country 3",
          "year": "2029",
          "id": "l2Opx489xb"
        },
        {
          "country": "Country 4",
          "year": "2002",
          "id": "s09zbHYjIp"
        }
      ]
    },
    {
      "name": "Name Person 4",
      "visited": [],
      "_id": {
        "$oid": "622bdb9eio0sbt2d6d166ec0"
      }
    }
  ],
  "__v": {
    "$numberInt": "0"
  }
}

I am targeting "Name Person 3" with the array of objects "visited" and object with id "l2Opx489xb".

My expected result is:

{
  "country": "Country 3",
  "year": "2029",
  "id": "l2Opx489xb"
}

Solution

  • This is a variation of the common problem where an attempt to match a single item in an array yields the entire array. The issue is not "matching" because the match is correct; it is a question of projection. After we find a match, we only wish to project specific items in the array or the rest of the doc in general.

    Here is a solution that has a single stage and no $unwind. In general, between $filter, $map, and $reduce it is possible to extract data from single documents without $unwind, which can be expensive. Read the comments from "inside-out" starting with #1.

    db.foo.aggregate([
        {$replaceRoot: {newRoot: // #7 ...and make this the root object
            {$first: // #6 like #2, turn the array of 1 into a single object.
                {$filter: {  // #4 ... and now we filter the 'visited' array...
                    input: {$let: {
                        vars: {qq: {$first: // #2  $filter will yield an array of 0 or 1;
                                            // use $first to turn into one object
                                    // #1 Find Name Person 3
                                    {$filter: {
                                    input: '$person',
                                    cond: {$eq:['$$this.name','Name Person 3']}
                                    }}
                               }},
                         //  #3  We wish we could say $first.visited in #2 but we cannot
                         //  so we use $let and the vars setup to allow us to get to
                         //  the inner array 'visited':
                         in: '$$qq.visited'}
                     },
                     cond: {$eq:['$$this.id','l2Opx489xb']} // #5 to match target id
                }}
            }
        }}
    ]);
    
    { "country" : "Country 3", "year" : "2029", "id" : "l2Opx489xb" }
    

    NOTE: $first arrived in v4.4. For earlier version, instead of

    {$first: <expression that yields array>}
    

    use this instead:

    {$arrayElemAt: [ <expression that yields array>, 0]
    

    $first is a little cleaner because you do not have the ,0 "dangling" at the end of a complex expression.

    Below is the "expanded" version plus an extra check against $X evaluating to null (if Name Person 3 or target id is not present) because null cannot be passed to $replaceRoot:

    db.foo.aggregate([
        {$project: {
            X: {$first: {$filter: {
                input: '$person',
                cond: {$eq:['$$this.name','Name Person 3']}
            }} }
        }}
    
        ,{$project: {
            X: {$first: {$filter: {
                input: '$X.visited',
                cond: {$eq:['$$this.id','l2Opx489xb']}
            }} }
        }}
    
        ,{$match: {X: {$ne: null}} }
    
        ,{$replaceRoot: {newRoot: '$X'}}
    ]);
    

    The OP has expanded the question to include 1. removal of an item in the visited array 2. update on peer fields to an id match in the visited array. There are two approaches: easy and "artsy" that explores some of the power of pipeline functions.

    EASY

    To update year and country:

    db.foo.update(                                                                         
        {'person.name':'Name Person 3'}                                                       
        ,{$set: {                                                                             
            'person.$[p].visited.$[v].year':'1999',                                           
            'person.$[p].visited.$[v].country':'ZOOP'                                         
        }}                                                                                    
        ,{arrayFilters: [ {'p.name':'Name Person 3'},{'v.id':'l2Opx489xb'} ] }                
    ); 
    

    To remove the target id object entirely from the visited array:

    db.foo.update(
        {'person.name':'Name Person 3'}
        ,{$pull: {'person.$.visited': {'id':'l2Opx489xb'}}}
    );
    

    ARTSY

    rc=db.foo.update(
        // Use dot notation for match to cut down initial input set.  
        // Remember:  This will NOT find the exact offset into the
        // array where Name Person 3 may be found, only that it is 
        // *somewhere* in that array.  But this is good to filter out
        // things where NO update is needed.  The pipeline below will
        // hunt for and remove the target id.
        {'person.name':'Name Person 3'},
    
        [{$set: {  // pipeline form of update
        // Use $reduce to walk the person array and essentially
        // rebuild it but only operating on those entries where
        // name is 'Name Person 3'
        person: {$reduce: {
            input: '$person',
            initialValue: [],
    
            // $concatArrays wants 2 arrays.  The first is our ever-growing
            // $$value.  The second is a candidate object -- but we have to
            // turn THAT into an array of 1, so watch for the [ ] wrapper around
            // the outer $cond expression.
            in:{$concatArrays: [ "$$value", [
              {$cond: [
                {$ne:['$$this.name','Name Person 3']}, // if NOT our target
                '$$this', // THEN pass to concatArrays unchanged (noop)
    
                // ELSE
    
                // To DELETE the entire object:
                // overwrite 'visited' with filtered version
                // of same.  This lets all other vars like name
                // and anything else pass thru unchanged.
                {$mergeObjects: [ '$$this', 
                          {visited: {$filter: {
                          input: '$$this.visited',
                          as: 'qq',
                          cond: {$ne:['$$qq.id','l2Opx489xb']}
                          }}
                  }
                ]}
    
                /*
                  To UPDATE year and country in the target, use
                  this instead instead of the $mergeObjects call
                  just above.  Choose one or the other; both cannot
                  be active at same time.
    
                {$mergeObjects: [ '$$this', 
                      {visited: {$map: {  // $map this time, not $filter
                      input: '$$this.visited',
                      as: 'qq',
                      in: {$cond:[
                          {$ne:['$$qq.id','l2Opx489xb']},
                          '$$qq', // THEN return unchanged
    
                          // ELSE overwrite year and country
                          // fields of the object:
                          {$mergeObjects: [ '$$qq', 
                                {year:"1999",
                                 country:"ZPONC"}
                                  ]}
                      ]}
                      }}
                      }
                ]}
                */
             ]}
            ]] }
        }}
        }}
        ]
        // ,{multi:true}  // if Name Person 3 exists in more than 1 doc....
    );