mongoose
Version:
Mongoose MongoDB ODM
838 lines (733 loc) • 22.5 kB
JavaScript
/*!
* Module dependencies.
*/
var EmbeddedDocument = require('./embedded');
var Document = require('../document');
var ObjectId = require('./objectid');
var cleanModifiedSubpaths = require('../services/document/cleanModifiedSubpaths');
var utils = require('../utils');
var isMongooseObject = utils.isMongooseObject;
/**
* Mongoose Array constructor.
*
* ####NOTE:
*
* _Values always have to be passed to the constructor to initialize, otherwise `MongooseArray#push` will mark the array as modified._
*
* @param {Array} values
* @param {String} path
* @param {Document} doc parent document
* @api private
* @inherits Array
* @see http://bit.ly/f6CnZU
*/
function MongooseArray(values, path, doc) {
var arr = [].concat(values);
var keysMA = Object.keys(MongooseArray.mixin);
var numKeys = keysMA.length;
for (var i = 0; i < numKeys; ++i) {
arr[keysMA[i]] = MongooseArray.mixin[keysMA[i]];
}
arr._path = path;
arr.isMongooseArray = true;
arr.validators = [];
arr._atomics = {};
arr._schema = void 0;
// Because doc comes from the context of another function, doc === global
// can happen if there was a null somewhere up the chain (see #3020)
// RB Jun 17, 2015 updated to check for presence of expected paths instead
// to make more proof against unusual node environments
if (doc && doc instanceof Document) {
arr._parent = doc;
arr._schema = doc.schema.path(path);
}
return arr;
}
MongooseArray.mixin = {
/*!
* ignore
*/
toBSON: function() {
return this.toObject({
transform: false,
virtuals: false,
_skipDepopulateTopLevel: true,
depopulate: true,
flattenDecimals: false
});
},
/**
* Stores a queue of atomic operations to perform
*
* @property _atomics
* @api private
*/
_atomics: undefined,
/**
* Parent owner document
*
* @property _parent
* @api private
* @receiver MongooseArray
*/
_parent: undefined,
/**
* Casts a member based on this arrays schema.
*
* @param {any} value
* @return value the casted value
* @method _cast
* @api private
* @receiver MongooseArray
*/
_cast: function(value) {
var populated = false;
var Model;
if (this._parent) {
populated = this._parent.populated(this._path, true);
}
if (populated && value !== null && value !== undefined) {
// cast to the populated Models schema
Model = populated.options.model || populated.options.Model;
// only objects are permitted so we can safely assume that
// non-objects are to be interpreted as _id
if (Buffer.isBuffer(value) ||
value instanceof ObjectId || !utils.isObject(value)) {
value = {_id: value};
}
// gh-2399
// we should cast model only when it's not a discriminator
var isDisc = value.schema && value.schema.discriminatorMapping &&
value.schema.discriminatorMapping.key !== undefined;
if (!isDisc) {
value = new Model(value);
}
return this._schema.caster.applySetters(value, this._parent, true);
}
return this._schema.caster.applySetters(value, this._parent, false);
},
/**
* Marks this array as modified.
*
* If it bubbles up from an embedded document change, then it takes the following arguments (otherwise, takes 0 arguments)
*
* @param {EmbeddedDocument} embeddedDoc the embedded doc that invoked this method on the Array
* @param {String} embeddedPath the path which changed in the embeddedDoc
* @method _markModified
* @api private
* @receiver MongooseArray
*/
_markModified: function(elem, embeddedPath) {
var parent = this._parent,
dirtyPath;
if (parent) {
dirtyPath = this._path;
if (arguments.length) {
if (embeddedPath != null) {
// an embedded doc bubbled up the change
dirtyPath = dirtyPath + '.' + this.indexOf(elem) + '.' + embeddedPath;
} else {
// directly set an index
dirtyPath = dirtyPath + '.' + elem;
}
}
parent.markModified(dirtyPath, arguments.length > 0 ? elem : parent);
}
return this;
},
/**
* Register an atomic operation with the parent.
*
* @param {Array} op operation
* @param {any} val
* @method _registerAtomic
* @api private
* @receiver MongooseArray
*/
_registerAtomic: function(op, val) {
if (op === '$set') {
// $set takes precedence over all other ops.
// mark entire array modified.
this._atomics = {$set: val};
return this;
}
var atomics = this._atomics;
// reset pop/shift after save
if (op === '$pop' && !('$pop' in atomics)) {
var _this = this;
this._parent.once('save', function() {
_this._popped = _this._shifted = null;
});
}
// check for impossible $atomic combos (Mongo denies more than one
// $atomic op on a single path
if (this._atomics.$set ||
Object.keys(atomics).length && !(op in atomics)) {
// a different op was previously registered.
// save the entire thing.
this._atomics = {$set: this};
return this;
}
var selector;
if (op === '$pullAll' || op === '$pushAll' || op === '$addToSet') {
atomics[op] || (atomics[op] = []);
atomics[op] = atomics[op].concat(val);
} else if (op === '$pullDocs') {
var pullOp = atomics['$pull'] || (atomics['$pull'] = {});
if (val[0] instanceof EmbeddedDocument) {
selector = pullOp['$or'] || (pullOp['$or'] = []);
Array.prototype.push.apply(selector, val.map(function(v) {
return v.toObject({transform: false, virtuals: false});
}));
} else {
selector = pullOp['_id'] || (pullOp['_id'] = {$in: []});
selector['$in'] = selector['$in'].concat(val);
}
} else {
atomics[op] = val;
}
return this;
},
/**
* Depopulates stored atomic operation values as necessary for direct insertion to MongoDB.
*
* If no atomics exist, we return all array values after conversion.
*
* @return {Array}
* @method $__getAtomics
* @memberOf MongooseArray
* @api private
*/
$__getAtomics: function() {
var ret = [];
var keys = Object.keys(this._atomics);
var i = keys.length;
if (i === 0) {
ret[0] = ['$set', this.toObject({depopulate: 1, transform: false, _isNested: true, virtuals: false})];
return ret;
}
while (i--) {
var op = keys[i];
var val = this._atomics[op];
// the atomic values which are arrays are not MongooseArrays. we
// need to convert their elements as if they were MongooseArrays
// to handle populated arrays versus DocumentArrays properly.
if (isMongooseObject(val)) {
val = val.toObject({depopulate: 1, transform: false, _isNested: true, virtuals: false});
} else if (Array.isArray(val)) {
val = this.toObject.call(val, {depopulate: 1, transform: false, _isNested: true});
} else if (val.valueOf) {
val = val.valueOf();
}
if (op === '$addToSet') {
val = {$each: val};
}
ret.push([op, val]);
}
return ret;
},
/**
* Returns the number of pending atomic operations to send to the db for this array.
*
* @api private
* @return {Number}
* @method hasAtomics
* @receiver MongooseArray
*/
hasAtomics: function hasAtomics() {
if (!(this._atomics && this._atomics.constructor.name === 'Object')) {
return 0;
}
return Object.keys(this._atomics).length;
},
/**
* Internal helper for .map()
*
* @api private
* @return {Number}
* @method _mapCast
* @receiver MongooseArray
*/
_mapCast: function(val, index) {
return this._cast(val, this.length + index);
},
/**
* Wraps [`Array#push`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/push) with proper change tracking.
*
* @param {Object} [args...]
* @api public
* @method push
* @receiver MongooseArray
*/
push: function() {
_checkManualPopulation(this, arguments);
var values = [].map.call(arguments, this._mapCast, this);
values = this._schema.applySetters(values, this._parent, undefined,
undefined, { skipDocumentArrayCast: true });
var ret = [].push.apply(this, values);
// $pushAll might be fibbed (could be $push). But it makes it easier to
// handle what could have been $push, $pushAll combos
this._registerAtomic('$pushAll', values);
this._markModified();
return ret;
},
/**
* Pushes items to the array non-atomically.
*
* ####NOTE:
*
* _marks the entire array as modified, which if saved, will store it as a `$set` operation, potentially overwritting any changes that happen between when you retrieved the object and when you save it._
*
* @param {any} [args...]
* @api public
* @method nonAtomicPush
* @receiver MongooseArray
*/
nonAtomicPush: function() {
var values = [].map.call(arguments, this._mapCast, this);
var ret = [].push.apply(this, values);
this._registerAtomic('$set', this);
this._markModified();
return ret;
},
/**
* Pops the array atomically at most one time per document `save()`.
*
* #### NOTE:
*
* _Calling this mulitple times on an array before saving sends the same command as calling it once._
* _This update is implemented using the MongoDB [$pop](http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pop) method which enforces this restriction._
*
* doc.array = [1,2,3];
*
* var popped = doc.array.$pop();
* console.log(popped); // 3
* console.log(doc.array); // [1,2]
*
* // no affect
* popped = doc.array.$pop();
* console.log(doc.array); // [1,2]
*
* doc.save(function (err) {
* if (err) return handleError(err);
*
* // we saved, now $pop works again
* popped = doc.array.$pop();
* console.log(popped); // 2
* console.log(doc.array); // [1]
* })
*
* @api public
* @method $pop
* @memberOf MongooseArray
* @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pop
* @method $pop
* @receiver MongooseArray
*/
$pop: function() {
this._registerAtomic('$pop', 1);
this._markModified();
// only allow popping once
if (this._popped) {
return;
}
this._popped = true;
return [].pop.call(this);
},
/**
* Wraps [`Array#pop`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/pop) with proper change tracking.
*
* ####Note:
*
* _marks the entire array as modified which will pass the entire thing to $set potentially overwritting any changes that happen between when you retrieved the object and when you save it._
*
* @see MongooseArray#$pop #types_array_MongooseArray-%24pop
* @api public
* @method pop
* @receiver MongooseArray
*/
pop: function() {
var ret = [].pop.call(this);
this._registerAtomic('$set', this);
this._markModified();
return ret;
},
/**
* Atomically shifts the array at most one time per document `save()`.
*
* ####NOTE:
*
* _Calling this mulitple times on an array before saving sends the same command as calling it once._
* _This update is implemented using the MongoDB [$pop](http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pop) method which enforces this restriction._
*
* doc.array = [1,2,3];
*
* var shifted = doc.array.$shift();
* console.log(shifted); // 1
* console.log(doc.array); // [2,3]
*
* // no affect
* shifted = doc.array.$shift();
* console.log(doc.array); // [2,3]
*
* doc.save(function (err) {
* if (err) return handleError(err);
*
* // we saved, now $shift works again
* shifted = doc.array.$shift();
* console.log(shifted ); // 2
* console.log(doc.array); // [3]
* })
*
* @api public
* @memberOf MongooseArray
* @method $shift
* @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pop
*/
$shift: function $shift() {
this._registerAtomic('$pop', -1);
this._markModified();
// only allow shifting once
if (this._shifted) {
return;
}
this._shifted = true;
return [].shift.call(this);
},
/**
* Wraps [`Array#shift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking.
*
* ####Example:
*
* doc.array = [2,3];
* var res = doc.array.shift();
* console.log(res) // 2
* console.log(doc.array) // [3]
*
* ####Note:
*
* _marks the entire array as modified, which if saved, will store it as a `$set` operation, potentially overwritting any changes that happen between when you retrieved the object and when you save it._
*
* @api public
* @method shift
* @receiver MongooseArray
*/
shift: function() {
var ret = [].shift.call(this);
this._registerAtomic('$set', this);
this._markModified();
return ret;
},
/**
* Pulls items from the array atomically. Equality is determined by casting
* the provided value to an embedded document and comparing using
* [the `Document.equals()` function.](./api.html#document_Document-equals)
*
* ####Examples:
*
* doc.array.pull(ObjectId)
* doc.array.pull({ _id: 'someId' })
* doc.array.pull(36)
* doc.array.pull('tag 1', 'tag 2')
*
* To remove a document from a subdocument array we may pass an object with a matching `_id`.
*
* doc.subdocs.push({ _id: 4815162342 })
* doc.subdocs.pull({ _id: 4815162342 }) // removed
*
* Or we may passing the _id directly and let mongoose take care of it.
*
* doc.subdocs.push({ _id: 4815162342 })
* doc.subdocs.pull(4815162342); // works
*
* The first pull call will result in a atomic operation on the database, if pull is called repeatedly without saving the document, a $set operation is used on the complete array instead, overwriting possible changes that happened on the database in the meantime.
*
* @param {any} [args...]
* @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pull
* @api public
* @method pull
* @receiver MongooseArray
*/
pull: function() {
var values = [].map.call(arguments, this._cast, this),
cur = this._parent.get(this._path),
i = cur.length,
mem;
while (i--) {
mem = cur[i];
if (mem instanceof Document) {
var some = values.some(function(v) {
return mem.equals(v);
});
if (some) {
[].splice.call(cur, i, 1);
}
} else if (~cur.indexOf.call(values, mem)) {
[].splice.call(cur, i, 1);
}
}
if (values[0] instanceof EmbeddedDocument) {
this._registerAtomic('$pullDocs', values.map(function(v) {
return v._id || v;
}));
} else {
this._registerAtomic('$pullAll', values);
}
this._markModified();
// Might have modified child paths and then pulled, like
// `doc.children[1].name = 'test';` followed by
// `doc.children.remove(doc.children[0]);`. In this case we fall back
// to a `$set` on the whole array. See #3511
if (cleanModifiedSubpaths(this._parent, this._path) > 0) {
this._registerAtomic('$set', this);
}
return this;
},
/**
* Wraps [`Array#splice`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/splice) with proper change tracking and casting.
*
* ####Note:
*
* _marks the entire array as modified, which if saved, will store it as a `$set` operation, potentially overwritting any changes that happen between when you retrieved the object and when you save it._
*
* @api public
* @method splice
* @receiver MongooseArray
*/
splice: function splice() {
var ret;
var vals;
var i;
_checkManualPopulation(this, Array.prototype.slice.call(arguments, 2));
if (arguments.length) {
vals = [];
for (i = 0; i < arguments.length; ++i) {
vals[i] = i < 2
? arguments[i]
: this._cast(arguments[i], arguments[0] + (i - 2));
}
ret = [].splice.apply(this, vals);
this._registerAtomic('$set', this);
this._markModified();
}
return ret;
},
/**
* Wraps [`Array#unshift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking.
*
* ####Note:
*
* _marks the entire array as modified, which if saved, will store it as a `$set` operation, potentially overwritting any changes that happen between when you retrieved the object and when you save it._
*
* @api public
* @method unshift
* @receiver MongooseArray
*/
unshift: function() {
_checkManualPopulation(this, arguments);
var values = [].map.call(arguments, this._cast, this);
values = this._schema.applySetters(values, this._parent);
[].unshift.apply(this, values);
this._registerAtomic('$set', this);
this._markModified();
return this.length;
},
/**
* Wraps [`Array#sort`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/sort) with proper change tracking.
*
* ####NOTE:
*
* _marks the entire array as modified, which if saved, will store it as a `$set` operation, potentially overwritting any changes that happen between when you retrieved the object and when you save it._
*
* @api public
* @method sort
* @receiver MongooseArray
*/
sort: function() {
var ret = [].sort.apply(this, arguments);
this._registerAtomic('$set', this);
this._markModified();
return ret;
},
/**
* Adds values to the array if not already present.
*
* ####Example:
*
* console.log(doc.array) // [2,3,4]
* var added = doc.array.addToSet(4,5);
* console.log(doc.array) // [2,3,4,5]
* console.log(added) // [5]
*
* @param {any} [args...]
* @return {Array} the values that were added
* @receiver MongooseArray
* @api public
* @method addToSet
*/
addToSet: function addToSet() {
_checkManualPopulation(this, arguments);
var values = [].map.call(arguments, this._mapCast, this);
values = this._schema.applySetters(values, this._parent);
var added = [];
var type = '';
if (values[0] instanceof EmbeddedDocument) {
type = 'doc';
} else if (values[0] instanceof Date) {
type = 'date';
}
values.forEach(function(v) {
var found;
switch (type) {
case 'doc':
found = this.some(function(doc) {
return doc.equals(v);
});
break;
case 'date':
var val = +v;
found = this.some(function(d) {
return +d === val;
});
break;
default:
found = ~this.indexOf(v);
}
if (!found) {
[].push.call(this, v);
this._registerAtomic('$addToSet', v);
this._markModified();
[].push.call(added, v);
}
}, this);
return added;
},
/**
* Sets the casted `val` at index `i` and marks the array modified.
*
* ####Example:
*
* // given documents based on the following
* var Doc = mongoose.model('Doc', new Schema({ array: [Number] }));
*
* var doc = new Doc({ array: [2,3,4] })
*
* console.log(doc.array) // [2,3,4]
*
* doc.array.set(1,"5");
* console.log(doc.array); // [2,5,4] // properly cast to number
* doc.save() // the change is saved
*
* // VS not using array#set
* doc.array[1] = "5";
* console.log(doc.array); // [2,"5",4] // no casting
* doc.save() // change is not saved
*
* @return {Array} this
* @api public
* @method set
* @receiver MongooseArray
*/
set: function set(i, val) {
var value = this._cast(val, i);
this[i] = value;
this._markModified(i);
return this;
},
/**
* Returns a native js Array.
*
* @param {Object} options
* @return {Array}
* @api public
* @method toObject
* @receiver MongooseArray
*/
toObject: function(options) {
if (options && options.depopulate) {
options._isNested = true;
return this.map(function(doc) {
return doc instanceof Document
? doc.toObject(options)
: doc;
});
}
return this.slice();
},
/**
* Helper for console.log
*
* @api public
* @method inspect
* @receiver MongooseArray
*/
inspect: function() {
return JSON.stringify(this);
},
/**
* Return the index of `obj` or `-1` if not found.
*
* @param {Object} obj the item to look for
* @return {Number}
* @api public
* @method indexOf
* @receiver MongooseArray
*/
indexOf: function indexOf(obj) {
if (obj instanceof ObjectId) {
obj = obj.toString();
}
for (var i = 0, len = this.length; i < len; ++i) {
if (obj == this[i]) {
return i;
}
}
return -1;
}
};
/**
* Alias of [pull](#types_array_MongooseArray-pull)
*
* @see MongooseArray#pull #types_array_MongooseArray-pull
* @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pull
* @api public
* @memberOf MongooseArray
* @method remove
*/
MongooseArray.mixin.remove = MongooseArray.mixin.pull;
/*!
* ignore
*/
function _isAllSubdocs(docs, ref) {
if (!ref) {
return false;
}
for (var i = 0; i < docs.length; ++i) {
var arg = docs[i];
if (arg == null) {
return false;
}
var model = arg.constructor;
if (!(arg instanceof Document) ||
(model.modelName !== ref && model.baseModelName !== ref)) {
return false;
}
}
return true;
}
/*!
* ignore
*/
function _checkManualPopulation(arr, docs) {
var ref = arr._schema.caster.options && arr._schema.caster.options.ref;
if (arr.length === 0 &&
docs.length > 0) {
if (_isAllSubdocs(docs, ref)) {
arr._parent.populated(arr._path, [], { model: docs[0].constructor });
}
}
}
/*!
* Module exports.
*/
module.exports = exports = MongooseArray;