From 9ffd12e97b3a7c602b6f639746a6c3d2249471d8 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 15 Nov 2024 14:17:35 -0500 Subject: [PATCH] feat: add forceRepopulate option for populate() to allow avoiding repopulating already populated docs Fix #14979 --- .../populate/getModelsMapForPopulate.js | 7 ++ lib/model.js | 2 + lib/validOptions.js | 1 + test/model.populate.test.js | 85 +++++++++++++++++++ types/populate.d.ts | 2 + 5 files changed, 97 insertions(+) diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js index 16d920366a8..0eead6ec632 100644 --- a/lib/helpers/populate/getModelsMapForPopulate.js +++ b/lib/helpers/populate/getModelsMapForPopulate.js @@ -54,6 +54,13 @@ module.exports = function getModelsMapForPopulate(model, docs, options) { doc = docs[i]; let justOne = null; + if (doc.$__ != null && doc.populated(options.path)) { + const forceRepopulate = options.forceRepopulate != null ? options.forceRepopulate : doc.constructor.base.options.forceRepopulate; + if (forceRepopulate === false) { + continue; + } + } + const docSchema = doc != null && doc.$__ != null ? doc.$__schema : modelSchema; schema = getSchemaTypes(model, docSchema, doc, options.path); diff --git a/lib/model.js b/lib/model.js index dd7f3227d83..c730afacefc 100644 --- a/lib/model.js +++ b/lib/model.js @@ -4199,6 +4199,7 @@ Model.validate = async function validate(obj, pathsOrOptions, context) { * - options: optional query options like sort, limit, etc * - justOne: optional boolean, if true Mongoose will always set `path` to a document, or `null` if no document was found. If false, Mongoose will always set `path` to an array, which will be empty if no documents are found. Inferred from schema by default. * - strictPopulate: optional boolean, set to `false` to allow populating paths that aren't in the schema. + * - forceRepopulate: optional boolean, defaults to `true`. Set to `false` to prevent Mongoose from repopulating paths that are already populated * * #### Example: * @@ -4235,6 +4236,7 @@ Model.validate = async function validate(obj, pathsOrOptions, context) { * @param {Boolean} [options.strictPopulate=true] Set to false to allow populating paths that aren't defined in the given model's schema. * @param {Object} [options.options=null] Additional options like `limit` and `lean`. * @param {Function} [options.transform=null] Function that Mongoose will call on every populated document that allows you to transform the populated document. + * @param {Boolean} [options.forceRepopulate=true] Set to `false` to prevent Mongoose from repopulating paths that are already populated * @param {Function} [callback(err,doc)] Optional callback, executed upon completion. Receives `err` and the `doc(s)`. * @return {Promise} * @api public diff --git a/lib/validOptions.js b/lib/validOptions.js index 15fd3e634e7..6c09480def1 100644 --- a/lib/validOptions.js +++ b/lib/validOptions.js @@ -17,6 +17,7 @@ const VALID_OPTIONS = Object.freeze([ 'cloneSchemas', 'createInitialConnection', 'debug', + 'forceRepopulate', 'id', 'timestamps.createdAt.immutable', 'maxTimeMS', diff --git a/test/model.populate.test.js b/test/model.populate.test.js index ef827fbc3bf..7cec370e890 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -11279,4 +11279,89 @@ describe('model: populate:', function() { assert.strictEqual(doc.node.length, 1); assert.strictEqual(doc.node[0]._id, '65c7953e-c6e9-4c2f-8328-fe2de7df560d'); }); + + it('avoids repopulating if forceRepopulate disabled (gh-14979)', async function() { + const ChildSchema = new Schema({ name: String }); + const ParentSchema = new Schema({ + children: [{ type: Schema.Types.ObjectId, ref: 'Child' }], + child: { type: 'ObjectId', ref: 'Child' } + }); + + const Child = db.model('Child', ChildSchema); + const Parent = db.model('Parent', ParentSchema); + + const child = await Child.create({ name: 'Child test' }); + let parent = await Parent.create({ child: child._id, children: [child._id] }); + + parent = await Parent.findOne({ _id: parent._id }).populate(['child', 'children']).orFail(); + child.name = 'Child test updated 1'; + await child.save(); + + await parent.populate({ path: 'child', forceRepopulate: false }); + await parent.populate({ path: 'children', forceRepopulate: false }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + await Parent.populate([parent], { path: 'child', forceRepopulate: false }); + await Parent.populate([parent], { path: 'children', forceRepopulate: false }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + parent.depopulate('child'); + parent.depopulate('children'); + await parent.populate({ path: 'child', forceRepopulate: false }); + await parent.populate({ path: 'children', forceRepopulate: false }); + assert.equal(parent.child.name, 'Child test updated 1'); + assert.equal(parent.children[0].name, 'Child test updated 1'); + }); + + it('handles forceRepopulate as a global option (gh-14979)', async function() { + const m = new mongoose.Mongoose(); + m.set('forceRepopulate', false); + await m.connect(start.uri); + const ChildSchema = new m.Schema({ name: String }); + const ParentSchema = new m.Schema({ + children: [{ type: Schema.Types.ObjectId, ref: 'Child' }], + child: { type: 'ObjectId', ref: 'Child' } + }); + + const Child = m.model('Child', ChildSchema); + const Parent = m.model('Parent', ParentSchema); + + const child = await Child.create({ name: 'Child test' }); + let parent = await Parent.create({ child: child._id, children: [child._id] }); + + parent = await Parent.findOne({ _id: parent._id }).populate(['child', 'children']).orFail(); + child.name = 'Child test updated 1'; + await child.save(); + + await parent.populate({ path: 'child' }); + await parent.populate({ path: 'children' }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + await Parent.populate([parent], { path: 'child' }); + await Parent.populate([parent], { path: 'children' }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + parent.depopulate('child'); + parent.depopulate('children'); + await parent.populate({ path: 'child' }); + await parent.populate({ path: 'children' }); + assert.equal(parent.child.name, 'Child test updated 1'); + assert.equal(parent.children[0].name, 'Child test updated 1'); + + child.name = 'Child test updated 2'; + await child.save(); + + parent.depopulate('child'); + parent.depopulate('children'); + await parent.populate({ path: 'child', forceRepopulate: true }); + await parent.populate({ path: 'children', forceRepopulate: true }); + assert.equal(parent.child.name, 'Child test updated 2'); + assert.equal(parent.children[0].name, 'Child test updated 2'); + + await m.disconnect(); + }); }); diff --git a/types/populate.d.ts b/types/populate.d.ts index 0db038014f9..8517c15865c 100644 --- a/types/populate.d.ts +++ b/types/populate.d.ts @@ -37,6 +37,8 @@ declare module 'mongoose' { localField?: string; /** Overwrite the schema-level foreign field to populate on if this is a populated virtual. */ foreignField?: string; + /** Set to `false` to prevent Mongoose from repopulating paths that are already populated */ + forceRepopulate?: boolean; } interface PopulateOption {