diff --git a/lib/document.js b/lib/document.js index 2990eb8ba1c..234e32f18d4 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2846,7 +2846,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; @@ -2929,8 +2932,19 @@ Document.prototype.$__validate = function(pathsToValidate, options, callback) { const validated = {}; let total = 0; - for (const path of paths) { - validatePath(path); + 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); + } } function validatePath(path) { diff --git a/lib/model.js b/lib/model.js index ebf03e22133..f5e4f12f967 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); @@ -335,6 +334,18 @@ 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(pathToSave => key.slice(0, pathToSave.length) === pathToSave && key.charAt(pathToSave.length) === '.')) { + continue; + } else { + delete delta[1]['$set'][key]; + } + } + } if (delta) { if (delta instanceof MongooseError) { callback(delta); @@ -521,6 +532,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 @@ -747,7 +759,6 @@ function handleAtomics(self, where, delta, data, value) { Model.prototype.$__delta = function() { const dirty = this.$__dirty(); - 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 e3926b39d5e..76e8341ec1b 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -2537,6 +2537,95 @@ 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: { 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, + 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', 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'] }); + 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'); + 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'); + + 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 (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; + doc.location[1].state = 'Ohio'; + 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); + assert.equal(check.location[1].state, 'New York'); + 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'); + }); });