3

how can I target a last item in array on an instance of that schema:

let TrackerSchema = new Schema({
  status: String,
  start_date: { type: Date, default: Date.now },
  end_date: { type: Date },
  companyId: { type: mongoose.Schema.Types.ObjectId, ref: "Company" },
  userId: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
  pauses: Array,
  total_today: Number
});

instance of a tracker looks like that:

{
    "pauses": [{
        "reason": "Lanch",
        "start": "2018-11-21T18:13:22.835Z",
        "end": "2018-11-21T18:14:30.835Z"
    }, {
        "reason": "Lanch",
        "start": "2018-11-21T18:15:09.057Z"
    }],
    "_id": "...",
    "status": "pause",
    "start_date": "2018-11-21T18:12:43.896Z",
    "companyId": "...",
    "userId": "...",
    "__v": 2
}

I need to get last item in pauses and add an "end" property to it, I tried like that and it doesn't save it, and seems that the right way is to use something like update or findOneAndUpdate?

Tracker.findOne(query, (error, tracker) => {
  checkForError(res, error);
  let date = new Date();
  let lastItem = tracker.pauses.length;
  tracker.pauses[lastItem - 1].end = date;
  tracker.status = "go";
  tracker.save(error => {
    checkForError(res, error);
    res.send(tracker);
  });
});
Raza Rafaideen
  • 2,119
  • 1
  • 19
  • 30
Felix
  • 55
  • 9

1 Answers1

2

Schema Problems

The basic reason this is not updating for you is because Mongoose actually demands to be told that such changes actually did something. This is essentially related to how it keeps internal references and then decides what to send back to the server as an update() statement when you actually call save().

The "simple" fix for this is to just call the markModified() method on the path before you save():

tracker.markModified('pauses');
tracker.save((error) => {

There is however another way, since the only reason you ever need to call markModified() is because mongoose has no concept of what you actually changed on the underlying element. Typically people encounter this on the Mixed type. But in your case you defined Array on pauses without specifying he structure of the schema for the items within that array.

Therefore simply adding schema allows the changes without calling markModified()

// pauses: Array // change this line
pauses: [{
  reason: String,
  start: Date,
  end: Date
}]

Once you do either of those the changes to the array element will actually be persisted, but as you note the whole process of findOne() and then making these changes and calling save() is not really ideal.

As such, there is a way to to this atomically, but it does require you make some changes.

Atomic Updates

The core problem here is updating the last element of the array. MongoDB does not have any real method for either querying or updating the last element in any efficient manner. This is not a new problem, but the common line is that the solution to this has been around for some time.

Reverse Sort the array

The basic principle is that as you add items to your array you can actually atomically sort all of the elements in the array by a given property so that there is always a certain array order. The reason this is a reverse sort is that what MongoDB is actually very happy with is the first array element.

Therefore in order to keep the array elements ordered so the latest is always the first element, you can apply the $sort modifier to $push as follows:

vat newPauses = [{ reason: "Next", start: new Date() }];

Tracker.findOneAndUpdate(
  { _id },
  { 
    '$push': {
      'pauses': { '$each': newPauses, '$sort': { start: -1 } }
    }
  },
  { new: true }
)

That is what your code would look like every time you want to add to the array. Because the new item has a more recent date value in start the $sort modifier will re-arrange he array so that this most recent item is actually at the beginning.

You can even update all existing documents in your collection with one simple statement:

Tracker.updateMany(
  {},
  { 
    '$push': {
      'pauses': { '$each': [], '$sort': { start: -1 } }
    }
  }
)

In this case the $each is given an empty array, so of course no new items are added to the arrays in any document. But the $sort is fired and all items will then be re-ordered by their start time.

This method using $sort suits a lot of people, but there may be cases where it is not the right solution. This is why there is another way.

Prepend to the array

The general case here is where you consider that either you don't really have a property such as the start which should govern what the order of elements in the array actually is, or even in cases where you don't want to have any possible overhead of applying a $sort or move existing elements around in any way.

In order to achieve this all that is needed is to add the $position modifier to your $push statement:

vat newPauses = [{ reason: "Next", start: new Date() }];

Tracker.findOneAndUpdate(
  { _id },
  { 
    '$push': {
      'pauses': { '$each': newPauses, '$position': 0 }
    }
  },
  { new: true }
)

In short, $position tells MongoDB where the new element is supposed to go by array index. The 0 of course means the beginning of the array, and this will move all existing members along to the right rather than adding a new member to the right side.

The only drawback here is that you cannot really update the existing array members in a purely reversed order without running a process to loop through all documents and rewrite them in the collection as there is no atomic update that can reverse the whole array in the same way the $sort can modify the existing array. So the setup cost if changing to using $position is a little higher.

If you want this type of approach, then the methods for updating your existing collection are extensively documented at Update MongoDB field using value of another field

Demonstration

Of course these are best described by demonstration. Here is a full listing demonstrating all approaches:

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

const uri = 'mongodb://localhost:27017/trackdemo';
const opts = { useNewUrlParser: true };

// sensible defaults
mongoose.Promise = global.Promise;
mongoose.set('debug', true);
mongoose.set('useFindAndModify', false);
mongoose.set('useCreateIndex', true);

// schema defs

const pauseSchema = new Schema({
  reason: String,
  start: Date,
  end: Date
})

const trackerSchema = new Schema({
  pauses: Array
});

const altTrackerSchema = new Schema({
  pauses: [pauseSchema]
});

const Tracker = mongoose.model('Tracker', trackerSchema);
const AltTracker = mongoose.model('AltTracker', altTrackerSchema);

// log helper

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

// main

const getPauses = () => [
  {
    reason: "Lunch",
    start: new Date("2018-11-21T18:13:22.835Z"),
    end: new Date("2018-11-21T18:14:30.835Z")
  },
  {
    reason: "Lunch",
    start: new Date("2018-11-21T18:15:09.057Z")
  }
];

(async function() {

  try {

    const conn = await mongoose.connect(uri, opts);

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

    // Test Tracker
    await (async function() {
      let pauses = getPauses();
      let tracker = await Tracker.create({ pauses });
      log(tracker);

      let temp = tracker.pauses[tracker.pauses.length - 1];
      temp.end = new Date();
      log(temp);

      tracker.pauses[tracker.pauses.length -1] = temp;
      tracker.markModified('pauses');
      await tracker.save();

      let result = await Tracker.findById(tracker._id);
      log(result);
    })()

    // Test AltTracker
    await (async function() {
      let pauses = getPauses();
      let tracker = await AltTracker.create({ pauses });
      log(tracker);

      let temp = tracker.pauses[tracker.pauses.length - 1];
      temp.end = new Date();
      log(temp);

      tracker.pauses[tracker.pauses.length -1] = temp;
      //tracker.markModified('pauses');
      await tracker.save();

      let result = await AltTracker.findById(tracker._id);
      log(result);
    })()

    // AltTracker atomic $sort method
    await (async function() {

      let _id = new ObjectId();         // keep ref for tests
      let pauses = getPauses();

      let tracker = await AltTracker.findOneAndUpdate(
        { _id },
        {
          '$push': {
            'pauses': { '$each': pauses, '$sort': { 'start': -1 } }
          }
        },
        { 'new': true, 'upsert': true }
      );
      log(tracker);

      // update first

      tracker = await AltTracker.findOneAndUpdate(
        { _id, 'pauses.0.end': { '$exists': false } },
        { '$set': { 'pauses.0.end': new Date() } },
        { 'new': true }
      );
      log(tracker);

    })()

    // AltTracker atomic $position method
    await (async function() {

      let _id = new ObjectId();         // keep ref for tests
      let pauses = getPauses();

      // Doing this twice purely for demo

      let tracker = await AltTracker.findOneAndUpdate(
        { _id },
        {
          '$push': {
            'pauses': { '$each': [ pauses[0] ], '$position': 0 }
          }
        },
        { 'new': true, 'upsert': true }
      );
      log(tracker);

      tracker = await AltTracker.findOneAndUpdate(
        { _id },
        {
          '$push': {
            'pauses': { '$each': [ pauses[1] ], '$position': 0 }
          }
        },
        { 'new': true, 'upsert': true }
      );
      log(tracker);

      tracker = await AltTracker.findOneAndUpdate(
        { _id, 'pauses.0.end': { '$exists': false } },
        { '$set': { 'pauses.0.end': new Date() } },
        { 'new': true }
      );
      log(tracker);

    })()

  } catch(e) {
    console.error(e)
  } finally {
    mongoose.disconnect()
  }

})()

And the output demonstrating what happens on all updates:

Mongoose: trackers.deleteMany({}, {})
Mongoose: alttrackers.deleteMany({}, {})
Mongoose: trackers.insertOne({ pauses: [ { reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:13:22 GMT"), end: new Date("Wed, 21 Nov 2018 18:14:30 GMT") }, { reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:15:09 GMT") } ], _id: ObjectId("5bf65cf6ae7b8639c3f5090d"), __v: 0 })
{
  "pauses": [
    {
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    },
    {
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z"
    }
  ],
  "_id": "5bf65cf6ae7b8639c3f5090d",
  "__v": 0
}
{
  "reason": "Lunch",
  "start": "2018-11-21T18:15:09.057Z",
  "end": "2018-11-22T07:38:30.883Z"
}
Mongoose: trackers.updateOne({ _id: ObjectId("5bf65cf6ae7b8639c3f5090d"), __v: 0 }, { '$set': { pauses: [ { reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:13:22 GMT"), end: new Date("Wed, 21 Nov 2018 18:14:30 GMT") }, { reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:15:09 GMT"), end: new Date("Thu, 22 Nov 2018 07:38:30 GMT") } ] }, '$inc': { __v: 1 } })
Mongoose: trackers.findOne({ _id: ObjectId("5bf65cf6ae7b8639c3f5090d") }, { projection: {} })
{
  "pauses": [
    {
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    },
    {
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z",
      "end": "2018-11-22T07:38:30.883Z"
    }
  ],
  "_id": "5bf65cf6ae7b8639c3f5090d",
  "__v": 1
}
Mongoose: alttrackers.insertOne({ _id: ObjectId("5bf65cf6ae7b8639c3f5090e"), pauses: [ { _id: ObjectId("5bf65cf6ae7b8639c3f50910"), reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:13:22 GMT"), end: new Date("Wed, 21 Nov 2018 18:14:30 GMT") }, { _id: ObjectId("5bf65cf6ae7b8639c3f5090f"), reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:15:09 GMT") } ], __v: 0 })
{
  "_id": "5bf65cf6ae7b8639c3f5090e",
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50910",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    },
    {
      "_id": "5bf65cf6ae7b8639c3f5090f",
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z"
    }
  ],
  "__v": 0
}
{
  "_id": "5bf65cf6ae7b8639c3f5090f",
  "reason": "Lunch",
  "start": "2018-11-21T18:15:09.057Z",
  "end": "2018-11-22T07:38:30.915Z"
}
Mongoose: alttrackers.updateOne({ _id: ObjectId("5bf65cf6ae7b8639c3f5090e"), __v: 0 }, { '$set': { 'pauses.1.end': new Date("Thu, 22 Nov 2018 07:38:30 GMT") } })
Mongoose: alttrackers.findOne({ _id: ObjectId("5bf65cf6ae7b8639c3f5090e") }, { projection: {} })
{
  "_id": "5bf65cf6ae7b8639c3f5090e",
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50910",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    },
    {
      "_id": "5bf65cf6ae7b8639c3f5090f",
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z",
      "end": "2018-11-22T07:38:30.915Z"
    }
  ],
  "__v": 0
}
Mongoose: alttrackers.findOneAndUpdate({ _id: ObjectId("5bf65cf6ae7b8639c3f50911") }, { '$setOnInsert': { __v: 0 }, '$push': { pauses: { '$each': [ { _id: ObjectId("5bf65cf6ae7b8639c3f50913"), reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:13:22 GMT"), end: new Date("Wed, 21 Nov 2018 18:14:30 GMT") }, { _id: ObjectId("5bf65cf6ae7b8639c3f50912"), reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:15:09 GMT") } ], '$sort': { start: -1 } } } }, { upsert: true, remove: false, projection: {}, returnOriginal: false })
{
  "_id": "5bf65cf6ae7b8639c3f50911",
  "__v": 0,
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50912",
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z"
    },
    {
      "_id": "5bf65cf6ae7b8639c3f50913",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    }
  ]
}
Mongoose: alttrackers.findOneAndUpdate({ _id: ObjectId("5bf65cf6ae7b8639c3f50911"), 'pauses.0.end': { '$exists': false } }, { '$set': { 'pauses.0.end': new Date("Thu, 22 Nov 2018 07:38:30 GMT") } }, { upsert: false, remove: false, projection: {}, returnOriginal: false })
{
  "_id": "5bf65cf6ae7b8639c3f50911",
  "__v": 0,
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50912",
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z",
      "end": "2018-11-22T07:38:30.940Z"
    },
    {
      "_id": "5bf65cf6ae7b8639c3f50913",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    }
  ]
}
Mongoose: alttrackers.findOneAndUpdate({ _id: ObjectId("5bf65cf6ae7b8639c3f50914") }, { '$setOnInsert': { __v: 0 }, '$push': { pauses: { '$each': [ { _id: ObjectId("5bf65cf6ae7b8639c3f50915"), reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:13:22 GMT"), end: new Date("Wed, 21 Nov 2018 18:14:30 GMT") } ], '$position': 0 } } }, { upsert: true, remove: false, projection: {}, returnOriginal: false })
{
  "_id": "5bf65cf6ae7b8639c3f50914",
  "__v": 0,
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50915",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    }
  ]
}
Mongoose: alttrackers.findOneAndUpdate({ _id: ObjectId("5bf65cf6ae7b8639c3f50914") }, { '$setOnInsert': { __v: 0 }, '$push': { pauses: { '$each': [ { _id: ObjectId("5bf65cf6ae7b8639c3f50916"), reason: 'Lunch', start: new Date("Wed, 21 Nov 2018 18:15:09 GMT") } ], '$position': 0 } } }, { upsert: true, remove: false, projection: {}, returnOriginal: false })
{
  "_id": "5bf65cf6ae7b8639c3f50914",
  "__v": 0,
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50916",
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z"
    },
    {
      "_id": "5bf65cf6ae7b8639c3f50915",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    }
  ]
}
Mongoose: alttrackers.findOneAndUpdate({ _id: ObjectId("5bf65cf6ae7b8639c3f50914"), 'pauses.0.end': { '$exists': false } }, { '$set': { 'pauses.0.end': new Date("Thu, 22 Nov 2018 07:38:30 GMT") } }, { upsert: false, remove: false, projection: {}, returnOriginal: false })
{
  "_id": "5bf65cf6ae7b8639c3f50914",
  "__v": 0,
  "pauses": [
    {
      "_id": "5bf65cf6ae7b8639c3f50916",
      "reason": "Lunch",
      "start": "2018-11-21T18:15:09.057Z",
      "end": "2018-11-22T07:38:30.957Z"
    },
    {
      "_id": "5bf65cf6ae7b8639c3f50915",
      "reason": "Lunch",
      "start": "2018-11-21T18:13:22.835Z",
      "end": "2018-11-21T18:14:30.835Z"
    }
  ]
}
Neil Lunn
  • 148,042
  • 36
  • 346
  • 317
  • 1
    Thank you very much. Your answer not only helps me to solve the problem but also gives me understanding of the matter. – Felix Nov 22 '18 at 19:33