Collection.update() only updates first item in embedded array?


#1

Hi guys,

I am trying to use collection2 in combination with collection-hooks to keep 2 collections in sync.

Its an icecreamshop test-project. There is a “Scoop” collection and an Order collection. Basically a customer can oder multiple scoops. The scoop-instance is then embedded within the Order-Collection.

The problem is that when I change the name of a scoop in the “Scoop”-Collection,
the Scoops.after.update triggers, but only updates the first element in the order collection.

How do you guys handle this in meteor? (I think it is a mongodb problem? http://stackoverflow.com/questions/4669178/how-to-update-multiple-array-elements-in-mongodb)

This is the code I have:

AutoformScoopOrders = new Mongo.Collection('autoformscooporders');
// SCHEMA
autoformScoopOrdersSchema = new SimpleSchema({
  // Customer-Name
  customerName: {
    type: String
  },
  // scoopItems
  scoopItems: {
    type: Array,  // 1 invoice can have m invoiceItems
    optional: true,
  },
  'scoopItems.$': {
    type: Object
  },
  'scoopItems.$.amount': {
    type: Number,
  },
  'scoopItems.$.scoopId': {
    type: String,
    autoform: {  // Foreign-Key
      options: function () {
        return Scoops.find().map(function (c) {
          return {label: c.name + '( ' + c.price + '€ )', value: c._id};
        });
      }
    },
  },
  'scoopItems.$.scoopInstance': {
    type: Object,
      blackbox: true, // skip validation for all included fiels
      optional: true,
      autoValue: function () {
          var theScoopInstanceId = this.siblingField('scoopId');
          var theScoopInstance = theScoopInstanceId.isSet && Scoops.findOne(theScoopInstanceId.value);
          return _.omit(theScoopInstance, '_id');
      },
      autoform: {
        omit: true,
      },
  },
});
AutoformScoopOrders.attachSchema(autoformScoopOrdersSchema);  // Anhängen an das Schema
// HOOKS
Scoops.after.update(function (userId, doc, fieldNames, modifier, options) {
  if (Meteor.isServer) {
    if (Scoops._transform(doc).hasDataChanged(this.previous)) {
      // ERROR: ONLY THE FIRST ITEM IS UPDATED
      // AM I DOING SOMETHING WRONG?
      nrOfUpdates = AutoformScoopOrders.update( 
        {'scoopItems.scoopId': doc._id}, 
        {$set: { 'scoopItems.$.scoopInstance': doc }},
        {multi: true}
      );
    }
  }
});

Any suggestions?


#2

This should work and update all orders where there is a matching scoopId.

Are you sure there is more than one order with the tested scoopId?

An alternative method would be to update one by one

AutoformScoopOrders.find({'scoopItems.scoopId': doc._id})
                   .forEach(function(order){
                       AutoformScoopOrders.update( 
                           {_id: order._id, 'scoopItems.scoopId': doc._id}, 
                           {$set: { 'scoopItems.$.scoopInstance': doc }}
                       )
                   });

On a non-related topic, you can change your schema to the following:

autoformScoopOrdersSchema = new SimpleSchema({
  // Customer-Name
  customerName: {
    type: String
  },
  // scoopItems
  scoopItems: {
    type: [Object],  // 1 invoice can have m invoiceItems
    optional: true,
  },
  'scoopItems.$.amount': {
    type: Number,
  },
  'scoopItems.$.scoopId': {
    type: String,
    autoform: {  // Foreign-Key
      options: function () {
        return Scoops.find().map(function (c) {
          return {label: c.name + '( ' + c.price + '€ )', value: c._id};
        });
      }
    },
  },
  'scoopItems.$.scoopInstance': {
    type: Object,
      blackbox: true, // skip validation for all included fiels
      optional: true,
      autoValue: function () {
          var theScoopInstanceId = this.siblingField('scoopId');
          var theScoopInstance = theScoopInstanceId.isSet && Scoops.findOne(theScoopInstanceId.value);
          return _.omit(theScoopInstance, '_id');
      },
      autoform: {
        omit: true,
      },
  },
});

Notice the scoopItems top level field definition chaning from Array to [Object] and the omission of the scoopItems.$ field.

On an even more unrelated topic, if I were you, I would not update the scoopInstance field because it represents the scoop at the time of the purchase and is how the customer knows of it and when you change it, from the customer’s point of view, you will have changed the order!


#3

Hi @serkandurusoy,

hey man thanks a lot for your quick and excelent response!!!

I’ll try this out asap and play around with it. This is my first test case. You are totally right: If this was a real invoice in a production system it would be wrong to change the scoop-instance within the order. :smile:

The strange thing is that my mongodb.update command really does NOT work, for example when you have 2 different “vanilla” scoopsItems in an order. then only the first one is updated.

Is this a bug in mongo?

nrOfUpdates = AutoformScoopOrders.update( 
        {'scoopItems.scoopId': doc._id}, 
        {$set: { 'scoopItems.$.scoopInstance': doc }},
        {multi: true}
      );

Example-Data:

{
   "_id":"xerC9iSZEmrotbEhS",
   "customerName":"Jimy",
   "scoopItems":[
      {
         "amount":2,
         "scoopId":"Saqka3yyBxPhHyYp7",
         "scoopInstance":{
            "_id":"Saqka3yyBxPhHyYp7",
            "name":"Vanilla",
            "price":3
         }
      },
      // THIS SECOND INSTANCE WILL **NOT** BE UPDATED
      {
         "amount":2,
         "scoopId":"Saqka3yyBxPhHyYp7",
         "scoopInstance":{
            "_id":"Saqka3yyBxPhHyYp7",
            "name":"Vanilla",
            "price":3
         }
      },
   ]
}

#4

The update function works and it is not a bug.
It is behaving exactly according to documentation.


#5

Oh, now I see your point. When you said you can update only the first element, I though you meant the first document returned by the query selector. But I now understand that you are referring to multiple documents contained within the array of a single document returned by the query selector.

So two points stand:

From a design/business perspective, why would you want two different vanilla items with amount = 2 instead of one vanilla with amount = 4 anyway? You should be combining the order line items if they belong to the same item :slight_smile:

Mongodb positional ($) update updates the first matched item in an array.

… the positional $ operator acts as a placeholder for the first element that matches the query document …

If you want to update all the items that match a query projected over an array, there is no single query atomic way and probably won’t be

To work around this, you could

a) keep order items in a separate collection and update that collection over application level joins by orderItemId reference from that foreign collection
b) read the complete array, iterate it to find and modify the matching items and then do a complete $set of the complete updated array

Personally, I would go for (a) if I were to design an app where I need to update array items in bulk. A good reason not to keep them in arrays.

But if you want to go for (a), you can try this (I have not)

AutoformScoopOrders.find({'scoopItems.scoopId': doc._id})
                   .forEach(function(order){
                       var updatedScoopItems = _.map(order.scoopItems, function(item) {
                           if (item.scoopId === doc._id) item.scoopInstance = _.omit(doc, '_id');
                           return item;
                       });
                       AutoformScoopOrders.update( 
                           {_id: order._id, 'scoopItems.scoopId': doc._id}, 
                           {$set: { scoopItems: updatedScoopItems }}
                       )
                   });