From 96ed6c055e76889e865f6841eb294ba95f03732a Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 28 Jul 2020 15:32:06 -0400 Subject: [PATCH 01/22] feat: proof of concept for using proxies to track changes on arrays Re: #8884 --- lib/document.js | 2 +- .../populate/assignRawDocsToIdStructure.js | 4 ++ lib/helpers/populate/assignVals.js | 6 ++- .../populate/getModelsMapForPopulate.js | 6 ++- lib/schema/array.js | 2 +- lib/types/array.js | 3 +- lib/types/arrayProxy.js | 40 +++++++++++++++++++ lib/types/core_array.js | 11 +++-- 8 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 lib/types/arrayProxy.js diff --git a/lib/document.js b/lib/document.js index e82a728882b..704fa51d1a2 100644 --- a/lib/document.js +++ b/lib/document.js @@ -1227,7 +1227,7 @@ Document.prototype.$set = function $set(path, val, type, options) { if (Array.isArray(val) && this.$__.populated[path]) { for (let i = 0; i < val.length; ++i) { if (val[i] instanceof Document) { - val[i] = val[i]._id; + val.set(i, val[i]._id, true); } } } diff --git a/lib/helpers/populate/assignRawDocsToIdStructure.js b/lib/helpers/populate/assignRawDocsToIdStructure.js index 843c148373e..6dac9a7b72c 100644 --- a/lib/helpers/populate/assignRawDocsToIdStructure.js +++ b/lib/helpers/populate/assignRawDocsToIdStructure.js @@ -35,6 +35,10 @@ function assignRawDocsToIdStructure(rawIds, resultDocs, resultOrder, options, re let sid; let id; + if (rawIds.isMongooseArrayProxy) { + rawIds = rawIds.__array; + } + for (let i = 0; i < rawIds.length; ++i) { id = rawIds[i]; diff --git a/lib/helpers/populate/assignVals.js b/lib/helpers/populate/assignVals.js index 948d8151222..5f1bf4c32c8 100644 --- a/lib/helpers/populate/assignVals.js +++ b/lib/helpers/populate/assignVals.js @@ -204,7 +204,11 @@ function valueFilter(val, assignmentOpts, populateOptions) { Array.prototype.pop.apply(val, []); } for (let i = 0; i < ret.length; ++i) { - val[i] = ret[i]; + if (val.isMongooseArrayProxy) { + val.set(i, ret[i], true); + } else { + val[i] = ret[i]; + } } return val; } diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js index 4e339e27d25..ec7f75eb9cc 100644 --- a/lib/helpers/populate/getModelsMapForPopulate.js +++ b/lib/helpers/populate/getModelsMapForPopulate.js @@ -540,7 +540,11 @@ function convertTo_id(val, schema) { if (Array.isArray(val)) { for (let i = 0; i < val.length; ++i) { if (val[i] != null && val[i].$__ != null) { - val[i] = val[i]._id; + if (val.isMongooseArrayProxy) { + val.set(i, val[i]._id, true); + } else { + val[i] = val[i]._id; + } } } if (val.isMongooseArray && val.$schema()) { diff --git a/lib/schema/array.js b/lib/schema/array.js index 65560d06d50..b4029ac77de 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -355,7 +355,7 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { } else if (this.caster._arrayPath != null) { opts.arrayPath = this.caster._arrayPath.slice(0, -2) + '.' + i; } - value[i] = this.caster.cast(value[i], doc, init, void 0, opts); + value.set(i, this.caster.cast(value[i], doc, init, void 0, opts), true); } } catch (e) { // rethrow diff --git a/lib/types/array.js b/lib/types/array.js index 41c51e5075b..6eef7041216 100644 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -6,6 +6,7 @@ const CoreMongooseArray = require('./core_array'); const Document = require('../document'); +const arrayProxy = require('./arrayProxy'); const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; @@ -56,7 +57,7 @@ function MongooseArray(values, path, doc) { arr[arraySchemaSymbol] = doc.schema.path(path); } - return arr; + return arrayProxy(arr); } /*! diff --git a/lib/types/arrayProxy.js b/lib/types/arrayProxy.js new file mode 100644 index 00000000000..57d09b2b6df --- /dev/null +++ b/lib/types/arrayProxy.js @@ -0,0 +1,40 @@ +'use strict'; + +module.exports = function arrayProxy(arr) { + const proxy = new Proxy(arr, { + get: function(target, prop) { + if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy') { + return true; + } + if (prop === '__array') { + return arr; + } + if (prop === 'set') { + return function set(i, val, skipModified) { + if (skipModified) { + arr[i] = val; + return arr; + } + const value = arr._cast(val, i); + arr[i] = value; + arr._markModified(i); + return arr; + }; + } + + return target[prop]; + }, + set: function(target, prop, value) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + // console.log('Set', prop, value, new Error().stack); + target.set(prop, value); + } else { + target[prop] = value; + } + + return true; + } + }); + + return proxy; +}; \ No newline at end of file diff --git a/lib/types/core_array.js b/lib/types/core_array.js index 032b6e8e7a1..b9cfd71f311 100644 --- a/lib/types/core_array.js +++ b/lib/types/core_array.js @@ -659,6 +659,7 @@ class CoreMongooseArray extends Array { let atomic = values; const isOverwrite = values[0] != null && utils.hasUserDefinedProperty(values[0], '$each'); + const arr = this.isMongooseArrayProxy ? this.__array : this; if (isOverwrite) { atomic = values[0]; values = values[0].$each; @@ -690,7 +691,7 @@ class CoreMongooseArray extends Array { [].splice.apply(this, [atomic.$position, 0].concat(values)); ret = this.length; } else { - ret = [].push.apply(this, values); + ret = [].push.apply(arr, values); } } else { if (get(atomics, '$push.$each.length', 0) > 0 && @@ -699,7 +700,7 @@ class CoreMongooseArray extends Array { 'with different `$position`'); } atomic = values; - ret = [].push.apply(this, values); + ret = [].push.apply(arr, values); } this._registerAtomic('$push', atomic); this._markModified(); @@ -748,7 +749,11 @@ class CoreMongooseArray extends Array { * @memberOf MongooseArray */ - set(i, val) { + set(i, val, skipModified) { + if (skipModified) { + this[i] = val; + return this; + } const value = this._cast(val, i); this[i] = value; this._markModified(i); From dfcc346902effc6a2cb5e6e5b492415b4511abdc Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 29 Jul 2020 11:27:02 -0400 Subject: [PATCH 02/22] feat: make Mongoose arrays proxied plain arrays, remove instantiation of CoreMongooseArray Re: #8884 Fix #7700 --- lib/types/array.js | 2 +- lib/types/arrayProxy.js | 30 ++++++++++++++++++------------ lib/types/core_array.js | 5 ++++- test/types.array.test.js | 1 + 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/lib/types/array.js b/lib/types/array.js index 6eef7041216..349e3273f73 100644 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -33,7 +33,7 @@ const _basePush = Array.prototype.push; function MongooseArray(values, path, doc) { // TODO: replace this with `new CoreMongooseArray().concat()` when we remove // support for node 4.x and 5.x, see https://i.imgur.com/UAAHk4S.png - const arr = new CoreMongooseArray(); + const arr = []; arr[arrayAtomicsSymbol] = {}; if (Array.isArray(values)) { diff --git a/lib/types/arrayProxy.js b/lib/types/arrayProxy.js index 57d09b2b6df..89b686fd956 100644 --- a/lib/types/arrayProxy.js +++ b/lib/types/arrayProxy.js @@ -1,5 +1,7 @@ 'use strict'; +const CoreMongooseArray = require('./core_array'); + module.exports = function arrayProxy(arr) { const proxy = new Proxy(arr, { get: function(target, prop) { @@ -10,24 +12,17 @@ module.exports = function arrayProxy(arr) { return arr; } if (prop === 'set') { - return function set(i, val, skipModified) { - if (skipModified) { - arr[i] = val; - return arr; - } - const value = arr._cast(val, i); - arr[i] = value; - arr._markModified(i); - return arr; - }; + return set; + } + if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { + return CoreMongooseArray.prototype[prop]; } return target[prop]; }, set: function(target, prop, value) { if (typeof prop === 'string' && /^\d+$/.test(prop)) { - // console.log('Set', prop, value, new Error().stack); - target.set(prop, value); + set(prop, value); } else { target[prop] = value; } @@ -36,5 +31,16 @@ module.exports = function arrayProxy(arr) { } }); + function set(i, val, skipModified) { + if (skipModified) { + arr[i] = val; + return arr; + } + const value = CoreMongooseArray.prototype._cast.call(arr, val, i); + arr[i] = value; + CoreMongooseArray.prototype._markModified.call(arr, i); + return arr; + } + return proxy; }; \ No newline at end of file diff --git a/lib/types/core_array.js b/lib/types/core_array.js index b9cfd71f311..5939788f83d 100644 --- a/lib/types/core_array.js +++ b/lib/types/core_array.js @@ -589,7 +589,10 @@ class CoreMongooseArray extends Array { pull() { const values = [].map.call(arguments, this._cast, this); - const cur = this[arrayParentSymbol].get(this[arrayPathSymbol]); + let cur = this[arrayParentSymbol].get(this[arrayPathSymbol]); + if (cur.isMongooseArrayProxy) { + cur = cur.__array; + } let i = cur.length; let mem; diff --git a/test/types.array.test.js b/test/types.array.test.js index 52265db5db7..033770fd75e 100644 --- a/test/types.array.test.js +++ b/test/types.array.test.js @@ -60,6 +60,7 @@ describe('types array', function() { const doc = new Test({ arr: ['test'] }); assert.deepEqual(doc.arr, new MongooseArray(['test'])); + assert.deepEqual(doc.arr, ['test']); done(); }); From fc58adbd31f37b1385bbd639f8c63b3fd76f4908 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 18 Jan 2021 10:45:55 -0500 Subject: [PATCH 03/22] fix: clean up test failures on #8884 branch --- lib/types/array.js | 1 - lib/types/core_array.js | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/types/array.js b/lib/types/array.js index da372d1b1e9..8fdad8ab56f 100644 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -4,7 +4,6 @@ 'use strict'; -const CoreMongooseArray = require('./core_array'); const Document = require('../document'); const arrayProxy = require('./arrayProxy'); diff --git a/lib/types/core_array.js b/lib/types/core_array.js index 01ef4aa20c3..7f6fd0b42e1 100644 --- a/lib/types/core_array.js +++ b/lib/types/core_array.js @@ -589,10 +589,7 @@ class CoreMongooseArray extends Array { pull() { const values = [].map.call(arguments, this._cast, this); - let cur = this[arrayParentSymbol].get(this[arrayPathSymbol]); - if (cur.isMongooseArrayProxy) { - cur = cur.__array; - } + const cur = this[arrayParentSymbol].get(this[arrayPathSymbol]); let i = cur.length; let mem; From f7287836a82079f86ea87c3fcb14758ea1d884a1 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 18 Jan 2021 11:45:55 -0500 Subject: [PATCH 04/22] perf(array): improve perf on #9588 benchmark by 50% with #8884 proxy changes --- lib/schema/array.js | 5 ++++- lib/types/arrayProxy.js | 46 ++++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/lib/schema/array.js b/lib/schema/array.js index 498280a4813..4dbf3a2e852 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -352,6 +352,9 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { return value; } + // Bypass the array proxy for performance + const rawValue = value.__array; + const caster = this.caster; if (caster && this.casterConstructor !== Mixed) { try { @@ -368,7 +371,7 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { opts.arrayPath = this.caster._arrayParentPath + '.' + i; } } - value.set(i, caster.applySetters(value[i], doc, init, void 0, opts), true); + rawValue[i] = caster.applySetters(rawValue[i], doc, init, void 0, opts); } } catch (e) { // rethrow diff --git a/lib/types/arrayProxy.js b/lib/types/arrayProxy.js index 89b686fd956..27f945a1cdd 100644 --- a/lib/types/arrayProxy.js +++ b/lib/types/arrayProxy.js @@ -12,35 +12,53 @@ module.exports = function arrayProxy(arr) { return arr; } if (prop === 'set') { - return set; + return __set; } if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { return CoreMongooseArray.prototype[prop]; } - return target[prop]; + return arr[prop]; }, set: function(target, prop, value) { if (typeof prop === 'string' && /^\d+$/.test(prop)) { - set(prop, value); + set(arr, prop, value); } else { - target[prop] = value; + arr[prop] = value; } return true; } }); - function set(i, val, skipModified) { - if (skipModified) { - arr[i] = val; - return arr; - } - const value = CoreMongooseArray.prototype._cast.call(arr, val, i); - arr[i] = value; - CoreMongooseArray.prototype._markModified.call(arr, i); + return proxy; +}; + +/*! + * Used as a method by array instances + */ +function __set(i, val, skipModified) { + const arr = this.__array; + if (skipModified) { + arr[i] = val; return arr; } + const value = CoreMongooseArray.prototype._cast.call(arr, val, i); + arr[i] = value; + CoreMongooseArray.prototype._markModified.call(arr, i); + return arr; +} - return proxy; -}; \ No newline at end of file +/** + * Internal `set()` logic for proxies + */ +function set(arr, i, val, skipModified) { + if (skipModified) { + arr[i] = val; + return arr; + } + const value = CoreMongooseArray.prototype._cast.call(arr, val, i); + arr[i] = value; + CoreMongooseArray.prototype._markModified.call(arr, i); + return arr; +} \ No newline at end of file From 1ec2cc05e40b0b12c5c53fd0d244a511c1634fe3 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 18 Jan 2021 11:56:09 -0500 Subject: [PATCH 05/22] test(array): add some test coverage for #8884 --- test/types.array.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/types.array.test.js b/test/types.array.test.js index ce04d53a632..7c50cfd24aa 100644 --- a/test/types.array.test.js +++ b/test/types.array.test.js @@ -1421,7 +1421,7 @@ describe('types array', function() { save(m, function(err, doc) { assert.ifError(err); assert.equal(doc.arr.length, '4'); - doc.arr.set(2, 10); + doc.arr[2] = 10; assert.equal(doc.arr.length, 4); assert.equal(doc.arr[2], '10'); doc.arr.set(doc.arr.length, '11'); @@ -1629,7 +1629,7 @@ describe('types array', function() { doc.arr.set(0, 'THREE'); assert.strictEqual('three', doc.arr[0]); assert.strictEqual('two', doc.arr[1]); - doc.arr.set(doc.arr.length, 'FOUR'); + doc.arr[doc.arr.length] = 'FOUR'; assert.strictEqual('three', doc.arr[0]); assert.strictEqual('two', doc.arr[1]); assert.strictEqual('four', doc.arr[2]); From 4caea6564fd8b4097c761e534f24537e17772f5a Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 18 Jan 2021 14:00:08 -0500 Subject: [PATCH 06/22] fix(array): correct inspect output for array proxies in Node.js 14 re: #8884 --- lib/types/arrayProxy.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/types/arrayProxy.js b/lib/types/arrayProxy.js index 27f945a1cdd..8bfd21eb587 100644 --- a/lib/types/arrayProxy.js +++ b/lib/types/arrayProxy.js @@ -1,8 +1,12 @@ 'use strict'; const CoreMongooseArray = require('./core_array'); +const inspect = require('util').inspect.custom; module.exports = function arrayProxy(arr) { + if (inspect) { + arr[inspect] = CoreMongooseArray.prototype.inspect; + } const proxy = new Proxy(arr, { get: function(target, prop) { if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy') { From 6a4f9e0450fc3c7cdd16e1e5869735c3a66533cd Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 18 Jan 2021 14:00:23 -0500 Subject: [PATCH 07/22] BREAKING CHANGE: drop support for Node.js < 10 --- .github/workflows/test.yml | 2 +- .travis.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 96519ae8a18..c56b6f1bdd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - node: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + node: [10, 12, 14, 15] name: Node ${{ matrix.node }} steps: - uses: actions/checkout@v2 diff --git a/.travis.yml b/.travis.yml index ad98e77aa83..1cba4dd76b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: node_js sudo: false -node_js: [14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4] +node_js: [15, 14, 12, 10] install: - travis_retry npm install before_script: From b30ffd162da9029f96e13b7b98553113391505fa Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 18 Jan 2021 16:35:13 -0500 Subject: [PATCH 08/22] chore: wip --- lib/types/documentArrayProxy.js | 407 ++++++++++++++++++++++++++++++++ test/types.array.test.js | 2 +- 2 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 lib/types/documentArrayProxy.js diff --git a/lib/types/documentArrayProxy.js b/lib/types/documentArrayProxy.js new file mode 100644 index 00000000000..d88eecc5017 --- /dev/null +++ b/lib/types/documentArrayProxy.js @@ -0,0 +1,407 @@ +'use strict'; + +/*! + * Module dependencies. + */ + +const CoreMongooseArray = require('./core_array'); +const Document = require('../document'); +const ObjectId = require('./objectid'); +const castObjectId = require('../cast/objectid'); +const getDiscriminatorByValue = require('../helpers/discriminator/getDiscriminatorByValue'); +const inspect = require('util').inspect.custom; +const internalToObjectOptions = require('../options').internalToObjectOptions; +const util = require('util'); +const utils = require('../utils'); + +const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; +const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; +const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; +const documentArrayParent = require('../helpers/symbols').documentArrayParent; + +class CoreDocumentArray extends CoreMongooseArray { + get isMongooseDocumentArray() { + return true; + } + + /*! + * ignore + */ + + toBSON() { + return this.toObject(internalToObjectOptions); + } + + /** + * Overrides MongooseArray#cast + * + * @method _cast + * @api private + * @receiver MongooseDocumentArray + */ + + _cast(value, index) { + if (this[arraySchemaSymbol] == null) { + return value; + } + let Constructor = this[arraySchemaSymbol].casterConstructor; + const isInstance = Constructor.$isMongooseDocumentArray ? + value && value.isMongooseDocumentArray : + value instanceof Constructor; + if (isInstance || + // Hack re: #5001, see #5005 + (value && value.constructor && value.constructor.baseCasterConstructor === Constructor)) { + if (!(value[documentArrayParent] && value.__parentArray)) { + // value may have been created using array.create() + value[documentArrayParent] = this[arrayParentSymbol]; + value.__parentArray = this; + } + value.$setIndex(index); + return value; + } + + if (value === undefined || value === null) { + return null; + } + + // handle cast('string') or cast(ObjectId) etc. + // 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 }; + } + + if (value && + Constructor.discriminators && + Constructor.schema && + Constructor.schema.options && + Constructor.schema.options.discriminatorKey) { + if (typeof value[Constructor.schema.options.discriminatorKey] === 'string' && + Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]) { + Constructor = Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]; + } else { + const constructorByValue = getDiscriminatorByValue(Constructor, value[Constructor.schema.options.discriminatorKey]); + if (constructorByValue) { + Constructor = constructorByValue; + } + } + } + + if (Constructor.$isMongooseDocumentArray) { + return Constructor.cast(value, this, undefined, undefined, index); + } + const ret = new Constructor(value, this, undefined, undefined, index); + ret.isNew = true; + return ret; + } + + /** + * Searches array items for the first document with a matching _id. + * + * ####Example: + * + * const embeddedDoc = m.array.id(some_id); + * + * @return {EmbeddedDocument|null} the subdocument or null if not found. + * @param {ObjectId|String|Number|Buffer} id + * @TODO cast to the _id based on schema for proper comparison + * @method id + * @api public + * @receiver MongooseDocumentArray + */ + + id(id) { + let casted; + let sid; + let _id; + + try { + casted = castObjectId(id).toString(); + } catch (e) { + casted = null; + } + + for (const val of this) { + if (!val) { + continue; + } + + _id = val.get('_id'); + + if (_id === null || typeof _id === 'undefined') { + continue; + } else if (_id instanceof Document) { + sid || (sid = String(id)); + if (sid == _id._id) { + return val; + } + } else if (!(id instanceof ObjectId) && !(_id instanceof ObjectId)) { + if (id == _id || utils.deepEqual(id, _id)) { + return val; + } + } else if (casted == _id) { + return val; + } + } + + return null; + } + + /** + * Returns a native js Array of plain js objects + * + * ####NOTE: + * + * _Each sub-document is converted to a plain object by calling its `#toObject` method._ + * + * @param {Object} [options] optional options to pass to each documents `toObject` method call during conversion + * @return {Array} + * @method toObject + * @api public + * @receiver MongooseDocumentArray + */ + + toObject(options) { + // `[].concat` coerces the return value into a vanilla JS array, rather + // than a Mongoose array. + return [].concat(this.map(function(doc) { + if (doc == null) { + return null; + } + if (typeof doc.toObject !== 'function') { + return doc; + } + return doc.toObject(options); + })); + } + + /** + * 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 + * @memberOf MongooseDocumentArray + */ + + push() { + const ret = super.push.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + } + + /** + * Pulls items from the array atomically. + * + * @param {Object} [args...] + * @api public + * @method pull + * @memberOf MongooseDocumentArray + */ + + pull() { + const ret = super.pull.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + } + + /** + * Wraps [`Array#shift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking. + */ + + shift() { + const ret = super.shift.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + } + + /** + * Wraps [`Array#splice`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/splice) with proper change tracking and casting. + */ + + splice() { + const ret = super.splice.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + } + + /** + * Helper for console.log + * + * @method inspect + * @api public + * @receiver MongooseDocumentArray + */ + + inspect() { + console.log('F', this.isMongooseDocumentArray, this.isMongooseDocumentArrayProxy) + return this.toObject(); + } + + /** + * Creates a subdocument casted to this schema. + * + * This is the same subdocument constructor used for casting. + * + * @param {Object} obj the value to cast to this arrays SubDocument schema + * @method create + * @api public + * @receiver MongooseDocumentArray + */ + + create(obj) { + let Constructor = this[arraySchemaSymbol].casterConstructor; + if (obj && + Constructor.discriminators && + Constructor.schema && + Constructor.schema.options && + Constructor.schema.options.discriminatorKey) { + if (typeof obj[Constructor.schema.options.discriminatorKey] === 'string' && + Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]) { + Constructor = Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]; + } else { + const constructorByValue = getDiscriminatorByValue(Constructor, obj[Constructor.schema.options.discriminatorKey]); + if (constructorByValue) { + Constructor = constructorByValue; + } + } + } + + return new Constructor(obj, this); + } + + /*! + * ignore + */ + + notify(event) { + const _this = this; + return function notify(val, _arr) { + _arr = _arr || _this; + let i = _arr.length; + while (i--) { + if (_arr[i] == null) { + continue; + } + switch (event) { + // only swap for save event for now, we may change this to all event types later + case 'save': + val = _this[i]; + break; + default: + // NO-OP + break; + } + + if (_arr[i].isMongooseArray) { + notify(val, _arr[i]); + } else if (_arr[i]) { + _arr[i].emit(event, val); + } + } + }; + } +} + +if (util.inspect.custom) { + CoreDocumentArray.prototype[util.inspect.custom] = + CoreDocumentArray.prototype.inspect; +} + +/*! + * If this is a document array, each element may contain single + * populated paths, so we need to modify the top-level document's + * populated cache. See gh-8247, gh-8265. + */ + +function _updateParentPopulated(arr) { + const parent = arr[arrayParentSymbol]; + if (!parent || parent.$__.populated == null) return; + + const populatedPaths = Object.keys(parent.$__.populated). + filter(p => p.startsWith(arr[arrayPathSymbol] + '.')); + + for (const path of populatedPaths) { + const remnant = path.slice((arr[arrayPathSymbol] + '.').length); + if (!Array.isArray(parent.$__.populated[path].value)) { + continue; + } + + parent.$__.populated[path].value = arr.map(val => val.populated(remnant)); + } +} + +module.exports = function arrayProxy(arr) { + if (inspect) { + arr[inspect] = CoreDocumentArray.prototype.inspect; + } + const proxy = new Proxy(arr, { + get: function(target, prop) { + if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy' || prop === 'isMongooseDocumentArray' || prop === 'isMongooseDocumentArrayProxy') { + return true; + } + if (prop === '__array') { + return arr; + } + if (prop === 'set') { + return __set; + } + if (CoreDocumentArray.prototype.hasOwnProperty(prop)) { + return CoreDocumentArray.prototype[prop]; + } else if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { + return CoreMongooseArray.prototype[prop]; + } + + return arr[prop]; + }, + set: function(target, prop, value) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + set(arr, prop, value); + } else { + arr[prop] = value; + } + + return true; + } + }); + + return proxy; +}; + +/*! + * Used as a method by array instances + */ +function __set(i, val, skipModified) { + const arr = this.__array; + if (skipModified) { + arr[i] = val; + return arr; + } + const value = CoreDocumentArray.prototype._cast.call(arr, val, i); + arr[i] = value; + CoreDocumentArray.prototype._markModified.call(arr, i); + return arr; +} + +/** + * Internal `set()` logic for proxies + */ +function set(arr, i, val, skipModified) { + if (skipModified) { + arr[i] = val; + return arr; + } + const value = CoreDocumentArray.prototype._cast.call(arr, val, i); + arr[i] = value; + CoreDocumentArray.prototype._markModified.call(arr, i); + return arr; +} \ No newline at end of file diff --git a/test/types.array.test.js b/test/types.array.test.js index 7c50cfd24aa..4da49406aa7 100644 --- a/test/types.array.test.js +++ b/test/types.array.test.js @@ -1557,7 +1557,7 @@ describe('types array', function() { save(m, function(err, doc) { assert.ifError(err); assert.equal(doc.arr.length, 2); - doc.arr.set(0, { name: 'vdrums' }); + doc.arr[0] = { name: 'vdrums' }; assert.equal(doc.arr.length, 2); assert.equal(doc.arr[0].name, 'vdrums'); doc.arr.set(doc.arr.length, { name: 'Restrepo' }); From 856f39ca41b32f0c06228373ef3034fb86645531 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 5 Feb 2021 13:22:42 -0500 Subject: [PATCH 09/22] feat(types): make document arrays into proxies Re: #8884 --- lib/types/documentArrayProxy.js | 407 -------------------------------- lib/types/documentarray.js | 70 +++++- 2 files changed, 66 insertions(+), 411 deletions(-) delete mode 100644 lib/types/documentArrayProxy.js diff --git a/lib/types/documentArrayProxy.js b/lib/types/documentArrayProxy.js deleted file mode 100644 index d88eecc5017..00000000000 --- a/lib/types/documentArrayProxy.js +++ /dev/null @@ -1,407 +0,0 @@ -'use strict'; - -/*! - * Module dependencies. - */ - -const CoreMongooseArray = require('./core_array'); -const Document = require('../document'); -const ObjectId = require('./objectid'); -const castObjectId = require('../cast/objectid'); -const getDiscriminatorByValue = require('../helpers/discriminator/getDiscriminatorByValue'); -const inspect = require('util').inspect.custom; -const internalToObjectOptions = require('../options').internalToObjectOptions; -const util = require('util'); -const utils = require('../utils'); - -const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; -const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; -const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; -const documentArrayParent = require('../helpers/symbols').documentArrayParent; - -class CoreDocumentArray extends CoreMongooseArray { - get isMongooseDocumentArray() { - return true; - } - - /*! - * ignore - */ - - toBSON() { - return this.toObject(internalToObjectOptions); - } - - /** - * Overrides MongooseArray#cast - * - * @method _cast - * @api private - * @receiver MongooseDocumentArray - */ - - _cast(value, index) { - if (this[arraySchemaSymbol] == null) { - return value; - } - let Constructor = this[arraySchemaSymbol].casterConstructor; - const isInstance = Constructor.$isMongooseDocumentArray ? - value && value.isMongooseDocumentArray : - value instanceof Constructor; - if (isInstance || - // Hack re: #5001, see #5005 - (value && value.constructor && value.constructor.baseCasterConstructor === Constructor)) { - if (!(value[documentArrayParent] && value.__parentArray)) { - // value may have been created using array.create() - value[documentArrayParent] = this[arrayParentSymbol]; - value.__parentArray = this; - } - value.$setIndex(index); - return value; - } - - if (value === undefined || value === null) { - return null; - } - - // handle cast('string') or cast(ObjectId) etc. - // 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 }; - } - - if (value && - Constructor.discriminators && - Constructor.schema && - Constructor.schema.options && - Constructor.schema.options.discriminatorKey) { - if (typeof value[Constructor.schema.options.discriminatorKey] === 'string' && - Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]) { - Constructor = Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]; - } else { - const constructorByValue = getDiscriminatorByValue(Constructor, value[Constructor.schema.options.discriminatorKey]); - if (constructorByValue) { - Constructor = constructorByValue; - } - } - } - - if (Constructor.$isMongooseDocumentArray) { - return Constructor.cast(value, this, undefined, undefined, index); - } - const ret = new Constructor(value, this, undefined, undefined, index); - ret.isNew = true; - return ret; - } - - /** - * Searches array items for the first document with a matching _id. - * - * ####Example: - * - * const embeddedDoc = m.array.id(some_id); - * - * @return {EmbeddedDocument|null} the subdocument or null if not found. - * @param {ObjectId|String|Number|Buffer} id - * @TODO cast to the _id based on schema for proper comparison - * @method id - * @api public - * @receiver MongooseDocumentArray - */ - - id(id) { - let casted; - let sid; - let _id; - - try { - casted = castObjectId(id).toString(); - } catch (e) { - casted = null; - } - - for (const val of this) { - if (!val) { - continue; - } - - _id = val.get('_id'); - - if (_id === null || typeof _id === 'undefined') { - continue; - } else if (_id instanceof Document) { - sid || (sid = String(id)); - if (sid == _id._id) { - return val; - } - } else if (!(id instanceof ObjectId) && !(_id instanceof ObjectId)) { - if (id == _id || utils.deepEqual(id, _id)) { - return val; - } - } else if (casted == _id) { - return val; - } - } - - return null; - } - - /** - * Returns a native js Array of plain js objects - * - * ####NOTE: - * - * _Each sub-document is converted to a plain object by calling its `#toObject` method._ - * - * @param {Object} [options] optional options to pass to each documents `toObject` method call during conversion - * @return {Array} - * @method toObject - * @api public - * @receiver MongooseDocumentArray - */ - - toObject(options) { - // `[].concat` coerces the return value into a vanilla JS array, rather - // than a Mongoose array. - return [].concat(this.map(function(doc) { - if (doc == null) { - return null; - } - if (typeof doc.toObject !== 'function') { - return doc; - } - return doc.toObject(options); - })); - } - - /** - * 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 - * @memberOf MongooseDocumentArray - */ - - push() { - const ret = super.push.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Pulls items from the array atomically. - * - * @param {Object} [args...] - * @api public - * @method pull - * @memberOf MongooseDocumentArray - */ - - pull() { - const ret = super.pull.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Wraps [`Array#shift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking. - */ - - shift() { - const ret = super.shift.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Wraps [`Array#splice`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/splice) with proper change tracking and casting. - */ - - splice() { - const ret = super.splice.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Helper for console.log - * - * @method inspect - * @api public - * @receiver MongooseDocumentArray - */ - - inspect() { - console.log('F', this.isMongooseDocumentArray, this.isMongooseDocumentArrayProxy) - return this.toObject(); - } - - /** - * Creates a subdocument casted to this schema. - * - * This is the same subdocument constructor used for casting. - * - * @param {Object} obj the value to cast to this arrays SubDocument schema - * @method create - * @api public - * @receiver MongooseDocumentArray - */ - - create(obj) { - let Constructor = this[arraySchemaSymbol].casterConstructor; - if (obj && - Constructor.discriminators && - Constructor.schema && - Constructor.schema.options && - Constructor.schema.options.discriminatorKey) { - if (typeof obj[Constructor.schema.options.discriminatorKey] === 'string' && - Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]) { - Constructor = Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]; - } else { - const constructorByValue = getDiscriminatorByValue(Constructor, obj[Constructor.schema.options.discriminatorKey]); - if (constructorByValue) { - Constructor = constructorByValue; - } - } - } - - return new Constructor(obj, this); - } - - /*! - * ignore - */ - - notify(event) { - const _this = this; - return function notify(val, _arr) { - _arr = _arr || _this; - let i = _arr.length; - while (i--) { - if (_arr[i] == null) { - continue; - } - switch (event) { - // only swap for save event for now, we may change this to all event types later - case 'save': - val = _this[i]; - break; - default: - // NO-OP - break; - } - - if (_arr[i].isMongooseArray) { - notify(val, _arr[i]); - } else if (_arr[i]) { - _arr[i].emit(event, val); - } - } - }; - } -} - -if (util.inspect.custom) { - CoreDocumentArray.prototype[util.inspect.custom] = - CoreDocumentArray.prototype.inspect; -} - -/*! - * If this is a document array, each element may contain single - * populated paths, so we need to modify the top-level document's - * populated cache. See gh-8247, gh-8265. - */ - -function _updateParentPopulated(arr) { - const parent = arr[arrayParentSymbol]; - if (!parent || parent.$__.populated == null) return; - - const populatedPaths = Object.keys(parent.$__.populated). - filter(p => p.startsWith(arr[arrayPathSymbol] + '.')); - - for (const path of populatedPaths) { - const remnant = path.slice((arr[arrayPathSymbol] + '.').length); - if (!Array.isArray(parent.$__.populated[path].value)) { - continue; - } - - parent.$__.populated[path].value = arr.map(val => val.populated(remnant)); - } -} - -module.exports = function arrayProxy(arr) { - if (inspect) { - arr[inspect] = CoreDocumentArray.prototype.inspect; - } - const proxy = new Proxy(arr, { - get: function(target, prop) { - if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy' || prop === 'isMongooseDocumentArray' || prop === 'isMongooseDocumentArrayProxy') { - return true; - } - if (prop === '__array') { - return arr; - } - if (prop === 'set') { - return __set; - } - if (CoreDocumentArray.prototype.hasOwnProperty(prop)) { - return CoreDocumentArray.prototype[prop]; - } else if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { - return CoreMongooseArray.prototype[prop]; - } - - return arr[prop]; - }, - set: function(target, prop, value) { - if (typeof prop === 'string' && /^\d+$/.test(prop)) { - set(arr, prop, value); - } else { - arr[prop] = value; - } - - return true; - } - }); - - return proxy; -}; - -/*! - * Used as a method by array instances - */ -function __set(i, val, skipModified) { - const arr = this.__array; - if (skipModified) { - arr[i] = val; - return arr; - } - const value = CoreDocumentArray.prototype._cast.call(arr, val, i); - arr[i] = value; - CoreDocumentArray.prototype._markModified.call(arr, i); - return arr; -} - -/** - * Internal `set()` logic for proxies - */ -function set(arr, i, val, skipModified) { - if (skipModified) { - arr[i] = val; - return arr; - } - const value = CoreDocumentArray.prototype._cast.call(arr, val, i); - arr[i] = value; - CoreDocumentArray.prototype._markModified.call(arr, i); - return arr; -} \ No newline at end of file diff --git a/lib/types/documentarray.js b/lib/types/documentarray.js index 03e3ee898c0..f7f76bbbde9 100644 --- a/lib/types/documentarray.js +++ b/lib/types/documentarray.js @@ -341,6 +341,70 @@ function _updateParentPopulated(arr) { } } +function documentArrayProxy(arr) { + const proxy = new Proxy(arr, { + get: function(target, prop) { + if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy' || prop === 'isMongooseDocumentArray') { + return true; + } + if (prop === '__array') { + return arr; + } + if (prop === 'set') { + return __set; + } + if (CoreDocumentArray.prototype.hasOwnProperty(prop)) { + return CoreDocumentArray.prototype[prop]; + } + if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { + return CoreMongooseArray.prototype[prop]; + } + + return arr[prop]; + }, + set: function(target, prop, value) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + set(proxy, prop, value); + } else { + arr[prop] = value; + } + + return true; + } + }); + + return proxy; +}; + +/*! + * Used as a method by array instances + */ +function __set(i, val, skipModified) { + const arr = this.__array; + if (skipModified) { + arr[i] = val; + return arr; + } + const value = CoreDocumentArray.prototype._cast.call(this, val, i); + arr[i] = value; + CoreDocumentArray.prototype._markModified.call(this, i); + return arr; +} + +/** + * Internal `set()` logic for proxies + */ +function set(target, i, val, skipModified) { + if (skipModified) { + target.__array[i] = val; + return target; + } + const value = CoreDocumentArray.prototype._cast.call(target, val, i); + target.__array[i] = value; + CoreDocumentArray.prototype._markModified.call(target, i); + return target; +} + /** * DocumentArray constructor * @@ -354,9 +418,7 @@ function _updateParentPopulated(arr) { */ function MongooseDocumentArray(values, path, doc) { - // TODO: replace this with `new CoreDocumentArray().concat()` when we remove - // support for node 4.x and 5.x, see https://i.imgur.com/UAAHk4S.png - const arr = new CoreDocumentArray(); + const arr = []; arr[arrayAtomicsSymbol] = {}; arr[arraySchemaSymbol] = void 0; @@ -391,7 +453,7 @@ function MongooseDocumentArray(values, path, doc) { } } - return arr; + return documentArrayProxy(arr); } /*! From ff1b6a606863fafc1913f3446e3394bdda32dbfb Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 6 Feb 2021 12:04:07 -0500 Subject: [PATCH 10/22] fix: correctly handle updating document arrays internally --- lib/schema/documentarray.js | 38 +++++++++++++++++++------------------ lib/types/documentarray.js | 5 ++++- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/schema/documentarray.js b/lib/schema/documentarray.js index 2dc30a0c008..c645be714db 100644 --- a/lib/schema/documentarray.js +++ b/lib/schema/documentarray.js @@ -389,33 +389,35 @@ DocumentArrayPath.prototype.cast = function(value, doc, init, prev, options) { value[arrayPathSymbol] = options.arrayPath; } - const len = value.length; + const rawArray = value.isMongooseDocumentArrayProxy ? value.__array : value; + + const len = rawArray.length; const initDocumentOptions = { skipId: true, willInit: true }; for (let i = 0; i < len; ++i) { - if (!value[i]) { + if (!rawArray[i]) { continue; } const Constructor = getConstructor(this.casterConstructor, value[i]); // Check if the document has a different schema (re gh-3701) - if ((value[i].$__) && - (!(value[i] instanceof Constructor) || value[i][documentArrayParent] !== doc)) { - value[i] = value[i].toObject({ + if ((rawArray[i].$__) && + (!(rawArray[i] instanceof Constructor) || rawArray[i][documentArrayParent] !== doc)) { + rawArray[i] = rawArray[i].toObject({ transform: false, // Special case: if different model, but same schema, apply virtuals // re: gh-7898 - virtuals: value[i].schema === Constructor.schema + virtuals: rawArray[i].schema === Constructor.schema }); } - if (value[i] instanceof Subdocument) { + if (rawArray[i] instanceof Subdocument) { // Might not have the correct index yet, so ensure it does. - if (value[i].__index == null) { - value[i].$setIndex(i); + if (rawArray[i].__index == null) { + rawArray[i].$setIndex(i); } - } else if (value[i] != null) { + } else if (rawArray[i] != null) { if (init) { if (doc) { selected || (selected = scopePaths(this, doc.$__.selected, init)); @@ -424,27 +426,27 @@ DocumentArrayPath.prototype.cast = function(value, doc, init, prev, options) { } subdoc = new Constructor(null, value, initDocumentOptions, selected, i); - value[i] = subdoc.init(value[i]); + rawArray[i] = subdoc.init(rawArray[i]); } else { if (prev && typeof prev.id === 'function') { - subdoc = prev.id(value[i]._id); + subdoc = prev.id(rawArray[i]._id); } - if (prev && subdoc && utils.deepEqual(subdoc.toObject(_opts), value[i])) { + if (prev && subdoc && utils.deepEqual(subdoc.toObject(_opts), rawArray[i])) { // handle resetting doc with existing id and same data - subdoc.set(value[i]); + subdoc.set(rawArray[i]); // if set() is hooked it will have no return value // see gh-746 - value[i] = subdoc; + rawArray[i] = subdoc; } else { try { - subdoc = new Constructor(value[i], value, undefined, + subdoc = new Constructor(rawArray[i], value, undefined, undefined, i); // if set() is hooked it will have no return value // see gh-746 - value[i] = subdoc; + rawArray[i] = subdoc; } catch (error) { - const valueInErrorMessage = util.inspect(value[i]); + const valueInErrorMessage = util.inspect(rawArray[i]); throw new CastError('embedded', valueInErrorMessage, value[arrayPathSymbol], error, this); } diff --git a/lib/types/documentarray.js b/lib/types/documentarray.js index f7f76bbbde9..0414fe3f967 100644 --- a/lib/types/documentarray.js +++ b/lib/types/documentarray.js @@ -344,7 +344,10 @@ function _updateParentPopulated(arr) { function documentArrayProxy(arr) { const proxy = new Proxy(arr, { get: function(target, prop) { - if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy' || prop === 'isMongooseDocumentArray') { + if (prop === 'isMongooseArray' || + prop === 'isMongooseArrayProxy' || + prop === 'isMongooseDocumentArray' || + prop === 'isMongooseDocumentArrayProxy') { return true; } if (prop === '__array') { From 39b1c504ee3f8006a4bf6b118e430f06fccdc57f Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 6 Feb 2021 12:35:07 -0500 Subject: [PATCH 11/22] test: test cleanup re: #8884 --- test/types.document.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/types.document.test.js b/test/types.document.test.js index a2f7c4ee8b6..002060ff9e3 100644 --- a/test/types.document.test.js +++ b/test/types.document.test.js @@ -37,9 +37,7 @@ describe('types.document', function() { Dummy.prototype.$__setSchema(new Schema); function _Subdocument() { - const arr = new DocumentArray; - arr.$path = () => 'jsconf.ar'; - arr.$parent = () => new Dummy; + const arr = new DocumentArray([], 'jsconf.ar', new Dummy); arr[0] = this; ArraySubdocument.call(this, {}, arr); } From a8e2c18a9dcacf4312de40df0f635a320b83714a Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 6 Feb 2021 13:52:50 -0500 Subject: [PATCH 12/22] fix: correct proxy-based change tracking with addToSet() re: #8884 --- lib/types/core_array.js | 7 +++++-- lib/types/documentarray.js | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/types/core_array.js b/lib/types/core_array.js index 7f6fd0b42e1..56f7e9280a8 100644 --- a/lib/types/core_array.js +++ b/lib/types/core_array.js @@ -417,7 +417,10 @@ class CoreMongooseArray extends Array { type = 'date'; } - values.forEach(function(v) { + const rawValues = values.isMongooseArrayProxy ? values.__array : this; + const rawArray = this.isMongooseArrayProxy ? this.__array : this; + + rawValues.forEach(function(v) { let found; const val = +v; switch (type) { @@ -436,7 +439,7 @@ class CoreMongooseArray extends Array { } if (!found) { - [].push.call(this, v); + rawArray.push(v); this._registerAtomic('$addToSet', v); this._markModified(); [].push.call(added, v); diff --git a/lib/types/documentarray.js b/lib/types/documentarray.js index 0414fe3f967..a686cc3dffd 100644 --- a/lib/types/documentarray.js +++ b/lib/types/documentarray.js @@ -377,7 +377,7 @@ function documentArrayProxy(arr) { }); return proxy; -}; +} /*! * Used as a method by array instances From f08b9a5be898c03cf188806a0fdbfe83a552132b Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 6 Feb 2021 14:10:52 -0500 Subject: [PATCH 13/22] refactor: start moving symbols out of raw arrays --- lib/types/documentarray.js | 76 ++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/lib/types/documentarray.js b/lib/types/documentarray.js index a686cc3dffd..3df06b2a4b5 100644 --- a/lib/types/documentarray.js +++ b/lib/types/documentarray.js @@ -341,7 +341,41 @@ function _updateParentPopulated(arr) { } } -function documentArrayProxy(arr) { +function documentArrayProxy(arr, values, path, doc) { + let arrayAtomics = {}; + + arr[arraySchemaSymbol] = void 0; + if (Array.isArray(values)) { + if (values[arrayPathSymbol] === path && + values[arrayParentSymbol] === doc) { + arrayAtomics = Object.assign({}, values[arrayAtomicsSymbol]); + } + values.forEach(v => { + _basePush.call(arr, v); + }); + } + arr[arrayPathSymbol] = path; + + // Because doc comes from the context of another function, doc === global + // can happen if there was a null somewhere up the chain (see #3020 && #3034) + // 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[arrayParentSymbol] = doc; + arr[arraySchemaSymbol] = doc.schema.path(path); + + // `schema.path()` doesn't drill into nested arrays properly yet, see + // gh-6398, gh-6602. This is a workaround because nested arrays are + // always plain non-document arrays, so once you get to a document array + // nesting is done. Matryoshka code. + while (arr != null && + arr[arraySchemaSymbol] != null && + arr[arraySchemaSymbol].$isMongooseArray && + !arr[arraySchemaSymbol].$isMongooseDocumentArray) { + arr[arraySchemaSymbol] = arr[arraySchemaSymbol].casterConstructor; + } + } + const proxy = new Proxy(arr, { get: function(target, prop) { if (prop === 'isMongooseArray' || @@ -356,6 +390,9 @@ function documentArrayProxy(arr) { if (prop === 'set') { return __set; } + if (prop === arrayAtomicsSymbol) { + return arrayAtomics; + } if (CoreDocumentArray.prototype.hasOwnProperty(prop)) { return CoreDocumentArray.prototype[prop]; } @@ -368,6 +405,8 @@ function documentArrayProxy(arr) { set: function(target, prop, value) { if (typeof prop === 'string' && /^\d+$/.test(prop)) { set(proxy, prop, value); + } else if (prop === arrayAtomicsSymbol) { + arrayAtomics = value; } else { arr[prop] = value; } @@ -423,40 +462,7 @@ function set(target, i, val, skipModified) { function MongooseDocumentArray(values, path, doc) { const arr = []; - arr[arrayAtomicsSymbol] = {}; - arr[arraySchemaSymbol] = void 0; - if (Array.isArray(values)) { - if (values[arrayPathSymbol] === path && - values[arrayParentSymbol] === doc) { - arr[arrayAtomicsSymbol] = Object.assign({}, values[arrayAtomicsSymbol]); - } - values.forEach(v => { - _basePush.call(arr, v); - }); - } - arr[arrayPathSymbol] = path; - - // Because doc comes from the context of another function, doc === global - // can happen if there was a null somewhere up the chain (see #3020 && #3034) - // 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[arrayParentSymbol] = doc; - arr[arraySchemaSymbol] = doc.schema.path(path); - - // `schema.path()` doesn't drill into nested arrays properly yet, see - // gh-6398, gh-6602. This is a workaround because nested arrays are - // always plain non-document arrays, so once you get to a document array - // nesting is done. Matryoshka code. - while (arr != null && - arr[arraySchemaSymbol] != null && - arr[arraySchemaSymbol].$isMongooseArray && - !arr[arraySchemaSymbol].$isMongooseDocumentArray) { - arr[arraySchemaSymbol] = arr[arraySchemaSymbol].casterConstructor; - } - } - - return documentArrayProxy(arr); + return documentArrayProxy(arr, values, path, doc); } /*! From e1fb40ba295dbc2a0d6dc80dad5ee5abeb3629a9 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 10 Feb 2021 10:12:22 -0500 Subject: [PATCH 14/22] fix: remove atomics from core array, move into `internals` object Re: #8884 --- lib/types/array.js | 72 ++++++++++++++++++++++++++++++++++++----- lib/types/core_array.js | 27 ++++++++++------ test/schema.test.js | 3 +- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/lib/types/array.js b/lib/types/array.js index 8fdad8ab56f..4bc3e095b6d 100644 --- a/lib/types/array.js +++ b/lib/types/array.js @@ -4,10 +4,11 @@ 'use strict'; +const CoreMongooseArray = require('./core_array'); const Document = require('../document'); -const arrayProxy = require('./arrayProxy'); const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; +const arrayAtomicsBackupSymbol = require('../helpers/symbols').arrayAtomicsBackupSymbol; const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; @@ -31,7 +32,14 @@ const _basePush = Array.prototype.push; function MongooseArray(values, path, doc, schematype) { const arr = []; - arr[arrayAtomicsSymbol] = {}; + + const internals = { + [arrayAtomicsSymbol]: {}, + [arrayAtomicsBackupSymbol]: void 0, + [arrayPathSymbol]: path, + [arraySchemaSymbol]: void 0, + [arrayParentSymbol]: void 0 + }; if (Array.isArray(values)) { const len = values.length; @@ -40,23 +48,71 @@ function MongooseArray(values, path, doc, schematype) { } if (values[arrayAtomicsSymbol] != null) { - arr[arrayAtomicsSymbol] = values[arrayAtomicsSymbol]; + internals[arrayAtomicsSymbol] = values[arrayAtomicsSymbol]; } } - arr[arrayPathSymbol] = path; - arr[arraySchemaSymbol] = void 0; + internals[arrayPathSymbol] = path; + internals[arraySchemaSymbol] = 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[arrayParentSymbol] = doc; - arr[arraySchemaSymbol] = schematype || doc.schema.path(path); + internals[arrayParentSymbol] = doc; + internals[arraySchemaSymbol] = schematype || doc.schema.path(path); } - return arrayProxy(arr); + const proxy = new Proxy(arr, { + get: function(target, prop) { + if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy') { + return true; + } + if (prop === '__array') { + return arr; + } + if (prop === 'set') { + return set; + } + if (internals.hasOwnProperty(prop)) { + return internals[prop]; + } + if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { + return CoreMongooseArray.prototype[prop]; + } + + return arr[prop]; + }, + set: function(target, prop, value) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + set.call(proxy, prop, value); + } else if (internals.hasOwnProperty(prop)) { + internals[prop] = value; + } else { + arr[prop] = value; + } + + return true; + } + }); + + return proxy; +} + +/*! + * Used as a method by array instances + */ +function set(i, val, skipModified) { + const arr = this.__array; + if (skipModified) { + arr[i] = val; + return arr; + } + const value = CoreMongooseArray.prototype._cast.call(this, val, i); + arr[i] = value; + CoreMongooseArray.prototype._markModified.call(this, i); + return arr; } /*! diff --git a/lib/types/core_array.js b/lib/types/core_array.js index 56f7e9280a8..0f2c1c104c0 100644 --- a/lib/types/core_array.js +++ b/lib/types/core_array.js @@ -350,7 +350,7 @@ class CoreMongooseArray extends Array { // check for impossible $atomic combos (Mongo denies more than one // $atomic op on a single path - if (this[arrayAtomicsSymbol].$set || Object.keys(atomics).length && !(op in atomics)) { + if (atomics.$set || Object.keys(atomics).length && !(op in atomics)) { // a different op was previously registered. // save the entire thing. this[arrayAtomicsSymbol] = { $set: this }; @@ -685,13 +685,13 @@ class CoreMongooseArray extends Array { atomic.$each = values; if (get(atomics, '$push.$each.length', 0) > 0 && - atomics.$push.$position != atomics.$position) { + atomics.$push.$position != atomic.$position) { throw new MongooseError('Cannot call `Array#push()` multiple times ' + 'with different `$position`'); } if (atomic.$position != null) { - [].splice.apply(this, [atomic.$position, 0].concat(values)); + [].splice.apply(arr, [atomic.$position, 0].concat(values)); ret = this.length; } else { ret = [].push.apply(arr, values); @@ -705,6 +705,7 @@ class CoreMongooseArray extends Array { atomic = values; ret = [].push.apply(arr, values); } + this._registerAtomic('$push', atomic); this._markModified(); return ret; @@ -783,7 +784,8 @@ class CoreMongooseArray extends Array { */ shift() { - const ret = [].shift.call(this); + const arr = this.isMongooseArrayProxy ? this.__array : this; + const ret = [].shift.call(arr); this._registerAtomic('$set', this); this._markModified(); return ret; @@ -803,7 +805,8 @@ class CoreMongooseArray extends Array { */ sort() { - const ret = [].sort.apply(this, arguments); + const arr = this.isMongooseArrayProxy ? this.__array : this; + const ret = [].sort.apply(arr, arguments); this._registerAtomic('$set', this); return ret; } @@ -823,6 +826,7 @@ class CoreMongooseArray extends Array { splice() { let ret; + const arr = this.isMongooseArrayProxy ? this.__array : this; _checkManualPopulation(this, Array.prototype.slice.call(arguments, 2)); @@ -839,7 +843,7 @@ class CoreMongooseArray extends Array { } } - ret = [].splice.apply(this, vals); + ret = [].splice.apply(arr, vals); this._registerAtomic('$set', this); } @@ -865,19 +869,20 @@ class CoreMongooseArray extends Array { */ toObject(options) { + const arr = this.isMongooseArrayProxy ? this.__array : this; if (options && options.depopulate) { options = utils.clone(options); options._isNested = true; // Ensure return value is a vanilla array, because in Node.js 6+ `map()` // is smart enough to use the inherited array's constructor. - return [].concat(this).map(function(doc) { + return [].concat(arr).map(function(doc) { return doc instanceof Document ? doc.toObject(options) : doc; }); } - return [].concat(this); + return [].concat(arr); } /** @@ -903,7 +908,8 @@ class CoreMongooseArray extends Array { values = this[arraySchemaSymbol].applySetters(values, this[arrayParentSymbol]); } - [].unshift.apply(this, values); + const arr = this.isMongooseArrayProxy ? this.__array : this; + [].unshift.apply(arr, values); this._registerAtomic('$set', this); this._markModified(); return this.length; @@ -969,7 +975,8 @@ for (const method of returnVanillaArrayMethods) { } CoreMongooseArray.prototype[method] = function() { - const arr = [].concat(this); + const _arr = this.isMongooseArrayProxy ? this.__array : this; + const arr = [].concat(_arr); return arr[method].apply(arr, arguments); }; diff --git a/test/schema.test.js b/test/schema.test.js index cb9280e2663..c157e86b2fd 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -441,8 +441,7 @@ describe('schema', function() { } catch (error) { threw = true; assert.equal(error.name, 'CastError'); - assert.equal(error.message, - 'Cast to [[Number]] failed for value "[["abcd"]]" at path "nums.0"'); + assert.ok(error.message.includes('Cast to [[Number]] failed'), error.message); } assert.ok(threw); From 54b265cb0c92324d9f165c98c361781cc17bfd8d Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 10 Feb 2021 11:15:00 -0500 Subject: [PATCH 15/22] refactor: move array to separate directory re: #8884 --- lib/types/{array.js => array/index.js} | 16 +++--- lib/types/arrayProxy.js | 68 -------------------------- 2 files changed, 8 insertions(+), 76 deletions(-) rename lib/types/{array.js => array/index.js} (84%) delete mode 100644 lib/types/arrayProxy.js diff --git a/lib/types/array.js b/lib/types/array/index.js similarity index 84% rename from lib/types/array.js rename to lib/types/array/index.js index 4bc3e095b6d..d71e516c4d6 100644 --- a/lib/types/array.js +++ b/lib/types/array/index.js @@ -4,14 +4,14 @@ 'use strict'; -const CoreMongooseArray = require('./core_array'); -const Document = require('../document'); - -const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; -const arrayAtomicsBackupSymbol = require('../helpers/symbols').arrayAtomicsBackupSymbol; -const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; -const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; -const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; +const CoreMongooseArray = require('../core_array'); +const Document = require('../../document'); + +const arrayAtomicsSymbol = require('../../helpers/symbols').arrayAtomicsSymbol; +const arrayAtomicsBackupSymbol = require('../../helpers/symbols').arrayAtomicsBackupSymbol; +const arrayParentSymbol = require('../../helpers/symbols').arrayParentSymbol; +const arrayPathSymbol = require('../../helpers/symbols').arrayPathSymbol; +const arraySchemaSymbol = require('../../helpers/symbols').arraySchemaSymbol; const _basePush = Array.prototype.push; diff --git a/lib/types/arrayProxy.js b/lib/types/arrayProxy.js deleted file mode 100644 index 8bfd21eb587..00000000000 --- a/lib/types/arrayProxy.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict'; - -const CoreMongooseArray = require('./core_array'); -const inspect = require('util').inspect.custom; - -module.exports = function arrayProxy(arr) { - if (inspect) { - arr[inspect] = CoreMongooseArray.prototype.inspect; - } - const proxy = new Proxy(arr, { - get: function(target, prop) { - if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy') { - return true; - } - if (prop === '__array') { - return arr; - } - if (prop === 'set') { - return __set; - } - if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { - return CoreMongooseArray.prototype[prop]; - } - - return arr[prop]; - }, - set: function(target, prop, value) { - if (typeof prop === 'string' && /^\d+$/.test(prop)) { - set(arr, prop, value); - } else { - arr[prop] = value; - } - - return true; - } - }); - - return proxy; -}; - -/*! - * Used as a method by array instances - */ -function __set(i, val, skipModified) { - const arr = this.__array; - if (skipModified) { - arr[i] = val; - return arr; - } - const value = CoreMongooseArray.prototype._cast.call(arr, val, i); - arr[i] = value; - CoreMongooseArray.prototype._markModified.call(arr, i); - return arr; -} - -/** - * Internal `set()` logic for proxies - */ -function set(arr, i, val, skipModified) { - if (skipModified) { - arr[i] = val; - return arr; - } - const value = CoreMongooseArray.prototype._cast.call(arr, val, i); - arr[i] = value; - CoreMongooseArray.prototype._markModified.call(arr, i); - return arr; -} \ No newline at end of file From 49c97357c7888d16917e1125a83c398dc1ae1a38 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 13 Feb 2021 14:31:54 -0500 Subject: [PATCH 16/22] refactor(types): remove documentArrayProxy(), make MongooseDocumentArray return a proxy re: #8884 --- lib/types/documentarray.js | 93 ++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 54 deletions(-) diff --git a/lib/types/documentarray.js b/lib/types/documentarray.js index 3df06b2a4b5..faf6bb98fa2 100644 --- a/lib/types/documentarray.js +++ b/lib/types/documentarray.js @@ -14,6 +14,7 @@ const util = require('util'); const utils = require('../utils'); const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; +const arrayAtomicsBackupSymbol = require('../helpers/symbols').arrayAtomicsBackupSymbol; const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; @@ -341,38 +342,57 @@ function _updateParentPopulated(arr) { } } -function documentArrayProxy(arr, values, path, doc) { - let arrayAtomics = {}; +/** + * DocumentArray constructor + * + * @param {Array} values + * @param {String} path the path to this array + * @param {Document} doc parent document + * @api private + * @return {MongooseDocumentArray} + * @inherits MongooseArray + * @see http://bit.ly/f6CnZU + */ + +function MongooseDocumentArray(values, path, doc) { + const arr = []; + + const internals = { + [arrayAtomicsSymbol]: {}, + [arrayAtomicsBackupSymbol]: void 0, + [arrayPathSymbol]: path, + [arraySchemaSymbol]: void 0, + [arrayParentSymbol]: void 0 + }; - arr[arraySchemaSymbol] = void 0; + internals[arraySchemaSymbol] = void 0; if (Array.isArray(values)) { if (values[arrayPathSymbol] === path && values[arrayParentSymbol] === doc) { - arrayAtomics = Object.assign({}, values[arrayAtomicsSymbol]); + internals[arrayAtomicsSymbol] = Object.assign({}, values[arrayAtomicsSymbol]); } values.forEach(v => { _basePush.call(arr, v); }); } - arr[arrayPathSymbol] = path; + internals[arrayPathSymbol] = path; // Because doc comes from the context of another function, doc === global // can happen if there was a null somewhere up the chain (see #3020 && #3034) // 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[arrayParentSymbol] = doc; - arr[arraySchemaSymbol] = doc.schema.path(path); + internals[arrayParentSymbol] = doc; + internals[arraySchemaSymbol] = doc.schema.path(path); // `schema.path()` doesn't drill into nested arrays properly yet, see // gh-6398, gh-6602. This is a workaround because nested arrays are // always plain non-document arrays, so once you get to a document array // nesting is done. Matryoshka code. - while (arr != null && - arr[arraySchemaSymbol] != null && - arr[arraySchemaSymbol].$isMongooseArray && - !arr[arraySchemaSymbol].$isMongooseDocumentArray) { - arr[arraySchemaSymbol] = arr[arraySchemaSymbol].casterConstructor; + while (internals[arraySchemaSymbol] != null && + internals[arraySchemaSymbol].$isMongooseArray && + !internals[arraySchemaSymbol].$isMongooseDocumentArray) { + internals[arraySchemaSymbol] = internals[arraySchemaSymbol].casterConstructor; } } @@ -388,10 +408,10 @@ function documentArrayProxy(arr, values, path, doc) { return arr; } if (prop === 'set') { - return __set; + return set; } - if (prop === arrayAtomicsSymbol) { - return arrayAtomics; + if (internals.hasOwnProperty(prop)) { + return internals[prop]; } if (CoreDocumentArray.prototype.hasOwnProperty(prop)) { return CoreDocumentArray.prototype[prop]; @@ -404,9 +424,9 @@ function documentArrayProxy(arr, values, path, doc) { }, set: function(target, prop, value) { if (typeof prop === 'string' && /^\d+$/.test(prop)) { - set(proxy, prop, value); - } else if (prop === arrayAtomicsSymbol) { - arrayAtomics = value; + set.call(proxy, prop, value); + } else if (internals.hasOwnProperty(prop)) { + internals[prop] = value; } else { arr[prop] = value; } @@ -418,10 +438,7 @@ function documentArrayProxy(arr, values, path, doc) { return proxy; } -/*! - * Used as a method by array instances - */ -function __set(i, val, skipModified) { +function set(i, val, skipModified) { const arr = this.__array; if (skipModified) { arr[i] = val; @@ -433,38 +450,6 @@ function __set(i, val, skipModified) { return arr; } -/** - * Internal `set()` logic for proxies - */ -function set(target, i, val, skipModified) { - if (skipModified) { - target.__array[i] = val; - return target; - } - const value = CoreDocumentArray.prototype._cast.call(target, val, i); - target.__array[i] = value; - CoreDocumentArray.prototype._markModified.call(target, i); - return target; -} - -/** - * DocumentArray constructor - * - * @param {Array} values - * @param {String} path the path to this array - * @param {Document} doc parent document - * @api private - * @return {MongooseDocumentArray} - * @inherits MongooseArray - * @see http://bit.ly/f6CnZU - */ - -function MongooseDocumentArray(values, path, doc) { - const arr = []; - - return documentArrayProxy(arr, values, path, doc); -} - /*! * Module exports. */ From 006ed2b3f8c97d31ddcdc8b4d3be658af65fc563 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 13 Feb 2021 14:52:14 -0500 Subject: [PATCH 17/22] refactor: start removing CoreMongooseArray class re: #8884 --- lib/document.js | 6 +- lib/schema/documentarray.js | 6 +- lib/schema/index.js | 2 +- .../index.js} | 28 +- .../{core_array.js => array/ArrayWrapper.js} | 36 +- lib/types/array/index.js | 10 +- lib/types/array/methods/index.js | 965 ++++++++++++++++++ lib/types/index.js | 2 +- test/colors.js | 2 +- test/types.document.test.js | 2 +- test/types.documentarray.test.js | 2 +- 11 files changed, 1013 insertions(+), 48 deletions(-) rename lib/types/{documentarray.js => DocumentArray/index.js} (92%) rename lib/types/{core_array.js => array/ArrayWrapper.js} (96%) create mode 100644 lib/types/array/methods/index.js diff --git a/lib/document.js b/lib/document.js index 669914a73c1..a5ed6968c1f 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2859,7 +2859,7 @@ Document.prototype.$isValid = function(path) { Document.prototype.$__reset = function reset() { let _this = this; - DocumentArray || (DocumentArray = require('./types/documentarray')); + DocumentArray || (DocumentArray = require('./types/DocumentArray')); this.$__.activePaths .map('init', 'modify', function(i) { @@ -3054,7 +3054,7 @@ Document.prototype.$__setSchema = function(schema) { */ Document.prototype.$__getArrayPathsToValidate = function() { - DocumentArray || (DocumentArray = require('./types/documentarray')); + DocumentArray || (DocumentArray = require('./types/DocumentArray')); // validate all document arrays. return this.$__.activePaths @@ -3082,7 +3082,7 @@ Document.prototype.$__getArrayPathsToValidate = function() { */ Document.prototype.$__getAllSubdocs = function() { - DocumentArray || (DocumentArray = require('./types/documentarray')); + DocumentArray || (DocumentArray = require('./types/DocumentArray')); Embedded = Embedded || require('./types/ArraySubdocument'); function docReducer(doc, seed, path) { diff --git a/lib/schema/documentarray.js b/lib/schema/documentarray.js index c645be714db..d13cf550fc9 100644 --- a/lib/schema/documentarray.js +++ b/lib/schema/documentarray.js @@ -192,7 +192,7 @@ DocumentArrayPath.prototype.discriminator = function(name, schema, tiedValue) { DocumentArrayPath.prototype.doValidate = function(array, fn, scope, options) { // lazy load - MongooseDocumentArray || (MongooseDocumentArray = require('../types/documentarray')); + MongooseDocumentArray || (MongooseDocumentArray = require('../types/DocumentArray')); const _this = this; try { @@ -316,7 +316,7 @@ DocumentArrayPath.prototype.getDefault = function(scope) { } // lazy load - MongooseDocumentArray || (MongooseDocumentArray = require('../types/documentarray')); + MongooseDocumentArray || (MongooseDocumentArray = require('../types/DocumentArray')); if (!Array.isArray(ret)) { ret = [ret]; @@ -352,7 +352,7 @@ DocumentArrayPath.prototype.getDefault = function(scope) { DocumentArrayPath.prototype.cast = function(value, doc, init, prev, options) { // lazy load - MongooseDocumentArray || (MongooseDocumentArray = require('../types/documentarray')); + MongooseDocumentArray || (MongooseDocumentArray = require('../types/DocumentArray')); // Skip casting if `value` is the same as the previous value, no need to cast. See gh-9266 if (value != null && value[arrayPathSymbol] != null && value === prev) { diff --git a/lib/schema/index.js b/lib/schema/index.js index f33b08468d3..3d02b49d93c 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -11,7 +11,7 @@ exports.Number = require('./number'); exports.Boolean = require('./boolean'); -exports.DocumentArray = require('./documentarray'); +exports.DocumentArray = require('./DocumentArray'); exports.Embedded = require('./SingleNestedPath'); diff --git a/lib/types/documentarray.js b/lib/types/DocumentArray/index.js similarity index 92% rename from lib/types/documentarray.js rename to lib/types/DocumentArray/index.js index faf6bb98fa2..6f2ad9a533c 100644 --- a/lib/types/documentarray.js +++ b/lib/types/DocumentArray/index.js @@ -4,21 +4,21 @@ * Module dependencies. */ -const CoreMongooseArray = require('./core_array'); -const Document = require('../document'); -const ObjectId = require('./objectid'); -const castObjectId = require('../cast/objectid'); -const getDiscriminatorByValue = require('../helpers/discriminator/getDiscriminatorByValue'); -const internalToObjectOptions = require('../options').internalToObjectOptions; +const CoreMongooseArray = require('../array/ArrayWrapper'); +const Document = require('../../document'); +const ObjectId = require('../objectid'); +const castObjectId = require('../../cast/objectid'); +const getDiscriminatorByValue = require('../../helpers/discriminator/getDiscriminatorByValue'); +const internalToObjectOptions = require('../../options').internalToObjectOptions; const util = require('util'); -const utils = require('../utils'); - -const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; -const arrayAtomicsBackupSymbol = require('../helpers/symbols').arrayAtomicsBackupSymbol; -const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; -const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; -const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; -const documentArrayParent = require('../helpers/symbols').documentArrayParent; +const utils = require('../../utils'); + +const arrayAtomicsSymbol = require('../../helpers/symbols').arrayAtomicsSymbol; +const arrayAtomicsBackupSymbol = require('../../helpers/symbols').arrayAtomicsBackupSymbol; +const arrayParentSymbol = require('../../helpers/symbols').arrayParentSymbol; +const arrayPathSymbol = require('../../helpers/symbols').arrayPathSymbol; +const arraySchemaSymbol = require('../../helpers/symbols').arraySchemaSymbol; +const documentArrayParent = require('../../helpers/symbols').documentArrayParent; const _basePush = Array.prototype.push; diff --git a/lib/types/core_array.js b/lib/types/array/ArrayWrapper.js similarity index 96% rename from lib/types/core_array.js rename to lib/types/array/ArrayWrapper.js index 0f2c1c104c0..0f7245f7714 100644 --- a/lib/types/core_array.js +++ b/lib/types/array/ArrayWrapper.js @@ -1,20 +1,20 @@ 'use strict'; -const Document = require('../document'); -const ArraySubdocument = require('./ArraySubdocument'); -const MongooseError = require('../error/mongooseError'); -const ObjectId = require('./objectid'); -const cleanModifiedSubpaths = require('../helpers/document/cleanModifiedSubpaths'); -const get = require('../helpers/get'); -const internalToObjectOptions = require('../options').internalToObjectOptions; -const utils = require('../utils'); +const Document = require('../../document'); +const ArraySubdocument = require('../ArraySubdocument'); +const MongooseError = require('../../error/mongooseError'); +const ObjectId = require('../objectid'); +const cleanModifiedSubpaths = require('../../helpers/document/cleanModifiedSubpaths'); +const get = require('../../helpers/get'); +const internalToObjectOptions = require('../../options').internalToObjectOptions; +const utils = require('../../utils'); const util = require('util'); -const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; -const arrayParentSymbol = require('../helpers/symbols').arrayParentSymbol; -const arrayPathSymbol = require('../helpers/symbols').arrayPathSymbol; -const arraySchemaSymbol = require('../helpers/symbols').arraySchemaSymbol; -const populateModelSymbol = require('../helpers/symbols').populateModelSymbol; +const arrayAtomicsSymbol = require('../../helpers/symbols').arrayAtomicsSymbol; +const arrayParentSymbol = require('../../helpers/symbols').arrayParentSymbol; +const arrayPathSymbol = require('../../helpers/symbols').arrayPathSymbol; +const arraySchemaSymbol = require('../../helpers/symbols').arraySchemaSymbol; +const populateModelSymbol = require('../../helpers/symbols').populateModelSymbol; const slicedSymbol = Symbol('mongoose#Array#sliced'); const _basePush = Array.prototype.push; @@ -25,7 +25,7 @@ const validatorsSymbol = Symbol('mongoose#MongooseCoreArray#validators'); * ignore */ -class CoreMongooseArray extends Array { +class ArrayWrapper extends Array { get isMongooseArray() { return true; } @@ -917,8 +917,8 @@ class CoreMongooseArray extends Array { } if (util.inspect.custom) { - CoreMongooseArray.prototype[util.inspect.custom] = - CoreMongooseArray.prototype.inspect; + ArrayWrapper.prototype[util.inspect.custom] = + ArrayWrapper.prototype.inspect; } /*! @@ -974,7 +974,7 @@ for (const method of returnVanillaArrayMethods) { continue; } - CoreMongooseArray.prototype[method] = function() { + ArrayWrapper.prototype[method] = function() { const _arr = this.isMongooseArrayProxy ? this.__array : this; const arr = [].concat(_arr); @@ -982,4 +982,4 @@ for (const method of returnVanillaArrayMethods) { }; } -module.exports = CoreMongooseArray; +module.exports = ArrayWrapper; diff --git a/lib/types/array/index.js b/lib/types/array/index.js index d71e516c4d6..cf3e38e4a45 100644 --- a/lib/types/array/index.js +++ b/lib/types/array/index.js @@ -4,8 +4,8 @@ 'use strict'; -const CoreMongooseArray = require('../core_array'); const Document = require('../../document'); +const mongooseArrayMethods = require('./methods'); const arrayAtomicsSymbol = require('../../helpers/symbols').arrayAtomicsSymbol; const arrayAtomicsBackupSymbol = require('../../helpers/symbols').arrayAtomicsBackupSymbol; @@ -78,8 +78,8 @@ function MongooseArray(values, path, doc, schematype) { if (internals.hasOwnProperty(prop)) { return internals[prop]; } - if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { - return CoreMongooseArray.prototype[prop]; + if (mongooseArrayMethods.hasOwnProperty(prop)) { + return mongooseArrayMethods[prop]; } return arr[prop]; @@ -109,9 +109,9 @@ function set(i, val, skipModified) { arr[i] = val; return arr; } - const value = CoreMongooseArray.prototype._cast.call(this, val, i); + const value = mongooseArrayMethods._cast.call(this, val, i); arr[i] = value; - CoreMongooseArray.prototype._markModified.call(this, i); + mongooseArrayMethods._markModified.call(this, i); return arr; } diff --git a/lib/types/array/methods/index.js b/lib/types/array/methods/index.js new file mode 100644 index 00000000000..02116ea0c89 --- /dev/null +++ b/lib/types/array/methods/index.js @@ -0,0 +1,965 @@ +'use strict'; + +const Document = require('../../../document'); +const ArraySubdocument = require('../../ArraySubdocument'); +const MongooseError = require('../../../error/mongooseError'); +const ObjectId = require('../../objectid'); +const cleanModifiedSubpaths = require('../../../helpers/document/cleanModifiedSubpaths'); +const get = require('../../../helpers/get'); +const internalToObjectOptions = require('../../../options').internalToObjectOptions; +const utils = require('../../../utils'); + +const arrayAtomicsSymbol = require('../../../helpers/symbols').arrayAtomicsSymbol; +const arrayParentSymbol = require('../../../helpers/symbols').arrayParentSymbol; +const arrayPathSymbol = require('../../../helpers/symbols').arrayPathSymbol; +const arraySchemaSymbol = require('../../../helpers/symbols').arraySchemaSymbol; +const populateModelSymbol = require('../../../helpers/symbols').populateModelSymbol; +const slicedSymbol = Symbol('mongoose#Array#sliced'); + +const _basePush = Array.prototype.push; + +/*! + * ignore + */ + +const methods = { + /** + * 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 + * @instance + * @api private + */ + + $__getAtomics() { + const ret = []; + const keys = Object.keys(this[arrayAtomicsSymbol] || {}); + let i = keys.length; + + const opts = Object.assign({}, internalToObjectOptions, { _isNested: true }); + + if (i === 0) { + ret[0] = ['$set', this.toObject(opts)]; + return ret; + } + + while (i--) { + const op = keys[i]; + let val = this[arrayAtomicsSymbol][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 (utils.isMongooseObject(val)) { + val = val.toObject(opts); + } else if (Array.isArray(val)) { + val = this.toObject.call(val, opts); + } else if (val != null && Array.isArray(val.$each)) { + val.$each = this.toObject.call(val.$each, opts); + } else if (val != null && typeof val.valueOf === 'function') { + val = val.valueOf(); + } + + if (op === '$addToSet') { + val = { $each: val }; + } + + ret.push([op, val]); + } + + return ret; + }, + + /*! + * ignore + */ + + $atomics() { + return this[arrayAtomicsSymbol]; + }, + + /*! + * ignore + */ + + $parent() { + return this[arrayParentSymbol]; + }, + + /*! + * ignore + */ + + $path() { + return this[arrayPathSymbol]; + }, + + /** + * Atomically shifts the array at most one time per document `save()`. + * + * ####NOTE: + * + * _Calling this multiple 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]; + * + * const 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 + * @instance + * @method $shift + * @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pop + */ + + $shift() { + this._registerAtomic('$pop', -1); + this._markModified(); + + // only allow shifting once + if (this._shifted) { + return; + } + this._shifted = true; + + return [].shift.call(this); + }, + + /** + * 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]; + * + * const 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 + * @instance + * @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pop + * @method $pop + * @memberOf MongooseArray + */ + + $pop() { + this._registerAtomic('$pop', 1); + this._markModified(); + + // only allow popping once + if (this._popped) { + return; + } + this._popped = true; + + return [].pop.call(this); + }, + + /*! + * ignore + */ + + $schema() { + return this[arraySchemaSymbol]; + }, + + /** + * Casts a member based on this arrays schema. + * + * @param {any} value + * @return value the casted value + * @method _cast + * @api private + * @memberOf MongooseArray + */ + + _cast(value) { + let populated = false; + let Model; + + if (this[arrayParentSymbol]) { + populated = this[arrayParentSymbol].populated(this[arrayPathSymbol], true); + } + + if (populated && value !== null && value !== undefined) { + // cast to the populated Models schema + Model = populated.options[populateModelSymbol]; + + // 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 + const isDisc = value.schema && value.schema.discriminatorMapping && + value.schema.discriminatorMapping.key !== undefined; + if (!isDisc) { + value = new Model(value); + } + return this[arraySchemaSymbol].caster.applySetters(value, this[arrayParentSymbol], true); + } + + return this[arraySchemaSymbol].caster.applySetters(value, this[arrayParentSymbol], false); + }, + + /** + * Internal helper for .map() + * + * @api private + * @return {Number} + * @method _mapCast + * @memberOf MongooseArray + */ + + _mapCast(val, index) { + return this._cast(val, this.length + index); + }, + + /** + * 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 {ArraySubdocument} subdoc the embedded doc that invoked this method on the Array + * @param {String} embeddedPath the path which changed in the subdoc + * @method _markModified + * @api private + * @memberOf MongooseArray + */ + + _markModified(elem, embeddedPath) { + const parent = this[arrayParentSymbol]; + let dirtyPath; + + if (parent) { + dirtyPath = this[arrayPathSymbol]; + + const index = elem != null && elem.__index >= 0 ? + elem.__index : + this.indexOf(elem); + + if (arguments.length) { + if (embeddedPath != null) { + // an embedded doc bubbled up the change + dirtyPath = dirtyPath + '.' + index + '.' + embeddedPath; + } else { + // directly set an index + dirtyPath = dirtyPath + '.' + elem; + } + } + + if (dirtyPath != null && dirtyPath.endsWith('.$')) { + return this; + } + + 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 + * @memberOf MongooseArray + */ + + _registerAtomic(op, val) { + if (this[slicedSymbol]) { + return; + } + if (op === '$set') { + // $set takes precedence over all other ops. + // mark entire array modified. + this[arrayAtomicsSymbol] = { $set: val }; + cleanModifiedSubpaths(this[arrayParentSymbol], this[arrayPathSymbol]); + this._markModified(); + return this; + } + + const atomics = this[arrayAtomicsSymbol]; + + // reset pop/shift after save + if (op === '$pop' && !('$pop' in atomics)) { + const _this = this; + this[arrayParentSymbol].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 (atomics.$set || Object.keys(atomics).length && !(op in atomics)) { + // a different op was previously registered. + // save the entire thing. + this[arrayAtomicsSymbol] = { $set: this }; + return this; + } + + let selector; + + if (op === '$pullAll' || op === '$addToSet') { + atomics[op] || (atomics[op] = []); + atomics[op] = atomics[op].concat(val); + } else if (op === '$pullDocs') { + const pullOp = atomics['$pull'] || (atomics['$pull'] = {}); + if (val[0] instanceof ArraySubdocument) { + 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 if (op === '$push') { + atomics.$push = atomics.$push || { $each: [] }; + if (val != null && utils.hasUserDefinedProperty(val, '$each')) { + atomics.$push = val; + } else { + atomics.$push.$each = atomics.$push.$each.concat(val); + } + } else { + atomics[op] = val; + } + + return this; + }, + + /** + * Adds values to the array if not already present. + * + * ####Example: + * + * console.log(doc.array) // [2,3,4] + * const 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 + * @memberOf MongooseArray + * @api public + * @method addToSet + */ + + addToSet() { + _checkManualPopulation(this, arguments); + + let values = [].map.call(arguments, this._mapCast, this); + values = this[arraySchemaSymbol].applySetters(values, this[arrayParentSymbol]); + const added = []; + let type = ''; + if (values[0] instanceof ArraySubdocument) { + type = 'doc'; + } else if (values[0] instanceof Date) { + type = 'date'; + } + + const rawValues = values.isMongooseArrayProxy ? values.__array : this; + const rawArray = this.isMongooseArrayProxy ? this.__array : this; + + rawValues.forEach(function(v) { + let found; + const val = +v; + switch (type) { + case 'doc': + found = this.some(function(doc) { + return doc.equals(v); + }); + break; + case 'date': + found = this.some(function(d) { + return +d === val; + }); + break; + default: + found = ~this.indexOf(v); + } + + if (!found) { + rawArray.push(v); + this._registerAtomic('$addToSet', v); + this._markModified(); + [].push.call(added, v); + } + }, this); + + return added; + }, + + /** + * Returns the number of pending atomic operations to send to the db for this array. + * + * @api private + * @return {Number} + * @method hasAtomics + * @memberOf MongooseArray + */ + + hasAtomics() { + if (!utils.isPOJO(this[arrayAtomicsSymbol])) { + return 0; + } + + return Object.keys(this[arrayAtomicsSymbol]).length; + }, + + /** + * Return whether or not the `obj` is included in the array. + * + * @param {Object} obj the item to check + * @return {Boolean} + * @api public + * @method includes + * @memberOf MongooseArray + */ + + includes(obj, fromIndex) { + const ret = this.indexOf(obj, fromIndex); + return ret !== -1; + }, + + /** + * Return the index of `obj` or `-1` if not found. + * + * @param {Object} obj the item to look for + * @return {Number} + * @api public + * @method indexOf + * @memberOf MongooseArray + */ + + indexOf(obj, fromIndex) { + if (obj instanceof ObjectId) { + obj = obj.toString(); + } + + fromIndex = fromIndex == null ? 0 : fromIndex; + const len = this.length; + for (let i = fromIndex; i < len; ++i) { + if (obj == this[i]) { + return i; + } + } + return -1; + }, + + /** + * Helper for console.log + * + * @api public + * @method inspect + * @memberOf MongooseArray + */ + + inspect() { + return JSON.stringify(this); + }, + + /** + * 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 + * @memberOf MongooseArray + */ + + nonAtomicPush() { + const values = [].map.call(arguments, this._mapCast, this); + const ret = [].push.apply(this, values); + this._registerAtomic('$set', this); + this._markModified(); + return ret; + }, + + /** + * 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 + * @memberOf MongooseArray + */ + + pop() { + const ret = [].pop.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 + * @memberOf MongooseArray + */ + + pull() { + const values = [].map.call(arguments, this._cast, this); + const cur = this[arrayParentSymbol].get(this[arrayPathSymbol]); + let i = cur.length; + let mem; + + while (i--) { + mem = cur[i]; + if (mem instanceof Document) { + const 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 ArraySubdocument) { + this._registerAtomic('$pullDocs', values.map(function(v) { + return v.$__getValue('_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[arrayParentSymbol], this[arrayPathSymbol]) > 0) { + this._registerAtomic('$set', this); + } + + return this; + }, + + /** + * Wraps [`Array#push`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/push) with proper change tracking. + * + * ####Example: + * + * const schema = Schema({ nums: [Number] }); + * const Model = mongoose.model('Test', schema); + * + * const doc = await Model.create({ nums: [3, 4] }); + * doc.nums.push(5); // Add 5 to the end of the array + * await doc.save(); + * + * // You can also pass an object with `$each` as the + * // first parameter to use MongoDB's `$position` + * doc.nums.push({ + * $each: [1, 2], + * $position: 0 + * }); + * doc.nums; // [1, 2, 3, 4, 5] + * + * @param {Object} [args...] + * @api public + * @method push + * @memberOf MongooseArray + */ + + push() { + let values = arguments; + let atomic = values; + const isOverwrite = values[0] != null && + utils.hasUserDefinedProperty(values[0], '$each'); + const arr = this.isMongooseArrayProxy ? this.__array : this; + if (isOverwrite) { + atomic = values[0]; + values = values[0].$each; + } + + if (this[arraySchemaSymbol] == null) { + return _basePush.apply(this, values); + } + + _checkManualPopulation(this, values); + + const parent = this[arrayParentSymbol]; + values = [].map.call(values, this._mapCast, this); + values = this[arraySchemaSymbol].applySetters(values, parent, undefined, + undefined, { skipDocumentArrayCast: true }); + let ret; + const atomics = this[arrayAtomicsSymbol]; + + if (isOverwrite) { + atomic.$each = values; + + if (get(atomics, '$push.$each.length', 0) > 0 && + atomics.$push.$position != atomic.$position) { + throw new MongooseError('Cannot call `Array#push()` multiple times ' + + 'with different `$position`'); + } + + if (atomic.$position != null) { + [].splice.apply(arr, [atomic.$position, 0].concat(values)); + ret = this.length; + } else { + ret = [].push.apply(arr, values); + } + } else { + if (get(atomics, '$push.$each.length', 0) > 0 && + atomics.$push.$position != null) { + throw new MongooseError('Cannot call `Array#push()` multiple times ' + + 'with different `$position`'); + } + atomic = values; + ret = [].push.apply(arr, values); + } + + this._registerAtomic('$push', atomic); + this._markModified(); + return ret; + }, + + /** + * Alias of [pull](#mongoosearray_MongooseArray-pull) + * + * @see MongooseArray#pull #types_array_MongooseArray-pull + * @see mongodb http://www.mongodb.org/display/DOCS/Updating/#Updating-%24pull + * @api public + * @memberOf MongooseArray + * @instance + * @method remove + */ + + remove() { + return this.pull.apply(this, arguments); + }, + + /** + * Sets the casted `val` at index `i` and marks the array modified. + * + * ####Example: + * + * // given documents based on the following + * const Doc = mongoose.model('Doc', new Schema({ array: [Number] })); + * + * const 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 + * @memberOf MongooseArray + */ + + set(i, val, skipModified) { + if (skipModified) { + this[i] = val; + return this; + } + const value = this._cast(val, i); + this[i] = value; + this._markModified(i); + return this; + }, + + /** + * Wraps [`Array#shift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking. + * + * ####Example: + * + * doc.array = [2,3]; + * const 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 + * @memberOf MongooseArray + */ + + shift() { + const arr = this.isMongooseArrayProxy ? this.__array : this; + const ret = [].shift.call(arr); + this._registerAtomic('$set', this); + this._markModified(); + return ret; + }, + + /** + * 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 + * @memberOf MongooseArray + * @see https://masteringjs.io/tutorials/fundamentals/array-sort + */ + + sort() { + const arr = this.isMongooseArrayProxy ? this.__array : this; + const ret = [].sort.apply(arr, arguments); + this._registerAtomic('$set', this); + return ret; + }, + + /** + * 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 + * @memberOf MongooseArray + * @see https://masteringjs.io/tutorials/fundamentals/array-splice + */ + + splice() { + let ret; + const arr = this.isMongooseArrayProxy ? this.__array : this; + + _checkManualPopulation(this, Array.prototype.slice.call(arguments, 2)); + + if (arguments.length) { + let vals; + if (this[arraySchemaSymbol] == null) { + vals = arguments; + } else { + vals = []; + for (let i = 0; i < arguments.length; ++i) { + vals[i] = i < 2 ? + arguments[i] : + this._cast(arguments[i], arguments[0] + (i - 2)); + } + } + + ret = [].splice.apply(arr, vals); + this._registerAtomic('$set', this); + } + + return ret; + }, + + /*! + * ignore + */ + + toBSON() { + return this.toObject(internalToObjectOptions); + }, + + /** + * Returns a native js Array. + * + * @param {Object} options + * @return {Array} + * @api public + * @method toObject + * @memberOf MongooseArray + */ + + toObject(options) { + const arr = this.isMongooseArrayProxy ? this.__array : this; + if (options && options.depopulate) { + options = utils.clone(options); + options._isNested = true; + // Ensure return value is a vanilla array, because in Node.js 6+ `map()` + // is smart enough to use the inherited array's constructor. + return [].concat(arr).map(function(doc) { + return doc instanceof Document + ? doc.toObject(options) + : doc; + }); + } + + return [].concat(arr); + }, + + /** + * 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 overwriting any changes that happen between when you retrieved the object and when you save it._ + * + * @api public + * @method unshift + * @memberOf MongooseArray + */ + + unshift() { + _checkManualPopulation(this, arguments); + + let values; + if (this[arraySchemaSymbol] == null) { + values = arguments; + } else { + values = [].map.call(arguments, this._cast, this); + values = this[arraySchemaSymbol].applySetters(values, this[arrayParentSymbol]); + } + + const arr = this.isMongooseArrayProxy ? this.__array : this; + [].unshift.apply(arr, values); + this._registerAtomic('$set', this); + this._markModified(); + return this.length; + } +}; + +/*! + * ignore + */ + +function _isAllSubdocs(docs, ref) { + if (!ref) { + return false; + } + + for (const arg of docs) { + if (arg == null) { + return false; + } + const model = arg.constructor; + if (!(arg instanceof Document) || + (model.modelName !== ref && model.baseModelName !== ref)) { + return false; + } + } + + return true; +} + +/*! + * ignore + */ + +function _checkManualPopulation(arr, docs) { + const ref = arr == null ? + null : + get(arr[arraySchemaSymbol], 'caster.options.ref', null); + if (arr.length === 0 && + docs.length > 0) { + if (_isAllSubdocs(docs, ref)) { + arr[arrayParentSymbol].populated(arr[arrayPathSymbol], [], { + [populateModelSymbol]: docs[0].constructor + }); + } + } +} + +const returnVanillaArrayMethods = [ + 'filter', + 'flat', + 'flatMap', + 'map', + 'slice' +]; +for (const method of returnVanillaArrayMethods) { + if (Array.prototype[method] == null) { + continue; + } + + methods[method] = function() { + const _arr = this.isMongooseArrayProxy ? this.__array : this; + const arr = [].concat(_arr); + + return arr[method].apply(arr, arguments); + }; +} + +module.exports = methods; diff --git a/lib/types/index.js b/lib/types/index.js index 951d98bcac0..fbfb89a5582 100644 --- a/lib/types/index.js +++ b/lib/types/index.js @@ -11,7 +11,7 @@ exports.Buffer = require('./buffer'); exports.Document = // @deprecate exports.Embedded = require('./ArraySubdocument'); -exports.DocumentArray = require('./documentarray'); +exports.DocumentArray = require('./DocumentArray'); exports.Decimal128 = require('./decimal128'); exports.ObjectId = require('./objectid'); diff --git a/test/colors.js b/test/colors.js index b560a871c8f..e2971b0437a 100644 --- a/test/colors.js +++ b/test/colors.js @@ -6,7 +6,7 @@ const start = require('./common'); -const DocumentArray = require('../lib/types/documentarray'); +const DocumentArray = require('../lib/types/DocumentArray'); const ArraySubdocument = require('../lib/types/ArraySubdocument'); const assert = require('assert'); diff --git a/test/types.document.test.js b/test/types.document.test.js index 002060ff9e3..5a721a12ae3 100644 --- a/test/types.document.test.js +++ b/test/types.document.test.js @@ -11,7 +11,7 @@ const assert = require('assert'); const mongoose = start.mongoose; const ArraySubdocument = require('../lib/types/ArraySubdocument'); const EventEmitter = require('events').EventEmitter; -const DocumentArray = require('../lib/types/documentarray'); +const DocumentArray = require('../lib/types/DocumentArray'); const Schema = mongoose.Schema; const ValidationError = mongoose.Document.ValidationError; diff --git a/test/types.documentarray.test.js b/test/types.documentarray.test.js index 2e4da3796ec..1280eb47484 100644 --- a/test/types.documentarray.test.js +++ b/test/types.documentarray.test.js @@ -6,7 +6,7 @@ const start = require('./common'); -const DocumentArray = require('../lib/types/documentarray'); +const DocumentArray = require('../lib/types/DocumentArray'); const ArraySubdocument = require('../lib/types/ArraySubdocument'); const assert = require('assert'); const co = require('co'); From 1570da60712536bc55e2706f88e7a08f27ab6060 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 15 Feb 2021 11:10:09 -0500 Subject: [PATCH 18/22] refactor: move DocumentArray methods to separate directory re: #8884 --- lib/schema/index.js | 2 +- lib/types/DocumentArray/index.js | 342 +---------------------- lib/types/DocumentArray/methods/index.js | 329 ++++++++++++++++++++++ 3 files changed, 338 insertions(+), 335 deletions(-) create mode 100644 lib/types/DocumentArray/methods/index.js diff --git a/lib/schema/index.js b/lib/schema/index.js index 3d02b49d93c..f33b08468d3 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -11,7 +11,7 @@ exports.Number = require('./number'); exports.Boolean = require('./boolean'); -exports.DocumentArray = require('./DocumentArray'); +exports.DocumentArray = require('./documentarray'); exports.Embedded = require('./SingleNestedPath'); diff --git a/lib/types/DocumentArray/index.js b/lib/types/DocumentArray/index.js index 6f2ad9a533c..92eff902e7e 100644 --- a/lib/types/DocumentArray/index.js +++ b/lib/types/DocumentArray/index.js @@ -4,344 +4,18 @@ * Module dependencies. */ -const CoreMongooseArray = require('../array/ArrayWrapper'); +const ArrayMethods = require('../array/methods'); +const DocumentArrayMethods = require('./methods'); const Document = require('../../document'); -const ObjectId = require('../objectid'); -const castObjectId = require('../../cast/objectid'); -const getDiscriminatorByValue = require('../../helpers/discriminator/getDiscriminatorByValue'); -const internalToObjectOptions = require('../../options').internalToObjectOptions; -const util = require('util'); -const utils = require('../../utils'); const arrayAtomicsSymbol = require('../../helpers/symbols').arrayAtomicsSymbol; const arrayAtomicsBackupSymbol = require('../../helpers/symbols').arrayAtomicsBackupSymbol; const arrayParentSymbol = require('../../helpers/symbols').arrayParentSymbol; const arrayPathSymbol = require('../../helpers/symbols').arrayPathSymbol; const arraySchemaSymbol = require('../../helpers/symbols').arraySchemaSymbol; -const documentArrayParent = require('../../helpers/symbols').documentArrayParent; const _basePush = Array.prototype.push; -class CoreDocumentArray extends CoreMongooseArray { - get isMongooseDocumentArray() { - return true; - } - - /*! - * ignore - */ - - toBSON() { - return this.toObject(internalToObjectOptions); - } - - /** - * Overrides MongooseArray#cast - * - * @method _cast - * @api private - * @receiver MongooseDocumentArray - */ - - _cast(value, index) { - if (this[arraySchemaSymbol] == null) { - return value; - } - let Constructor = this[arraySchemaSymbol].casterConstructor; - const isInstance = Constructor.$isMongooseDocumentArray ? - value && value.isMongooseDocumentArray : - value instanceof Constructor; - if (isInstance || - // Hack re: #5001, see #5005 - (value && value.constructor && value.constructor.baseCasterConstructor === Constructor)) { - if (!(value[documentArrayParent] && value.__parentArray)) { - // value may have been created using array.create() - value[documentArrayParent] = this[arrayParentSymbol]; - value.__parentArray = this; - } - value.$setIndex(index); - return value; - } - - if (value === undefined || value === null) { - return null; - } - - // handle cast('string') or cast(ObjectId) etc. - // 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 }; - } - - if (value && - Constructor.discriminators && - Constructor.schema && - Constructor.schema.options && - Constructor.schema.options.discriminatorKey) { - if (typeof value[Constructor.schema.options.discriminatorKey] === 'string' && - Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]) { - Constructor = Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]; - } else { - const constructorByValue = getDiscriminatorByValue(Constructor, value[Constructor.schema.options.discriminatorKey]); - if (constructorByValue) { - Constructor = constructorByValue; - } - } - } - - if (Constructor.$isMongooseDocumentArray) { - return Constructor.cast(value, this, undefined, undefined, index); - } - const ret = new Constructor(value, this, undefined, undefined, index); - ret.isNew = true; - return ret; - } - - /** - * Searches array items for the first document with a matching _id. - * - * ####Example: - * - * const embeddedDoc = m.array.id(some_id); - * - * @return {EmbeddedDocument|null} the subdocument or null if not found. - * @param {ObjectId|String|Number|Buffer} id - * @TODO cast to the _id based on schema for proper comparison - * @method id - * @api public - * @receiver MongooseDocumentArray - */ - - id(id) { - let casted; - let sid; - let _id; - - try { - casted = castObjectId(id).toString(); - } catch (e) { - casted = null; - } - - for (const val of this) { - if (!val) { - continue; - } - - _id = val.get('_id'); - - if (_id === null || typeof _id === 'undefined') { - continue; - } else if (_id instanceof Document) { - sid || (sid = String(id)); - if (sid == _id._id) { - return val; - } - } else if (!(id instanceof ObjectId) && !(_id instanceof ObjectId)) { - if (id == _id || utils.deepEqual(id, _id)) { - return val; - } - } else if (casted == _id) { - return val; - } - } - - return null; - } - - /** - * Returns a native js Array of plain js objects - * - * ####NOTE: - * - * _Each sub-document is converted to a plain object by calling its `#toObject` method._ - * - * @param {Object} [options] optional options to pass to each documents `toObject` method call during conversion - * @return {Array} - * @method toObject - * @api public - * @receiver MongooseDocumentArray - */ - - toObject(options) { - // `[].concat` coerces the return value into a vanilla JS array, rather - // than a Mongoose array. - return [].concat(this.map(function(doc) { - if (doc == null) { - return null; - } - if (typeof doc.toObject !== 'function') { - return doc; - } - return doc.toObject(options); - })); - } - - /** - * 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 - * @memberOf MongooseDocumentArray - */ - - push() { - const ret = super.push.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Pulls items from the array atomically. - * - * @param {Object} [args...] - * @api public - * @method pull - * @memberOf MongooseDocumentArray - */ - - pull() { - const ret = super.pull.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Wraps [`Array#shift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking. - */ - - shift() { - const ret = super.shift.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Wraps [`Array#splice`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/splice) with proper change tracking and casting. - */ - - splice() { - const ret = super.splice.apply(this, arguments); - - _updateParentPopulated(this); - - return ret; - } - - /** - * Helper for console.log - * - * @method inspect - * @api public - * @receiver MongooseDocumentArray - */ - - inspect() { - return this.toObject(); - } - - /** - * Creates a subdocument casted to this schema. - * - * This is the same subdocument constructor used for casting. - * - * @param {Object} obj the value to cast to this arrays SubDocument schema - * @method create - * @api public - * @receiver MongooseDocumentArray - */ - - create(obj) { - let Constructor = this[arraySchemaSymbol].casterConstructor; - if (obj && - Constructor.discriminators && - Constructor.schema && - Constructor.schema.options && - Constructor.schema.options.discriminatorKey) { - if (typeof obj[Constructor.schema.options.discriminatorKey] === 'string' && - Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]) { - Constructor = Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]; - } else { - const constructorByValue = getDiscriminatorByValue(Constructor, obj[Constructor.schema.options.discriminatorKey]); - if (constructorByValue) { - Constructor = constructorByValue; - } - } - } - - return new Constructor(obj, this); - } - - /*! - * ignore - */ - - notify(event) { - const _this = this; - return function notify(val, _arr) { - _arr = _arr || _this; - let i = _arr.length; - while (i--) { - if (_arr[i] == null) { - continue; - } - switch (event) { - // only swap for save event for now, we may change this to all event types later - case 'save': - val = _this[i]; - break; - default: - // NO-OP - break; - } - - if (_arr[i].isMongooseArray) { - notify(val, _arr[i]); - } else if (_arr[i]) { - _arr[i].emit(event, val); - } - } - }; - } -} - -if (util.inspect.custom) { - CoreDocumentArray.prototype[util.inspect.custom] = - CoreDocumentArray.prototype.inspect; -} - -/*! - * If this is a document array, each element may contain single - * populated paths, so we need to modify the top-level document's - * populated cache. See gh-8247, gh-8265. - */ - -function _updateParentPopulated(arr) { - const parent = arr[arrayParentSymbol]; - if (!parent || parent.$__.populated == null) return; - - const populatedPaths = Object.keys(parent.$__.populated). - filter(p => p.startsWith(arr[arrayPathSymbol] + '.')); - - for (const path of populatedPaths) { - const remnant = path.slice((arr[arrayPathSymbol] + '.').length); - if (!Array.isArray(parent.$__.populated[path].value)) { - continue; - } - - parent.$__.populated[path].value = arr.map(val => val.populated(remnant)); - } -} - /** * DocumentArray constructor * @@ -413,11 +87,11 @@ function MongooseDocumentArray(values, path, doc) { if (internals.hasOwnProperty(prop)) { return internals[prop]; } - if (CoreDocumentArray.prototype.hasOwnProperty(prop)) { - return CoreDocumentArray.prototype[prop]; + if (DocumentArrayMethods.hasOwnProperty(prop)) { + return DocumentArrayMethods[prop]; } - if (CoreMongooseArray.prototype.hasOwnProperty(prop)) { - return CoreMongooseArray.prototype[prop]; + if (ArrayMethods.hasOwnProperty(prop)) { + return ArrayMethods[prop]; } return arr[prop]; @@ -444,9 +118,9 @@ function set(i, val, skipModified) { arr[i] = val; return arr; } - const value = CoreDocumentArray.prototype._cast.call(this, val, i); + const value = DocumentArrayMethods._cast.call(this, val, i); arr[i] = value; - CoreDocumentArray.prototype._markModified.call(this, i); + DocumentArrayMethods._markModified.call(this, i); return arr; } diff --git a/lib/types/DocumentArray/methods/index.js b/lib/types/DocumentArray/methods/index.js new file mode 100644 index 00000000000..10dcdf0f38a --- /dev/null +++ b/lib/types/DocumentArray/methods/index.js @@ -0,0 +1,329 @@ +'use strict'; + +const ArrayMethods = require('../../array/methods'); +const Document = require('../../../document'); +const ObjectId = require('../../objectid'); +const castObjectId = require('../../../cast/objectid'); +const getDiscriminatorByValue = require('../../../helpers/discriminator/getDiscriminatorByValue'); +const internalToObjectOptions = require('../../../options').internalToObjectOptions; +const utils = require('../../../utils'); + +const arrayParentSymbol = require('../../../helpers/symbols').arrayParentSymbol; +const arrayPathSymbol = require('../../../helpers/symbols').arrayPathSymbol; +const arraySchemaSymbol = require('../../../helpers/symbols').arraySchemaSymbol; +const documentArrayParent = require('../../../helpers/symbols').documentArrayParent; + +const methods = { + /*! + * ignore + */ + + toBSON() { + return this.toObject(internalToObjectOptions); + }, + + /** + * Overrides MongooseArray#cast + * + * @method _cast + * @api private + * @receiver MongooseDocumentArray + */ + + _cast(value, index) { + if (this[arraySchemaSymbol] == null) { + return value; + } + let Constructor = this[arraySchemaSymbol].casterConstructor; + const isInstance = Constructor.$isMongooseDocumentArray ? + value && value.isMongooseDocumentArray : + value instanceof Constructor; + if (isInstance || + // Hack re: #5001, see #5005 + (value && value.constructor && value.constructor.baseCasterConstructor === Constructor)) { + if (!(value[documentArrayParent] && value.__parentArray)) { + // value may have been created using array.create() + value[documentArrayParent] = this[arrayParentSymbol]; + value.__parentArray = this; + } + value.$setIndex(index); + return value; + } + + if (value === undefined || value === null) { + return null; + } + + // handle cast('string') or cast(ObjectId) etc. + // 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 }; + } + + if (value && + Constructor.discriminators && + Constructor.schema && + Constructor.schema.options && + Constructor.schema.options.discriminatorKey) { + if (typeof value[Constructor.schema.options.discriminatorKey] === 'string' && + Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]) { + Constructor = Constructor.discriminators[value[Constructor.schema.options.discriminatorKey]]; + } else { + const constructorByValue = getDiscriminatorByValue(Constructor, value[Constructor.schema.options.discriminatorKey]); + if (constructorByValue) { + Constructor = constructorByValue; + } + } + } + + if (Constructor.$isMongooseDocumentArray) { + return Constructor.cast(value, this, undefined, undefined, index); + } + const ret = new Constructor(value, this, undefined, undefined, index); + ret.isNew = true; + return ret; + }, + + /** + * Searches array items for the first document with a matching _id. + * + * ####Example: + * + * const embeddedDoc = m.array.id(some_id); + * + * @return {EmbeddedDocument|null} the subdocument or null if not found. + * @param {ObjectId|String|Number|Buffer} id + * @TODO cast to the _id based on schema for proper comparison + * @method id + * @api public + * @receiver MongooseDocumentArray + */ + + id(id) { + let casted; + let sid; + let _id; + + try { + casted = castObjectId(id).toString(); + } catch (e) { + casted = null; + } + + for (const val of this) { + if (!val) { + continue; + } + + _id = val.get('_id'); + + if (_id === null || typeof _id === 'undefined') { + continue; + } else if (_id instanceof Document) { + sid || (sid = String(id)); + if (sid == _id._id) { + return val; + } + } else if (!(id instanceof ObjectId) && !(_id instanceof ObjectId)) { + if (id == _id || utils.deepEqual(id, _id)) { + return val; + } + } else if (casted == _id) { + return val; + } + } + + return null; + }, + + /** + * Returns a native js Array of plain js objects + * + * ####NOTE: + * + * _Each sub-document is converted to a plain object by calling its `#toObject` method._ + * + * @param {Object} [options] optional options to pass to each documents `toObject` method call during conversion + * @return {Array} + * @method toObject + * @api public + * @receiver MongooseDocumentArray + */ + + toObject(options) { + // `[].concat` coerces the return value into a vanilla JS array, rather + // than a Mongoose array. + return [].concat(this.map(function(doc) { + if (doc == null) { + return null; + } + if (typeof doc.toObject !== 'function') { + return doc; + } + return doc.toObject(options); + })); + }, + + /** + * 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 + * @memberOf MongooseDocumentArray + */ + + push() { + const ret = ArrayMethods.push.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + }, + + /** + * Pulls items from the array atomically. + * + * @param {Object} [args...] + * @api public + * @method pull + * @memberOf MongooseDocumentArray + */ + + pull() { + const ret = ArrayMethods.pull.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + }, + + /** + * Wraps [`Array#shift`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/unshift) with proper change tracking. + */ + + shift() { + const ret = ArrayMethods.shift.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + }, + + /** + * Wraps [`Array#splice`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/splice) with proper change tracking and casting. + */ + + splice() { + const ret = ArrayMethods.splice.apply(this, arguments); + + _updateParentPopulated(this); + + return ret; + }, + + /** + * Helper for console.log + * + * @method inspect + * @api public + * @receiver MongooseDocumentArray + */ + + inspect() { + return this.toObject(); + }, + + /** + * Creates a subdocument casted to this schema. + * + * This is the same subdocument constructor used for casting. + * + * @param {Object} obj the value to cast to this arrays SubDocument schema + * @method create + * @api public + * @receiver MongooseDocumentArray + */ + + create(obj) { + let Constructor = this[arraySchemaSymbol].casterConstructor; + if (obj && + Constructor.discriminators && + Constructor.schema && + Constructor.schema.options && + Constructor.schema.options.discriminatorKey) { + if (typeof obj[Constructor.schema.options.discriminatorKey] === 'string' && + Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]) { + Constructor = Constructor.discriminators[obj[Constructor.schema.options.discriminatorKey]]; + } else { + const constructorByValue = getDiscriminatorByValue(Constructor, obj[Constructor.schema.options.discriminatorKey]); + if (constructorByValue) { + Constructor = constructorByValue; + } + } + } + + return new Constructor(obj, this); + }, + + /*! + * ignore + */ + + notify(event) { + const _this = this; + return function notify(val, _arr) { + _arr = _arr || _this; + let i = _arr.length; + while (i--) { + if (_arr[i] == null) { + continue; + } + switch (event) { + // only swap for save event for now, we may change this to all event types later + case 'save': + val = _this[i]; + break; + default: + // NO-OP + break; + } + + if (_arr[i].isMongooseArray) { + notify(val, _arr[i]); + } else if (_arr[i]) { + _arr[i].emit(event, val); + } + } + }; + }, + + _markModified: ArrayMethods._markModified +}; + +module.exports = methods; + +/*! + * If this is a document array, each element may contain single + * populated paths, so we need to modify the top-level document's + * populated cache. See gh-8247, gh-8265. + */ + +function _updateParentPopulated(arr) { + const parent = arr[arrayParentSymbol]; + if (!parent || parent.$__.populated == null) return; + + const populatedPaths = Object.keys(parent.$__.populated). + filter(p => p.startsWith(arr[arrayPathSymbol] + '.')); + + for (const path of populatedPaths) { + const remnant = path.slice((arr[arrayPathSymbol] + '.').length); + if (!Array.isArray(parent.$__.populated[path].value)) { + continue; + } + + parent.$__.populated[path].value = arr.map(val => val.populated(remnant)); + } +} \ No newline at end of file From 6a4a63805bbaf4f16f2808442a72b60b70b860a7 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 15 Feb 2021 12:59:00 -0500 Subject: [PATCH 19/22] perf: shave 15% off of array proxy performance re: #8884 --- lib/schema/array.js | 2 +- lib/types/DocumentArray/index.js | 1 - lib/types/array/index.js | 58 +++++++++----------------------- lib/types/array/methods/index.js | 9 ++--- 4 files changed, 21 insertions(+), 49 deletions(-) diff --git a/lib/schema/array.js b/lib/schema/array.js index 6e5c1b00363..fc3ab2a3057 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -358,7 +358,7 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { const caster = this.caster; if (caster && this.casterConstructor !== Mixed) { try { - const len = value.length; + const len = rawValue.length; for (i = 0; i < len; i++) { const opts = {}; // Perf: creating `arrayPath` is expensive for large arrays. diff --git a/lib/types/DocumentArray/index.js b/lib/types/DocumentArray/index.js index 92eff902e7e..6f428ee7d95 100644 --- a/lib/types/DocumentArray/index.js +++ b/lib/types/DocumentArray/index.js @@ -39,7 +39,6 @@ function MongooseDocumentArray(values, path, doc) { [arrayParentSymbol]: void 0 }; - internals[arraySchemaSymbol] = void 0; if (Array.isArray(values)) { if (values[arrayPathSymbol] === path && values[arrayParentSymbol] === doc) { diff --git a/lib/types/array/index.js b/lib/types/array/index.js index cf3e38e4a45..71d71afc9d4 100644 --- a/lib/types/array/index.js +++ b/lib/types/array/index.js @@ -13,8 +13,6 @@ const arrayParentSymbol = require('../../helpers/symbols').arrayParentSymbol; const arrayPathSymbol = require('../../helpers/symbols').arrayPathSymbol; const arraySchemaSymbol = require('../../helpers/symbols').arraySchemaSymbol; -const _basePush = Array.prototype.push; - /** * Mongoose Array constructor. * @@ -30,51 +28,38 @@ const _basePush = Array.prototype.push; * @see http://bit.ly/f6CnZU */ -function MongooseArray(values, path, doc, schematype) { - const arr = []; +function MongooseArray(values, path, doc, schematype, both) { + const valuesIsArray = Array.isArray(values); + const arr = valuesIsArray ? [...values] : []; const internals = { [arrayAtomicsSymbol]: {}, [arrayAtomicsBackupSymbol]: void 0, [arrayPathSymbol]: path, [arraySchemaSymbol]: void 0, - [arrayParentSymbol]: void 0 + [arrayParentSymbol]: void 0, + isMongooseArray: true, + isMongooseArrayProxy: true, + __array: arr }; - if (Array.isArray(values)) { - const len = values.length; - for (let i = 0; i < len; ++i) { - _basePush.call(arr, values[i]); - } - + if (valuesIsArray) { if (values[arrayAtomicsSymbol] != null) { internals[arrayAtomicsSymbol] = values[arrayAtomicsSymbol]; } } - internals[arrayPathSymbol] = path; - internals[arraySchemaSymbol] = 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) { + if (doc != null && doc instanceof Document) { internals[arrayParentSymbol] = doc; internals[arraySchemaSymbol] = schematype || doc.schema.path(path); } const proxy = new Proxy(arr, { get: function(target, prop) { - if (prop === 'isMongooseArray' || prop === 'isMongooseArrayProxy') { - return true; - } - if (prop === '__array') { - return arr; - } - if (prop === 'set') { - return set; - } if (internals.hasOwnProperty(prop)) { return internals[prop]; } @@ -84,13 +69,15 @@ function MongooseArray(values, path, doc, schematype) { return arr[prop]; }, - set: function(target, prop, value) { + set: function(target, prop, val) { if (typeof prop === 'string' && /^\d+$/.test(prop)) { - set.call(proxy, prop, value); + const value = mongooseArrayMethods._cast.call(proxy, val, prop); + arr[prop] = value; + mongooseArrayMethods._markModified.call(proxy, prop); } else if (internals.hasOwnProperty(prop)) { - internals[prop] = value; + internals[prop] = val; } else { - arr[prop] = value; + arr[prop] = val; } return true; @@ -100,21 +87,6 @@ function MongooseArray(values, path, doc, schematype) { return proxy; } -/*! - * Used as a method by array instances - */ -function set(i, val, skipModified) { - const arr = this.__array; - if (skipModified) { - arr[i] = val; - return arr; - } - const value = mongooseArrayMethods._cast.call(this, val, i); - arr[i] = value; - mongooseArrayMethods._markModified.call(this, i); - return arr; -} - /*! * Module exports. */ diff --git a/lib/types/array/methods/index.js b/lib/types/array/methods/index.js index 02116ea0c89..c885ddb5032 100644 --- a/lib/types/array/methods/index.js +++ b/lib/types/array/methods/index.js @@ -216,8 +216,9 @@ const methods = { let populated = false; let Model; - if (this[arrayParentSymbol]) { - populated = this[arrayParentSymbol].populated(this[arrayPathSymbol], true); + const parent = this[arrayParentSymbol]; + if (parent) { + populated = parent.populated(this[arrayPathSymbol], true); } if (populated && value !== null && value !== undefined) { @@ -238,10 +239,10 @@ const methods = { if (!isDisc) { value = new Model(value); } - return this[arraySchemaSymbol].caster.applySetters(value, this[arrayParentSymbol], true); + return this[arraySchemaSymbol].caster.applySetters(value, parent, true); } - return this[arraySchemaSymbol].caster.applySetters(value, this[arrayParentSymbol], false); + return this[arraySchemaSymbol].caster.applySetters(value, parent, false); }, /** From 5f8e8a06682f12909f09778516e9bfa09ab9a396 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 15 Feb 2021 13:23:17 -0500 Subject: [PATCH 20/22] perf: some more performance improvements for primitive array proxies re: #8884 --- lib/schema/array.js | 8 +++----- lib/types/array/index.js | 11 ++++------- test/types.array.test.js | 2 +- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/schema/array.js b/lib/schema/array.js index fc3ab2a3057..d4087ab28e9 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -339,12 +339,13 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { } } + const rawValue = Array.isArray(value) ? [...value] : []; if (!(value && value.isMongooseArray)) { - value = MongooseArray(value, get(options, 'path', null) || this._arrayPath || this.path, doc, this); + value = MongooseArray(rawValue, get(options, 'path', null) || this._arrayPath || this.path, doc, this); } else if (value && value.isMongooseArray) { // We need to create a new array, otherwise change tracking will // update the old doc (gh-4449) - value = MongooseArray(value, get(options, 'path', null) || this._arrayPath || this.path, doc, this); + value = MongooseArray(rawValue, get(options, 'path', null) || this._arrayPath || this.path, doc, this); } const isPopulated = doc != null && doc.$__ != null && doc.populated(this.path); @@ -352,9 +353,6 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { return value; } - // Bypass the array proxy for performance - const rawValue = value.__array; - const caster = this.caster; if (caster && this.casterConstructor !== Mixed) { try { diff --git a/lib/types/array/index.js b/lib/types/array/index.js index 71d71afc9d4..42cc9821d2b 100644 --- a/lib/types/array/index.js +++ b/lib/types/array/index.js @@ -28,9 +28,8 @@ const arraySchemaSymbol = require('../../helpers/symbols').arraySchemaSymbol; * @see http://bit.ly/f6CnZU */ -function MongooseArray(values, path, doc, schematype, both) { - const valuesIsArray = Array.isArray(values); - const arr = valuesIsArray ? [...values] : []; +function MongooseArray(values, path, doc, schematype) { + const arr = values; const internals = { [arrayAtomicsSymbol]: {}, @@ -43,10 +42,8 @@ function MongooseArray(values, path, doc, schematype, both) { __array: arr }; - if (valuesIsArray) { - if (values[arrayAtomicsSymbol] != null) { - internals[arrayAtomicsSymbol] = values[arrayAtomicsSymbol]; - } + if (values[arrayAtomicsSymbol] != null) { + internals[arrayAtomicsSymbol] = values[arrayAtomicsSymbol]; } // Because doc comes from the context of another function, doc === global diff --git a/test/types.array.test.js b/test/types.array.test.js index 4da49406aa7..acc60f22140 100644 --- a/test/types.array.test.js +++ b/test/types.array.test.js @@ -45,7 +45,7 @@ describe('types array', function() { afterEach(() => require('./util').stopRemainingOps(db)); it('behaves and quacks like an Array', function(done) { - const a = new MongooseArray; + const a = new MongooseArray([]); assert.ok(a instanceof Array); assert.ok(a.isMongooseArray); From 1d8b00fea49e582d729dfcc630e74a643a012718 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 15 Feb 2021 15:07:39 -0500 Subject: [PATCH 21/22] test: fix a couple of tests re: #8884 --- lib/schema/array.js | 3 +++ lib/types/array/methods/index.js | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/schema/array.js b/lib/schema/array.js index d4087ab28e9..74569f6fcb5 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -20,6 +20,7 @@ const utils = require('../utils'); const castToNumber = require('./operators/helpers').castToNumber; const geospatial = require('./operators/geospatial'); const getDiscriminatorByValue = require('../helpers/discriminator/getDiscriminatorByValue'); +const { arrayAtomicsSymbol } = require('../helpers/symbols'); let MongooseArray; let EmbeddedDoc; @@ -339,6 +340,7 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { } } + const originalValue = value; const rawValue = Array.isArray(value) ? [...value] : []; if (!(value && value.isMongooseArray)) { value = MongooseArray(rawValue, get(options, 'path', null) || this._arrayPath || this.path, doc, this); @@ -346,6 +348,7 @@ SchemaArray.prototype.cast = function(value, doc, init, prev, options) { // We need to create a new array, otherwise change tracking will // update the old doc (gh-4449) value = MongooseArray(rawValue, get(options, 'path', null) || this._arrayPath || this.path, doc, this); + value[arrayAtomicsSymbol] = originalValue[arrayAtomicsSymbol]; } const isPopulated = doc != null && doc.$__ != null && doc.populated(this.path); diff --git a/lib/types/array/methods/index.js b/lib/types/array/methods/index.js index c885ddb5032..d4983ebcc43 100644 --- a/lib/types/array/methods/index.js +++ b/lib/types/array/methods/index.js @@ -740,13 +740,14 @@ const methods = { */ set(i, val, skipModified) { + const arr = this.__array; if (skipModified) { - this[i] = val; + arr[i] = val; return this; } - const value = this._cast(val, i); - this[i] = value; - this._markModified(i); + const value = methods._cast.call(this, val, i); + arr[i] = value; + methods._markModified.call(this, i); return this; }, From 0953ff51b870266a0e0e2b6177f65a7a8752920a Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 15 Feb 2021 15:13:32 -0500 Subject: [PATCH 22/22] test: make versioning test less brittle --- test/versioning.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/versioning.test.js b/test/versioning.test.js index 7c4efd9af24..a26e22593a9 100644 --- a/test/versioning.test.js +++ b/test/versioning.test.js @@ -270,7 +270,8 @@ describe('versioning', function() { a.meta.numbers.pull(2); b.meta.numbers.push(10); - yield [a.save(), b.save()]; + yield a.save(); + yield b.save(); a = yield BlogPost.findById(a); assert.deepEqual(a.toObject().meta.numbers, [4, 6, 8, 10]);