Skip to content

Commit

Permalink
feat: add forceRepopulate option for populate() to allow avoiding rep…
Browse files Browse the repository at this point in the history
…opulating already populated docs

Fix #14979
  • Loading branch information
vkarpov15 committed Nov 15, 2024
1 parent 7aba322 commit 9ffd12e
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 0 deletions.
7 changes: 7 additions & 0 deletions lib/helpers/populate/getModelsMapForPopulate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/validOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const VALID_OPTIONS = Object.freeze([
'cloneSchemas',
'createInitialConnection',
'debug',
'forceRepopulate',
'id',
'timestamps.createdAt.immutable',
'maxTimeMS',
Expand Down
85 changes: 85 additions & 0 deletions test/model.populate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions types/populate.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 9ffd12e

Please sign in to comment.