Liang-Shih Lin
Liang-Shih Lin

Reputation: 125

Mongoose Nested Array $push

I am trying to $push an Object into a nested array, however it doesn't seem to be working. I am not sure what I am doing wrong.

My database is like this:

{
  customers: {
    name: String,
    address: String,
    proj_managers: [
      {
        name: String,
        username: String,
        projects: [
          name: String,
          tags: [
            {
              tag_no: Number,
              tag_id: String,
              time: String,
              product_id: String,
              urls: [
                url: String,
                count: Number
              ],
              gps: String,
              deactivate: Boolean
            }
          ]
        ]
      }
    ]
  }
}

So what I want to do is $push an array of tags into tags, for a specific project. My backend uses GraphQL:

/index.js

// GraphQL schema
import schema from './schema'
// Mongoose Models
import Customer from './models/Customer'
import Manager from './models/Manager'
// Access to GraphQL API
app.use('/graphql', bodyParser.json(), graphqlExpress({ schema, context: { Customer, Manager } }))
app.use('/graphiql', graphiqlExpress({ endpointURL: '/graphql' }))

/schema/index.js

import { bundle } from 'graphql-modules'
import { makeExecutableSchema } from 'graphql-tools'

// GraphQL Modules
import Manager from './Manager'
import Customer from './Customer'
import ProjectManager from './ProjectManager'
import Project from './Project'
import Tag from './Tag'
import URL from './URL'

const modules = [Manager, Customer, ProjectManager, Project, Tag, URL]

export default makeExecutableSchema(bundle(modules))

/schema/Project.js

const schema = `
  type Project {
    _id: ID,
    name: String!,
    description: String,
    tags: [Tag],
    deactivate: Boolean!
  }
  input TagInput {
    tagNo: Int!,
    tagId: String!,
    time: String,
    productId: String,
    url1: String,
    url2: String,
    gps: String
  }
`

const queries = `
  projects(customerUsername: String!): [Project],
  project(projectID: ID!): Project
`

const mutations = `
  editProject(id: ID!, name: String, description: String, deactivate: Boolean, customerUsername: String!, pmUsername: String!): String,
  addProject(name: String!, description: String, customerID: ID!, pmUsername: String!): String,
  pushTags(customerID: String!, username: String!, projectID: ID!, isManager: Boolean!, tags: [TagInput]!): String
`

const pushTags = async (root, { tags, customerID, username, projectID, isManager }, { Customer }) => {
  let result = ''
  let query = { _id: customerID }
  let update = {}
  let ts = []
  let options = {
    arrayFilters: [
      { 'a.username': username },
      { 'b._id': projectID }
    ]
  }
  tags.forEach(tag => {
    if (isManager) {
      ts.push({
        tag_no: tag.tagNo,
        tag_id: tag.tagId,
        time: new Date(),
        product_id: tag.productId,
        urls: [
          { url: tag.url1, count: 0 },
          { url: tag.url2, count: 0 }
        ],
        gps: tag.gps,
        deactivate: false
      })
    } else {
      update = {
        $set: {
          'proj_managers.$[a].projects.$[b].tags': {
            product_id: tag.productId,
            urls: [
              { url: tag.url1 },
              { url: tag.url2 }
            ],
            gps: tag.gps
          }
        }
      }
    }
  })
  if (isManager) {
    update = {
      $push: {
        'proj_managers.$[a].projects.$[b].tags': {
          $each: ts
        }
      }
    }
    result = await Customer.update(query, update, options)
  }
  return result.ok && result.nModified ? 'Success' : 'Failed'
}

const resolvers = {
  queries: {
    projects,
    project
  },
  mutations: {
    addProject,
    editProject,
    pushTags
  }
}

export default () => ({
  schema,
  queries,
  mutations,
  resolvers
})

The tags that are being sent to pushTags mutation is:

[
  {
    "tagNo":"1",
    "tagId":"02F9AMCGA38O7L",
    "productId":"",
    "url1":"",
    "url2":"",
    "gps":""
  },{
    "tagNo":"2",
    "tagId":"028MFL6EV5L904",
    "productId":"",
    "url1":"",
    "url2":"",
    "gps":""
  },{
    "tagNo":"3",
    "tagId":"02XDWCIL6W2IIX",
    "productId":"",
    "url1":"",
    "url2":"",
    "gps":""
  }
];

The Document

{
  "_id": ObjectId("5b0216f1cf14851f18e4312b"),
  "deactivate": false,
  "name": "Razer",
  "address": "201 3rd Street, Suite 900 San Francisco, CA 94103 USA",
  "phone_no": "0987654321",
  "proj_managers": [
    {
      "deactivate": false,
      "_id": ObjectId("5b021750cf14851f18e4312c"),
      "name": "Liang-Shih Lin",
      "username": "troopy",
      "phone_no": "0987654321",
      "password": "$2b$10$eOVoRkfmkHQyHkc6XaDanunUuyi0EFy.oZ.dRgKJYxBciMLYUVy0W",
      "projects": [
        {
          "deactivate": false,
          "_id": ObjectId("5b0217d4cf14851f18e4312d"),
          "name": "Razer Godzilla",
          "description": "A Godzilla Mouse",
          "tags": [ ]
        }
      ]
    }
  ],
  "__v": 0
}

I have tried using findByIdAndUpdate, updateOne, using a forEach() helper function to loop through the tags and $push it into the database one by one, but nothing seems to be working. I thought it could be my arrayFilters, I changed b._id to b.name but that didn't work either.

I tried using this in the mongo shell, with this query:

db.customers.update({ _id: "5afe642ed42aee261cb3292e" }, { $push: { "proj_managers.$[a].projects.$[b].tags": { tag_no: 1, tag_id: "0476F06A594980", time: "2018-05-20T23:18:18.824Z", product_id: "xr235Yf4", urls: [{url: "example.com", count: 0}, {url: "example2.com", count: 0}], gps: "", deactivate: false} } }, { arrayFilters: [{ "a.username": "joliver" }, { "b.id": "5b01367b6d053860e90e0f9f" }] })

The result:

WriteResult({
  "nMatched": 0,
  "nUpserted": 0,
  "nModified": 0
})

If you want to take a look at the whole project, here is the link

Upvotes: 4

Views: 3805

Answers (1)

Neil Lunn
Neil Lunn

Reputation: 151102

What you missed in your attempts is that arrayFilters entries do not "autocast" like other properties in mongoose operations based on what value the "schema" has. This is because there is nothing there which actually ties the condition to a specific detail in the defined schema, or at least as far as the current mongoose release processes it.

Therefore if you match to an _id within arrayFilters, you need to actually "cast" the ObjectId value yourself where the source comes from a "string":

let updated = await Customer.findOneAndUpdate(
  {
    "_id": "5b0216f1cf14851f18e4312b",              //<-- mongoose can autocast these
    "proj_managers": {
      "$elemMatch": {
        "username": "troopy",
        "projects._id": "5b0217d4cf14851f18e4312d" //<-- here as well
      }
    }
  },
  {
    "$push": {
      "proj_managers.$[a].projects.$[b].tags": { "$each": tags }
    }
  },
  {
    "new": true,
    // But not in here
    "arrayFilters": [
      { "a.username": "troopy" },
      { "b._id": ObjectId("5b0217d4cf14851f18e4312d") }  // <-- Cast manually
    ]
  }
);

And then you get the result you should. Cutting it down a bit just for demonstration:

{
  "_id": "5b0216f1cf14851f18e4312b",
  "name": "Bill",
  "address": "1 some street",
  "proj_managers": [
    {
      "projects": [
        {
          "tags": [
            {
              "_id": "5b0239cc0a7a34219b0efdab",
              "tagNo": 1,
              "tagId": "02F9AMCGA38O7L",
              "productId": "",
              "url1": "",
              "url2": "",
              "gps": ""
            },
            {
              "_id": "5b0239cc0a7a34219b0efdaa",
              "tagNo": 2,
              "tagId": "028MFL6EV5L904",
              "productId": "",
              "url1": "",
              "url2": "",
              "gps": ""
            },
            {
              "_id": "5b0239cc0a7a34219b0efda9",
              "tagNo": 3,
              "tagId": "02XDWCIL6W2IIX",
              "productId": "",
              "url1": "",
              "url2": "",
              "gps": ""
            }
          ],
          "_id": "5b0217d4cf14851f18e4312d",
          "name": "Razer Godzilla"
        }
      ],
      "_id": "5b021750cf14851f18e4312c",
      "name": "Ted",
      "username": "troopy"
    }
  ],
  "__v": 0
}

So the main thing here is to import the ObjectId method from Types.ObjectId and actually cast any strings you have. Input from external requests are typically "strings".

So for now, whenever you want such values in combination with matches for the positional filtered $[<identifier>] operator and arrayFilters just remember to actually "cast types".

Note that using $elemMatch here for the same matching criteria on the array is not actually a "requirement" but probably should always be considered best practice. The reason being that whilst the arrayFilters conditions will actually decide the selection of what actually gets altered, backing that up with a "query" condition to ensure the same conditions do exist on the array simply makes sure the document never even gets considered, and this actually reduces processing overhead.

Also note that because you are using a unique _id value within each array member inherent to mongoose schema, then you can essentially "get away with":

let wider = await Customer.findOneAndUpdate(
  { "_id": "5b0216f1cf14851f18e4312b" },
  { "$push": {
    "proj_managers.$[].projects.$[b].tags": { "$each": extra }
  }},
  {
    "new": true,
    "arrayFilters": [
      { "b._id": ObjectId("5b0217d4cf14851f18e4312d") }
    ]
  }
);

So actually using the positional all $[] instead and as mentioned simply skipping the other conditions in favor of the "unique" ObjectId values. It seems lighter, but it actually adds a few cpu cycles by unnecessarily checking potentially various array paths, not to mention the match on the document itself if the array simply did not meet the other conditions.

I also can't really leave this without the "caveat" where even though modern MongoDB releases will support this, it's still really not advisable to have nested arrays. Yes it's possible to update them with modern features, but it's still far more complicated to "query" them than using a much flatter array structure or even completely flattened data in a separate collection, depending on the needs.

There's more description at Updating a Nested Array with MongoDB and Find in Double Nested Array MongoDB, but in most cases it really is true that the perceived "organizing" of structuring as "nested" actually does not exist, and it's really more of a hindrance.

And the full listing to demonstrate the working update:

const { Schema, Types: { ObjectId } } = mongoose = require('mongoose');

const uri = 'mongodb://localhost/test';

mongoose.Promise = global.Promise;
mongoose.set('debug', true);

const tagSchema = new Schema({
  tagNo: Number,
  tagId: String,
  productId: String,
  url1: String,
  url2: String,
  gps: String
})

const projectSchema = new Schema({
  name: String,
  tags: [tagSchema]
})

const projManagerSchema = new Schema({
  name: String,
  username: String,
  projects: [projectSchema]
});

const customerSchema = new Schema({
  name: String,
  address: String,
  proj_managers: [projManagerSchema]
});


const Customer = mongoose.model('Customer', customerSchema);

const log = data => console.log(JSON.stringify(data, undefined, 2));

(async function() {

  try {

    const conn = await mongoose.connect(uri);

    await Promise.all(Object.entries(conn.models).map(([k,m]) => m.remove()));

    await Customer.create({
      _id: "5b0216f1cf14851f18e4312b",
      name: 'Bill',
      address: '1 some street',
      proj_managers: [
        {
          _id: "5b021750cf14851f18e4312c",
          name: "Ted",
          username: "troopy",
          projects: [
            {
              _id: "5b0217d4cf14851f18e4312d",
              name: "Razer Godzilla",
              tags: [  ]
            }
          ]
        }
      ]
    });

    const tags = [
      {
        "tagNo":"1",
        "tagId":"02F9AMCGA38O7L",
        "productId":"",
        "url1":"",
        "url2":"",
        "gps":""
      },{
        "tagNo":"2",
        "tagId":"028MFL6EV5L904",
        "productId":"",
        "url1":"",
        "url2":"",
        "gps":""
      },{
        "tagNo":"3",
        "tagId":"02XDWCIL6W2IIX",
        "productId":"",
        "url1":"",
        "url2":"",
        "gps":""
      }
    ];

    const extra = [{
      "tagNo":"4",
      "tagId":"02YIVGMFZBC9OI",
      "productId":"",
      "url1":"",
      "url2":"",
      "gps":""
    }];

    let cust = await Customer.findOne({
      "_id": "5b0216f1cf14851f18e4312b",
      "proj_managers": {
        "$elemMatch": {
          "username": "troopy",
          "projects._id": "5b0217d4cf14851f18e4312d"
        }
      }
    });
    log(cust);
    let updated = await Customer.findOneAndUpdate(
      {
        "_id": "5b0216f1cf14851f18e4312b",
        "proj_managers": {
          "$elemMatch": {
            "username": "troopy",
            "projects._id": "5b0217d4cf14851f18e4312d"
          }
        }
      },
      {
        "$push": {
          "proj_managers.$[a].projects.$[b].tags": { "$each": tags }
        }
      },
      {
        "new": true,
        "arrayFilters": [
          { "a.username": "troopy" },
          { "b._id": ObjectId("5b0217d4cf14851f18e4312d") }
        ]
      }
    );
    log(updated);

    let wider = await Customer.findOneAndUpdate(
      { "_id": "5b0216f1cf14851f18e4312b" },
      { "$push": {
        "proj_managers.$[].projects.$[b].tags": { "$each": extra }
      }},
      {
        "new": true,
        "arrayFilters": [
          { "b._id": ObjectId("5b0217d4cf14851f18e4312d") }
        ]
      }
    );
    log(wider);

    mongoose.disconnect();

  } catch(e) {
    console.error(e)
  } finally {
    process.exit()
  }

})()

Upvotes: 5

Related Questions