diff --git a/lib/document.js b/lib/document.js index b8a517fb8fd..c2857fe9061 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2721,12 +2721,33 @@ function _getPathsToValidate(doc, pathsToValidate, pathsToSkip, isNestedValidate function addToPaths(p) { paths.add(p); } if (!isNestedValidate) { - // If we're validating a subdocument, all this logic will run anyway on the top-level document, so skip for subdocuments - const subdocs = doc.$getAllSubdocs({ useCache: true }); + // If we're validating a subdocument, all this logic will run anyway on the top-level document, so skip for subdocuments. + // But only run for top-level subdocuments, because we're looking for subdocuments that are not modified at top-level but + // have a modified path. If that is the case, we will run validation on the top-level subdocument, and via that run validation + // on any subdocuments down to the modified path. + const topLevelSubdocs = []; + for (const path of Object.keys(doc.$__schema.paths)) { + const schemaType = doc.$__schema.path(path); + if (schemaType.$isSingleNested) { + const subdoc = doc.$get(path); + if (subdoc) { + topLevelSubdocs.push(subdoc); + } + } else if (schemaType.$isMongooseDocumentArray) { + const arr = doc.$get(path); + if (arr && arr.length) { + for (const subdoc of arr) { + if (subdoc) { + topLevelSubdocs.push(subdoc); + } + } + } + } + } const modifiedPaths = doc.modifiedPaths(); - for (const subdoc of subdocs) { + for (const subdoc of topLevelSubdocs) { if (subdoc.$basePath) { - const fullPathToSubdoc = subdoc.$isSingleNested ? subdoc.$__pathRelativeToParent() : subdoc.$__fullPathWithIndexes(); + const fullPathToSubdoc = subdoc.$__pathRelativeToParent(); // Remove child paths for now, because we'll be validating the whole // subdoc. @@ -2736,11 +2757,12 @@ function _getPathsToValidate(doc, pathsToValidate, pathsToSkip, isNestedValidate paths.delete(fullPathToSubdoc + '.' + modifiedPath); } + const subdocParent = subdoc.$parent(); if (doc.$isModified(fullPathToSubdoc, null, modifiedPaths) && // Avoid using isDirectModified() here because that does additional checks on whether the parent path // is direct modified, which can cause performance issues re: gh-14897 - !doc.$__.activePaths.getStatePaths('modify').hasOwnProperty(fullPathToSubdoc) && - !doc.$isDefault(fullPathToSubdoc)) { + !subdocParent.$__.activePaths.getStatePaths('modify').hasOwnProperty(fullPathToSubdoc) && + !subdocParent.$isDefault(fullPathToSubdoc)) { paths.add(fullPathToSubdoc); if (doc.$__.pathsToScopes == null) { diff --git a/test/document.test.js b/test/document.test.js index 646934b3b8c..bf4e7ba0630 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -14455,6 +14455,53 @@ describe('document', function() { assert.strictEqual(car.vin, undefined); assert.strictEqual(car.sunroof, true); }); + + it('avoids double validating document arrays underneath single nested (gh-15335)', async function() { + let arraySubdocValidateCalls = 0; + let strValidateCalls = 0; + + const embeddedSchema = new mongoose.Schema({ + arrObj: { + type: [{ + name: { + type: String, + validate: { + validator: () => { + ++arraySubdocValidateCalls; + return true; + } + } + } + }] + }, + arrStr: { + type: [{ + type: String, + validate: { + validator: () => { + ++strValidateCalls; + return true; + } + } + }] + } + }); + + const TestModel = db.model('Test', new Schema({ child: embeddedSchema })); + await TestModel.create({ + child: { + arrObj: [ + { + name: 'arrObj' + } + ], + arrStr: ['arrStr'] + } + }); + assert.strictEqual(arraySubdocValidateCalls, 1); + assert.strictEqual(strValidateCalls, 1); + + }); }); describe('Check if instance function that is supplied in schema option is available', function() {