From 53f927876af68ce4132222c665f18da6c18e0b71 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Thu, 6 Feb 2025 12:45:24 -0500 Subject: [PATCH] Revert "Revert "Introduce populate ordered option for populating in series rather than in parallel"" --- lib/document.js | 3 +++ lib/model.js | 37 ++++++++++++++++++++++++------- lib/utils.js | 10 ++++++--- test/document.populate.test.js | 40 ++++++++++++++++++++++++++++++++++ types/populate.d.ts | 6 +++++ 5 files changed, 85 insertions(+), 11 deletions(-) diff --git a/lib/document.js b/lib/document.js index ffba23c071f..4d37027f7a7 100644 --- a/lib/document.js +++ b/lib/document.js @@ -4509,6 +4509,8 @@ Document.prototype.equals = function(doc) { * @param {Object|Function} [options.match=null] Add an additional filter to the populate query. Can be a filter object containing [MongoDB query syntax](https://www.mongodb.com/docs/manual/tutorial/query-documents/), or a function that returns a filter object. * @param {Function} [options.transform=null] Function that Mongoose will call on every populated document that allows you to transform the populated document. * @param {Object} [options.options=null] Additional options like `limit` and `lean`. + * @param {Boolean} [options.forceRepopulate=true] Set to `false` to prevent Mongoose from repopulating paths that are already populated + * @param {Boolean} [options.ordered=false] Set to `true` to execute any populate queries one at a time, as opposed to in parallel. We recommend setting this option to `true` if using transactions, especially if also populating multiple paths or paths with multiple models. MongoDB server does **not** support multiple operations in parallel on a single transaction. * @param {Function} [callback] Callback * @see population https://mongoosejs.com/docs/populate.html * @see Query#select https://mongoosejs.com/docs/api/query.html#Query.prototype.select() @@ -4535,6 +4537,7 @@ Document.prototype.populate = async function populate() { } const paths = utils.object.vals(pop); + let topLevelModel = this.constructor; if (this.$__isNested) { topLevelModel = this.$__[scopeSymbol].constructor; diff --git a/lib/model.js b/lib/model.js index 404fb7cfc2e..80c4b2741c8 100644 --- a/lib/model.js +++ b/lib/model.js @@ -4369,6 +4369,7 @@ Model.validate = async function validate(obj, pathsOrOptions, context) { * @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 {Boolean} [options.ordered=false] Set to `true` to execute any populate queries one at a time, as opposed to in parallel. Set this option to `true` if populating multiple paths or paths with multiple models in transactions. * @return {Promise} * @api public */ @@ -4386,11 +4387,21 @@ Model.populate = async function populate(docs, paths) { } // each path has its own query options and must be executed separately - const promises = []; - for (const path of paths) { - promises.push(_populatePath(this, docs, path)); + if (paths.find(p => p.ordered)) { + // Populate in series, primarily for transactions because MongoDB doesn't support multiple operations on + // one transaction in parallel. + // Note that if _any_ path has `ordered`, we make the top-level populate `ordered` as well. + for (const path of paths) { + await _populatePath(this, docs, path); + } + } else { + // By default, populate in parallel + const promises = []; + for (const path of paths) { + promises.push(_populatePath(this, docs, path)); + } + await Promise.all(promises); } - await Promise.all(promises); return docs; }; @@ -4510,12 +4521,22 @@ async function _populatePath(model, docs, populateOptions) { return; } - const promises = []; - for (const arr of params) { - promises.push(_execPopulateQuery.apply(null, arr).then(valsFromDb => { vals = vals.concat(valsFromDb); })); + if (populateOptions.ordered) { + // Populate in series, primarily for transactions because MongoDB doesn't support multiple operations on + // one transaction in parallel. + for (const arr of params) { + await _execPopulateQuery.apply(null, arr).then(valsFromDb => { vals = vals.concat(valsFromDb); }); + } + } else { + // By default, populate in parallel + const promises = []; + for (const arr of params) { + promises.push(_execPopulateQuery.apply(null, arr).then(valsFromDb => { vals = vals.concat(valsFromDb); })); + } + + await Promise.all(promises); } - await Promise.all(promises); for (const arr of params) { const mod = arr[0]; diff --git a/lib/utils.js b/lib/utils.js index 6fc5c335ef0..e0cddc0ba6a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -551,8 +551,8 @@ exports.populate = function populate(path, select, model, match, options, subPop }; } - if (typeof obj.path !== 'string') { - throw new TypeError('utils.populate: invalid path. Expected string. Got typeof `' + typeof path + '`'); + if (typeof obj.path !== 'string' && !(Array.isArray(obj.path) && obj.path.every(el => typeof el === 'string'))) { + throw new TypeError('utils.populate: invalid path. Expected string or array of strings. Got typeof `' + typeof path + '`'); } return _populateObj(obj); @@ -600,7 +600,11 @@ function _populateObj(obj) { } const ret = []; - const paths = oneSpaceRE.test(obj.path) ? obj.path.split(manySpaceRE) : [obj.path]; + const paths = oneSpaceRE.test(obj.path) + ? obj.path.split(manySpaceRE) + : Array.isArray(obj.path) + ? obj.path + : [obj.path]; if (obj.options != null) { obj.options = clone(obj.options); } diff --git a/test/document.populate.test.js b/test/document.populate.test.js index bbfbc1df99a..00a34ba0ce8 100644 --- a/test/document.populate.test.js +++ b/test/document.populate.test.js @@ -1075,4 +1075,44 @@ describe('document.populate', function() { assert.deepStrictEqual(codeUser.extras[0].config.paymentConfiguration.paymentMethods[0]._id, code._id); assert.strictEqual(codeUser.extras[0].config.paymentConfiguration.paymentMethods[0].code, 'test code'); }); + + it('supports populate with ordered option (gh-15231)', async function() { + const docSchema = new Schema({ + refA: { type: Schema.Types.ObjectId, ref: 'Test1' }, + refB: { type: Schema.Types.ObjectId, ref: 'Test2' }, + refC: { type: Schema.Types.ObjectId, ref: 'Test3' } + }); + + const doc1Schema = new Schema({ name: String }); + const doc2Schema = new Schema({ title: String }); + const doc3Schema = new Schema({ content: String }); + + const Doc = db.model('Test', docSchema); + const Doc1 = db.model('Test1', doc1Schema); + const Doc2 = db.model('Test2', doc2Schema); + const Doc3 = db.model('Test3', doc3Schema); + + const doc1 = await Doc1.create({ name: 'test 1' }); + const doc2 = await Doc2.create({ title: 'test 2' }); + const doc3 = await Doc3.create({ content: 'test 3' }); + + const docD = await Doc.create({ + refA: doc1._id, + refB: doc2._id, + refC: doc3._id + }); + + await docD.populate({ + path: ['refA', 'refB', 'refC'], + ordered: true + }); + + assert.ok(docD.populated('refA')); + assert.ok(docD.populated('refB')); + assert.ok(docD.populated('refC')); + + assert.equal(docD.refA.name, 'test 1'); + assert.equal(docD.refB.title, 'test 2'); + assert.equal(docD.refC.content, 'test 3'); + }); }); diff --git a/types/populate.d.ts b/types/populate.d.ts index 8517c15865c..dac2a248217 100644 --- a/types/populate.d.ts +++ b/types/populate.d.ts @@ -39,6 +39,12 @@ declare module 'mongoose' { foreignField?: string; /** Set to `false` to prevent Mongoose from repopulating paths that are already populated */ forceRepopulate?: boolean; + /** + * Set to `true` to execute any populate queries one at a time, as opposed to in parallel. + * We recommend setting this option to `true` if using transactions, especially if also populating multiple paths or paths with multiple models. + * MongoDB server does **not** support multiple operations in parallel on a single transaction. + */ + ordered?: boolean; } interface PopulateOption {