Skip to content

Commit eaa9fdd

Browse files
committed
fixed; saving divergent populated arrays
When a $set of an entire array or a $pop operation of that array is produced through document.save() and the array was populated using limit, skip, query condition, or deselection of the _id field, we now return an error. The view mongoose has of the array has diverged from the array in the database and these operations would have unknown consequences on that data. $pop: // database { _id: 1, array: [3,5,7,9] } // mongoose var query = Doc.findOne(); query.populate({ path: 'array', match: { name: 'three' }}) query.exec(function (err, doc) { console.log(doc.array) // [{ _id: 3, name: 'three' }] doc.$pop(); console.log(doc.array) // [] doc.save() // <== Error }) This $pop removed the document with the _id of 3 from the array locally but when sent to the database would have removed the number 9 (since $pop removes the last element of the array). Instead, one could use doc.array.pull({ _id: 3 }) or perform this update manually using doc.update(..); $set: // database { _id: 1, array: [9,3,7,5] } // mongoose var query = Doc.findOne(); query.populate({ path: 'array', match: { _id: { $gt: 7 }}}) query.exec(function (err, doc) { console.log(doc.array) // [{ _id: 9 }] doc.array.unshift({ _id: 2 }) console.log(doc.array) // [{ _id: 2 }, { _id: 9 }] doc.save() // <== Error }) The would result in a $set of the entire array (there is no equivalent atomic operator for `unshift`) which would overwrite the other un-matched elements in the array in the database. Use doc.update() instead.
1 parent 5baac8c commit eaa9fdd

File tree

7 files changed

+290
-72
lines changed

7 files changed

+290
-72
lines changed

lib/document.js

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,31 +1282,6 @@ Document.prototype.$__registerHooks = function () {
12821282
} else {
12831283
next();
12841284
}
1285-
}).pre('save', function checkIfUpdatingElemMatchedArrays (next) {
1286-
if (this.isNew) return next();
1287-
if (!this.$__.selected) return next();
1288-
1289-
// walk through modified paths for arrays,
1290-
// if any array was selected using an $elemMatch projection, deny the update.
1291-
// see https://github.com/LearnBoost/mongoose/issues/1334
1292-
// NOTE: MongoDB only supports projected $elemMatch on top level array.
1293-
1294-
var self = this;
1295-
var elemMatched = [];
1296-
1297-
this.$__.activePaths.forEach('modify', function (path) {
1298-
var top = path.split('.')[0];
1299-
if (self.$__.selected[top] && self.$__.selected[top].$elemMatch) {
1300-
elemMatched.push(top);
1301-
}
1302-
});
1303-
1304-
if (elemMatched.length) {
1305-
next(new ElemMatchError(elemMatched));
1306-
} else {
1307-
next();
1308-
}
1309-
13101285
}).pre('save', function validation (next) {
13111286
return this.validate(next);
13121287
});

lib/error.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ MongooseError.ValidatorError = require('./errors/validator')
3636
MongooseError.VersionError =require('./errors/version')
3737
MongooseError.OverwriteModelError = require('./errors/overwriteModel')
3838
MongooseError.MissingSchemaError = require('./errors/missingSchema')
39-
MongooseError.ElemMatchError = require('./errors/elemMatch')
39+
MongooseError.DivergentArrayError = require('./errors/divergentArray')

lib/errors/divergentArray.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
2+
/*!
3+
* Module dependencies.
4+
*/
5+
6+
var MongooseError = require('../error');
7+
8+
/*!
9+
* DivergentArrayError constructor.
10+
*
11+
* @inherits MongooseError
12+
*/
13+
14+
function DivergentArrayError (paths) {
15+
var msg = 'For your own good, using `document.save()` to update an array '
16+
+ 'which was selected using an $elemMatch projection OR '
17+
+ 'populated using skip, limit, query conditions, or exclusion of '
18+
+ 'the _id field when the operation results in a $pop or $set of '
19+
+ 'the entire array is not supported. The following '
20+
+ 'path(s) would have been modified unsafely:\n'
21+
+ ' ' + paths.join('\n ') + '\n'
22+
+ 'Use Model.update() to update these arrays instead.'
23+
// TODO write up a docs page (FAQ) and link to it
24+
25+
MongooseError.call(this, msg);
26+
Error.captureStackTrace(this, arguments.callee);
27+
this.name = 'DivergentArrayError';
28+
};
29+
30+
/*!
31+
* Inherits from MongooseError.
32+
*/
33+
34+
DivergentArrayError.prototype.__proto__ = MongooseError.prototype;
35+
36+
/*!
37+
* exports
38+
*/
39+
40+
module.exports = DivergentArrayError;

lib/errors/elemMatch.js

Lines changed: 0 additions & 36 deletions
This file was deleted.

lib/model.js

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ var Document = require('./document')
66
, MongooseArray = require('./types/array')
77
, MongooseBuffer = require('./types/buffer')
88
, MongooseError = require('./error')
9-
, VersionError = require('./errors/version')
9+
, VersionError = MongooseError.VersionError
10+
, DivergentArrayError = MongooseError.DivergentArrayError
1011
, Query = require('./query')
1112
, Schema = require('./schema')
1213
, Types = require('./schema/index')
1314
, utils = require('./utils')
15+
, hasOwnProperty = utils.object.hasOwnProperty
1416
, isMongooseObject = utils.isMongooseObject
1517
, EventEmitter = require('events').EventEmitter
1618
, merge = utils.merge
@@ -182,6 +184,7 @@ Model.prototype.save = function save (fn) {
182184

183185
var delta = this.$__delta();
184186
if (delta) {
187+
if (delta instanceof Error) return complete(delta);
185188
var where = this.$__where(delta[0]);
186189
this.$__reset();
187190
this.collection.update(where, delta[1], options, complete);
@@ -334,10 +337,10 @@ Model.prototype.$__delta = function () {
334337
var dirty = this.$__dirty();
335338
if (!dirty.length) return;
336339

337-
var self = this
338-
, where = {}
340+
var where = {}
339341
, delta = {}
340342
, len = dirty.length
343+
, divergent = []
341344
, d = 0
342345
, val
343346
, obj
@@ -347,34 +350,95 @@ Model.prototype.$__delta = function () {
347350
var value = data.value
348351
var schema = data.schema
349352

353+
var match = checkDivergentArray(this, data.path, value);
354+
if (match) {
355+
divergent.push(match);
356+
continue;
357+
}
358+
359+
if (divergent.length) continue;
360+
350361
if (undefined === value) {
351-
operand(self, where, delta, data, 1, '$unset');
362+
operand(this, where, delta, data, 1, '$unset');
352363

353364
} else if (null === value) {
354-
operand(self, where, delta, data, null);
365+
operand(this, where, delta, data, null);
355366

356367
} else if (value._path && value._atomics) {
357368
// arrays and other custom types (support plugins etc)
358-
handleAtomics(self, where, delta, data, value);
369+
handleAtomics(this, where, delta, data, value);
359370

360371
} else if (value._path && Buffer.isBuffer(value)) {
361372
// MongooseBuffer
362373
value = value.toObject();
363-
operand(self, where, delta, data, value);
374+
operand(this, where, delta, data, value);
364375

365376
} else {
366377
value = utils.clone(value, { convertToId: 1 });
367-
operand(self, where, delta, data, value);
378+
operand(this, where, delta, data, value);
368379
}
369380
}
370381

382+
if (divergent.length) {
383+
return new DivergentArrayError(divergent);
384+
}
385+
371386
if (this.$__.version) {
372387
this.$__version(where, delta);
373388
}
374389

375390
return [where, delta];
376391
}
377392

393+
/*!
394+
* Determine if array was populated with some form of filter and is now
395+
* being updated in a manner which could overwrite data unintentionally.
396+
*
397+
* @see https://github.com/LearnBoost/mongoose/issues/1334
398+
* @param {Document} doc
399+
* @param {String} path
400+
* @return {String|undefined}
401+
*/
402+
403+
function checkDivergentArray (doc, path, array) {
404+
// see if we populated this path
405+
var pop = doc.populated(path, true);
406+
407+
if (!pop && doc.$__.selected) {
408+
// If any array was selected using an $elemMatch projection, we deny the update.
409+
// NOTE: MongoDB only supports projected $elemMatch on top level array.
410+
var top = path.split('.')[0];
411+
if (doc.$__.selected[top] && doc.$__.selected[top].$elemMatch) {
412+
return top;
413+
}
414+
}
415+
416+
if (!(pop && array instanceof MongooseArray)) return;
417+
418+
// If the array was populated using options that prevented all
419+
// documents from being returned (match, skip, limit) or they
420+
// deselected the _id field, $pop and $set of the array are
421+
// not safe operations. If _id was deselected, we do not know
422+
// how to remove elements. $pop will pop off the _id from the end
423+
// of the array in the db which is not guaranteed to be the
424+
// same as the last element we have here. $set of the entire array
425+
// would be similarily destructive as we never received all
426+
// elements of the array and potentially would overwrite data.
427+
var check = pop.options.match ||
428+
pop.options.options && hasOwnProperty(pop.options.options, 'limit') || // 0 is not permitted
429+
pop.options.options && pop.options.options.skip || // 0 is permitted
430+
pop.options.select && // deselected _id?
431+
(0 === pop.options.select._id ||
432+
/\s?-_id\s?/.test(pop.options.select))
433+
434+
if (check) {
435+
var atomics = array._atomics;
436+
if (0 === Object.keys(atomics).length || atomics.$set || atomics.$pop) {
437+
return path;
438+
}
439+
}
440+
}
441+
378442
/**
379443
* Appends versioning to the where and update clauses.
380444
*
@@ -1671,7 +1735,13 @@ function populate (model, docs, options, cb) {
16711735
return cb();
16721736
}
16731737

1674-
match || (match = {});
1738+
// preserve original match conditions by copying
1739+
if (match) {
1740+
match = utils.object.shallowCopy(match);
1741+
} else {
1742+
match = {};
1743+
}
1744+
16751745
match._id || (match._id = { $in: ids });
16761746

16771747
var assignmentOpts = {};
@@ -1685,6 +1755,8 @@ function populate (model, docs, options, cb) {
16851755
if ('string' == typeof select) {
16861756
select = null;
16871757
} else {
1758+
// preserve original select conditions by copying
1759+
select = utils.object.shallowCopy(select);
16881760
delete select._id;
16891761
}
16901762
}

lib/utils.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ function cloneArray (arr, options) {
302302
};
303303

304304
/**
305-
* Copies and merges options with defaults.
305+
* Shallow copies defaults into options.
306306
*
307307
* @param {Object} defaults
308308
* @param {Object} options
@@ -602,6 +602,23 @@ exports.object.vals = function vals (o) {
602602
return ret;
603603
}
604604

605+
/*!
606+
* @see exports.options
607+
*/
608+
609+
exports.object.shallowCopy = exports.options;
610+
611+
/*!
612+
* Safer helper for hasOwnProperty checks
613+
*
614+
* @param {Object} obj
615+
* @param {String} prop
616+
*/
617+
618+
exports.object.hasOwnProperty = function (obj, prop) {
619+
return Object.prototype.hasOwnProperty.call(obj, prop);
620+
}
621+
605622
/*!
606623
* Determine if `val` is null or undefined
607624
*

0 commit comments

Comments
 (0)