From 7218969d689d7270d8bba11c80b9b123708b31be Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Mon, 26 Feb 2024 13:24:49 -0500 Subject: [PATCH 1/8] feat: `pathsToSave` option to `save()` function --- lib/model.js | 9 ++++++--- test/model.test.js | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/model.js b/lib/model.js index 1542b5e2035..84515846436 100644 --- a/lib/model.js +++ b/lib/model.js @@ -298,7 +298,6 @@ Model.prototype.$__handleSave = function(options, callback) { if (!saveOptions.hasOwnProperty('session') && session != null) { saveOptions.session = session; } - if (this.$isNew) { // send entire doc const obj = this.toObject(saveToObjectOptions); @@ -511,6 +510,7 @@ function generateVersionError(doc, modifiedPaths) { * @param {Number} [options.wtimeout] sets a [timeout for the write concern](https://www.mongodb.com/docs/manual/reference/write-concern/#wtimeout). Overrides the [schema-level `writeConcern` option](https://mongoosejs.com/docs/guide.html#writeConcern). * @param {Boolean} [options.checkKeys=true] the MongoDB driver prevents you from saving keys that start with '$' or contain '.' by default. Set this option to `false` to skip that check. See [restrictions on field names](https://docs.mongodb.com/manual/reference/limits/#mongodb-limit-Restrictions-on-Field-Names) * @param {Boolean} [options.timestamps=true] if `false` and [timestamps](https://mongoosejs.com/docs/guide.html#timestamps) are enabled, skip timestamps for this `save()`. + * @param {Array} [options.pathsToSave] An array of paths that tell mongoose to only validate and save the paths in `pathsToSave`. * @throws {DocumentNotFoundError} if this [save updates an existing document](https://mongoosejs.com/docs/api/document.html#Document.prototype.isNew) but the document doesn't exist in the database. For example, you will get this error if the document is [deleted between when you retrieved the document and when you saved it](documents.html#updating). * @return {Promise} * @api public @@ -736,8 +736,11 @@ function handleAtomics(self, where, delta, data, value) { */ Model.prototype.$__delta = function() { - const dirty = this.$__dirty(); - + let dirty = this.$__dirty(); + if (this.$__.saveOptions && this.$__.saveOptions.pathsToSave) { + const pathsToSave = this.$__.saveOptions.pathsToSave; + dirty = dirty.filter(x => pathsToSave.includes(x.path)); + } const optimisticConcurrency = this.$__schema.options.optimisticConcurrency; if (optimisticConcurrency) { if (Array.isArray(optimisticConcurrency)) { diff --git a/test/model.test.js b/test/model.test.js index ad989eb734b..5bacd7919a3 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -2537,6 +2537,18 @@ describe('Model', function() { assert.ok(!doc.$__.$versionError); assert.ok(!doc.$__.saveOptions); }); + it('should only save paths specificed in the `pathsToSave` array (gh-9583)', async function() { + const schema = new Schema({ name: String, age: Number, weight: Number, location: String }); + const Test = db.model('Test', schema); + await Test.create({ name: 'Test Testerson', age: 1, weight: 180, location: 'Florida' }); + const doc = await Test.findOne(); + doc.name = 'Test'; + doc.age = 100; + await doc.save({ pathsToSave: ['name'] }); + const check = await Test.findOne(); + assert.equal(check.name, 'Test'); + assert.equal(check.age, 1); + }); }); From 62655fcac64de4ac4ac5282385f29353e61da704 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:23:45 -0500 Subject: [PATCH 2/8] move filtering to handle save --- lib/model.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/model.js b/lib/model.js index 84515846436..d7759c0e489 100644 --- a/lib/model.js +++ b/lib/model.js @@ -333,7 +333,16 @@ Model.prototype.$__handleSave = function(options, callback) { // Make sure we don't treat it as a new object on error, // since it already exists this.$__.inserting = false; - const delta = this.$__delta(); + let delta = this.$__delta(); + if (options.pathsToSave) { + for (const key in delta[1]['$set']) { + if (options.pathsToSave.includes(key)) { + continue; + } else { + delete delta[1]['$set'][key]; + } + } + } if (delta) { if (delta instanceof MongooseError) { callback(delta); @@ -736,11 +745,7 @@ function handleAtomics(self, where, delta, data, value) { */ Model.prototype.$__delta = function() { - let dirty = this.$__dirty(); - if (this.$__.saveOptions && this.$__.saveOptions.pathsToSave) { - const pathsToSave = this.$__.saveOptions.pathsToSave; - dirty = dirty.filter(x => pathsToSave.includes(x.path)); - } + const dirty = this.$__dirty(); const optimisticConcurrency = this.$__schema.options.optimisticConcurrency; if (optimisticConcurrency) { if (Array.isArray(optimisticConcurrency)) { From fae80d3bd89e62f9a5937752d5a41434a96038a9 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:24:17 -0500 Subject: [PATCH 3/8] fix: lint --- lib/model.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model.js b/lib/model.js index d7759c0e489..f4117bd7d76 100644 --- a/lib/model.js +++ b/lib/model.js @@ -333,7 +333,7 @@ Model.prototype.$__handleSave = function(options, callback) { // Make sure we don't treat it as a new object on error, // since it already exists this.$__.inserting = false; - let delta = this.$__delta(); + const delta = this.$__delta(); if (options.pathsToSave) { for (const key in delta[1]['$set']) { if (options.pathsToSave.includes(key)) { From 5635a497146e9785a846c87d3ac318da12e86680 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:42:08 -0500 Subject: [PATCH 4/8] Support subdocs, doc arrays, and validation --- lib/document.js | 10 +++++++- lib/model.js | 3 +++ test/model.test.js | 60 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/lib/document.js b/lib/document.js index 4127102f0c9..0dc1238bc2c 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2843,7 +2843,10 @@ function _getPathsToValidate(doc, pathsToValidate, pathsToSkip) { */ Document.prototype.$__validate = function(pathsToValidate, options, callback) { - if (typeof pathsToValidate === 'function') { + + if (this.$__.saveOptions && this.$__.saveOptions.pathsToSave && !pathsToValidate) { + pathsToValidate = [...this.$__.saveOptions.pathsToSave]; + } else if (typeof pathsToValidate === 'function') { callback = pathsToValidate; options = null; pathsToValidate = null; @@ -2927,6 +2930,11 @@ Document.prototype.$__validate = function(pathsToValidate, options, callback) { let total = 0; for (const path of paths) { + if (_this.$__.saveOptions && _this.$__.saveOptions.pathsToSave) { + if (!_this.$__.saveOptions.pathsToSave.includes(path)) { + continue; + } + } validatePath(path); } diff --git a/lib/model.js b/lib/model.js index f4117bd7d76..630d609bfc6 100644 --- a/lib/model.js +++ b/lib/model.js @@ -334,10 +334,13 @@ Model.prototype.$__handleSave = function(options, callback) { // since it already exists this.$__.inserting = false; const delta = this.$__delta(); + if (options.pathsToSave) { for (const key in delta[1]['$set']) { if (options.pathsToSave.includes(key)) { continue; + } else if (options.pathsToSave.some(element => key.includes(element))) { + continue; } else { delete delta[1]['$set'][key]; } diff --git a/test/model.test.js b/test/model.test.js index 5bacd7919a3..fa122f453dc 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -2538,17 +2538,75 @@ describe('Model', function() { assert.ok(!doc.$__.saveOptions); }); it('should only save paths specificed in the `pathsToSave` array (gh-9583)', async function() { - const schema = new Schema({ name: String, age: Number, weight: Number, location: String }); + const schema = new Schema({ name: String, age: Number, weight: { type: Number, validate: v => v == null || v >= 140 }, location: String }); const Test = db.model('Test', schema); await Test.create({ name: 'Test Testerson', age: 1, weight: 180, location: 'Florida' }); const doc = await Test.findOne(); doc.name = 'Test'; doc.age = 100; + doc.weight = 80; await doc.save({ pathsToSave: ['name'] }); const check = await Test.findOne(); assert.equal(check.name, 'Test'); + assert.equal(check.weight, 180); assert.equal(check.age, 1); }); + it('should have `pathsToSave` work with subdocs (gh-9583)', async function() { + const locationSchema = new Schema({ state: String, city: String, zip: { type: Number, validate: v => v == null || v.toString().length == 5 } }); + const schema = new Schema({ name: String, age: Number, weight: { type: Number, validate: v => v == null || v >= 140 }, location: locationSchema }); + const Test = db.model('Test', schema); + await Test.create({ name: 'Test Testerson', age: 1, weight: 180, location: { state: 'FL', city: 'Miami', zip: 33330 } }); + const doc = await Test.findOne(); + doc.name = 'Test'; + doc.age = 100; + doc.weight = 80; + doc.location.state = 'Ohio'; + doc.location.zip = 0; + await doc.save({ pathsToSave: ['name', 'location.state'] }); + const check = await Test.findOne(); + assert.equal(check.name, 'Test'); + assert.equal(check.weight, 180); + assert.equal(check.age, 1); + assert.equal(check.location.state, 'Ohio'); + assert.equal(check.location.zip, 33330); + check.location = { state: 'Georgia', city: 'Athens', zip: 34512 }; + check.name = 'Quiz'; + check.age = 50; + await check.save({ pathsToSave: ['name', 'location'] }); + const nestedCheck = await Test.findOne(); + assert.equal(nestedCheck.location.state, 'Georgia'); + assert.equal(nestedCheck.location.city, 'Athens'); + assert.equal(nestedCheck.location.zip, 34512); + assert.equal(nestedCheck.name, 'Quiz'); + }); + it('should have `pathsToSave` work with doc arrays asdf (gh-9583)', async function() { + const locationSchema = new Schema({ state: String, city: String, zip: { type: Number, validate: v => v == null || v.toString().length == 5 } }); + const schema = new Schema({ name: String, age: Number, weight: { type: Number, validate: v => v == null || v >= 140 }, location: [locationSchema] }); + const Test = db.model('Test', schema); + await Test.create({ name: 'Test Testerson', age: 1, weight: 180, location: [{ state: 'FL', city: 'Miami', zip: 33330 }, { state: 'New York', city: 'Albany', zip: 34567 }] }); + const doc = await Test.findOne(); + doc.name = 'Test'; + doc.age = 100; + doc.weight = 80; + doc.location[0].state = 'Ohio'; + doc.location[0].zip = 0; + await doc.save({ pathsToSave: ['name', 'location.0.state'] }); + const check = await Test.findOne(); + assert.equal(check.name, 'Test'); + assert.equal(check.weight, 180); + assert.equal(check.age, 1); + assert.equal(check.location[0].state, 'Ohio'); + assert.equal(check.location[0].zip, 33330); + check.location[0] = { state: 'Georgia', city: 'Athens', zip: 34512 }; + check.name = 'Quiz'; + check.age = 50; + await check.save({ pathsToSave: ['name', 'location'] }); + const nestedCheck = await Test.findOne(); + assert.equal(nestedCheck.location[0].state, 'Georgia'); + assert.equal(nestedCheck.location[0].city, 'Athens'); + assert.equal(nestedCheck.location[0].zip, 34512); + assert.equal(nestedCheck.name, 'Quiz'); + }); }); From c96b5bba0563471c7cadb839480c689db9d5b8ec Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 20 Mar 2024 17:24:45 -0400 Subject: [PATCH 5/8] fix: better handling for paths that contain other paths --- lib/model.js | 2 +- test/model.test.js | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/model.js b/lib/model.js index 630d609bfc6..f6540bced92 100644 --- a/lib/model.js +++ b/lib/model.js @@ -339,7 +339,7 @@ Model.prototype.$__handleSave = function(options, callback) { for (const key in delta[1]['$set']) { if (options.pathsToSave.includes(key)) { continue; - } else if (options.pathsToSave.some(element => key.includes(element))) { + } else if (options.pathsToSave.some(pathToSave => key.slice(0, pathToSave.length) === pathToSave && key.charAt(pathToSave.length) === '.')) { continue; } else { delete delta[1]['$set'][key]; diff --git a/test/model.test.js b/test/model.test.js index fa122f453dc..3f35166e461 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -2553,18 +2553,26 @@ describe('Model', function() { }); it('should have `pathsToSave` work with subdocs (gh-9583)', async function() { const locationSchema = new Schema({ state: String, city: String, zip: { type: Number, validate: v => v == null || v.toString().length == 5 } }); - const schema = new Schema({ name: String, age: Number, weight: { type: Number, validate: v => v == null || v >= 140 }, location: locationSchema }); + const schema = new Schema({ + name: String, + nickname: String, + age: Number, + weight: { type: Number, validate: v => v == null || v >= 140 }, + location: locationSchema + }); const Test = db.model('Test', schema); - await Test.create({ name: 'Test Testerson', age: 1, weight: 180, location: { state: 'FL', city: 'Miami', zip: 33330 } }); - const doc = await Test.findOne(); + await Test.create({ name: 'Test Testerson', nickname: 'test', age: 1, weight: 180, location: { state: 'FL', city: 'Miami', zip: 33330 } }); + let doc = await Test.findOne(); doc.name = 'Test'; + doc.nickname = 'Test2'; doc.age = 100; doc.weight = 80; doc.location.state = 'Ohio'; doc.location.zip = 0; await doc.save({ pathsToSave: ['name', 'location.state'] }); - const check = await Test.findOne(); + let check = await Test.findOne(); assert.equal(check.name, 'Test'); + assert.equal(check.nickname, 'test'); assert.equal(check.weight, 180); assert.equal(check.age, 1); assert.equal(check.location.state, 'Ohio'); @@ -2578,8 +2586,17 @@ describe('Model', function() { assert.equal(nestedCheck.location.city, 'Athens'); assert.equal(nestedCheck.location.zip, 34512); assert.equal(nestedCheck.name, 'Quiz'); + + doc = await Test.findOne(); + doc.name = 'foobar'; + doc.location.city = 'Reynolds'; + await doc.save({ pathsToSave: ['location'] }); + check = await Test.findById(doc._id); + assert.equal(check.name, 'Quiz'); + assert.equal(check.location.city, 'Reynolds'); + assert.equal(check.location.state, 'Georgia'); }); - it('should have `pathsToSave` work with doc arrays asdf (gh-9583)', async function() { + it('should have `pathsToSave` work with doc arrays (gh-9583)', async function() { const locationSchema = new Schema({ state: String, city: String, zip: { type: Number, validate: v => v == null || v.toString().length == 5 } }); const schema = new Schema({ name: String, age: Number, weight: { type: Number, validate: v => v == null || v >= 140 }, location: [locationSchema] }); const Test = db.model('Test', schema); @@ -2590,6 +2607,7 @@ describe('Model', function() { doc.weight = 80; doc.location[0].state = 'Ohio'; doc.location[0].zip = 0; + doc.location[1].state = 'Ohio'; await doc.save({ pathsToSave: ['name', 'location.0.state'] }); const check = await Test.findOne(); assert.equal(check.name, 'Test'); @@ -2597,6 +2615,7 @@ describe('Model', function() { assert.equal(check.age, 1); assert.equal(check.location[0].state, 'Ohio'); assert.equal(check.location[0].zip, 33330); + assert.equal(check.location[1].state, 'New York'); check.location[0] = { state: 'Georgia', city: 'Athens', zip: 34512 }; check.name = 'Quiz'; check.age = 50; From ebedffc37f838d56f775f216c322bf345d0a215e Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 20 Mar 2024 17:32:36 -0400 Subject: [PATCH 6/8] Update lib/document.js Co-authored-by: Hafez --- lib/document.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/document.js b/lib/document.js index 0dc1238bc2c..52ec614350c 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2930,11 +2930,12 @@ Document.prototype.$__validate = function(pathsToValidate, options, callback) { let total = 0; for (const path of paths) { - if (_this.$__.saveOptions && _this.$__.saveOptions.pathsToSave) { - if (!_this.$__.saveOptions.pathsToSave.includes(path)) { - continue; - } - } +if ( + Array.isArray(_this.$__.saveOptions?.pathsToSave) && + _this.$__.saveOptions.pathsToSave.includes(path) === false + ) { + continue; +} validatePath(path); } From 2edf2fffc5b22a97e9cc2e513f1a6dde0c142bbe Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 20 Mar 2024 17:40:31 -0400 Subject: [PATCH 7/8] style: fix lint --- lib/document.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/document.js b/lib/document.js index 3695778dce2..3ad8911854d 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2933,12 +2933,12 @@ Document.prototype.$__validate = function(pathsToValidate, options, callback) { let total = 0; for (const path of paths) { -if ( - Array.isArray(_this.$__.saveOptions?.pathsToSave) && + if ( + Array.isArray(_this.$__.saveOptions?.pathsToSave) && _this.$__.saveOptions.pathsToSave.includes(path) === false - ) { - continue; -} + ) { + continue; + } validatePath(path); } From aabddf439562c5ff21cc246cf47aa9814e05cf94 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 20 Mar 2024 17:44:01 -0400 Subject: [PATCH 8/8] style: cleanup --- lib/document.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/document.js b/lib/document.js index 3ad8911854d..234e32f18d4 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2932,14 +2932,19 @@ Document.prototype.$__validate = function(pathsToValidate, options, callback) { const validated = {}; let total = 0; - for (const path of paths) { - if ( - Array.isArray(_this.$__.saveOptions?.pathsToSave) && - _this.$__.saveOptions.pathsToSave.includes(path) === false - ) { - continue; + let pathsToSave = this.$__.saveOptions?.pathsToSave; + if (Array.isArray(pathsToSave)) { + pathsToSave = new Set(pathsToSave); + for (const path of paths) { + if (!pathsToSave.has(path)) { + continue; + } + validatePath(path); + } + } else { + for (const path of paths) { + validatePath(path); } - validatePath(path); } function validatePath(path) {