From a4e782dec84da0a4cb7cfaa71ac8b32a55b27023 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 23 Apr 2024 16:11:07 -0400 Subject: [PATCH 1/2] fix(model): make `hydrate()` hydrate populated docs underneath virtuals --- .../populate/getModelsMapForPopulate.js | 16 +----- lib/model.js | 3 +- lib/schema.js | 24 +++++++-- lib/virtualType.js | 29 ++++++++++ test/model.hydrate.test.js | 54 +++++++++++++++++++ 5 files changed, 106 insertions(+), 20 deletions(-) diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js index ae313bb1e87..276698217d0 100644 --- a/lib/helpers/populate/getModelsMapForPopulate.js +++ b/lib/helpers/populate/getModelsMapForPopulate.js @@ -410,26 +410,12 @@ function _virtualPopulate(model, docs, options, _virtualRes) { justOne = options.justOne; } + modelNames = virtual._getModelNamesForPopulate(doc); if (virtual.options.refPath) { - modelNames = - modelNamesFromRefPath(virtual.options.refPath, doc, options.path); justOne = !!virtual.options.justOne; data.isRefPath = true; } else if (virtual.options.ref) { - let normalizedRef; - if (typeof virtual.options.ref === 'function' && !virtual.options.ref[modelSymbol]) { - normalizedRef = virtual.options.ref.call(doc, doc); - } else { - normalizedRef = virtual.options.ref; - } justOne = !!virtual.options.justOne; - // When referencing nested arrays, the ref should be an Array - // of modelNames. - if (Array.isArray(normalizedRef)) { - modelNames = normalizedRef; - } else { - modelNames = [normalizedRef]; - } } data.isVirtual = true; diff --git a/lib/model.js b/lib/model.js index d07fe2b89fb..b4226e85f7f 100644 --- a/lib/model.js +++ b/lib/model.js @@ -3959,7 +3959,8 @@ Model.hydrate = function(obj, projection, options) { obj = applyProjection(obj, projection); } const document = require('./queryHelpers').createModel(this, obj, projection); - document.$init(obj, options); + options = options || {}; + document.$init(obj, { ...options, hydrate: true }); return document; }; diff --git a/lib/schema.js b/lib/schema.js index 9cb1602e64f..6005e209bd7 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2297,7 +2297,10 @@ Schema.prototype.virtual = function(name, options) { throw new Error('Reference virtuals require `foreignField` option'); } - this.pre('init', function virtualPreInit(obj) { + const virtual = this.virtual(name); + virtual.options = options; + + this.pre('init', function virtualPreInit(obj, opts) { if (mpath.has(name, obj)) { const _v = mpath.get(name, obj); if (!this.$$populatedVirtuals) { @@ -2314,13 +2317,26 @@ Schema.prototype.virtual = function(name, options) { _v == null ? [] : [_v]; } + if (opts?.hydrate && !options.count) { + const modelNames = virtual._getModelNamesForPopulate(this); + const populatedVal = this.$$populatedVirtuals[name]; + if (!Array.isArray(populatedVal) && !populatedVal.$__ && modelNames?.length === 1) { + const PopulateModel = this.db.model(modelNames[0]); + this.$$populatedVirtuals[name] = PopulateModel.hydrate(populatedVal); + } else if (Array.isArray(populatedVal) && modelNames?.length === 1) { + const PopulateModel = this.db.model(modelNames[0]); + for (let i = 0; i < populatedVal.length; ++i) { + if (!populatedVal[i].$__) { + populatedVal[i] = PopulateModel.hydrate(populatedVal[i]); + } + } + } + } + mpath.unset(name, obj); } }); - const virtual = this.virtual(name); - virtual.options = options; - virtual. set(function(v) { if (!this.$$populatedVirtuals) { diff --git a/lib/virtualType.js b/lib/virtualType.js index 87c912fdd70..2008ebf8bb4 100644 --- a/lib/virtualType.js +++ b/lib/virtualType.js @@ -1,7 +1,10 @@ 'use strict'; +const modelNamesFromRefPath = require('./helpers/populate/modelNamesFromRefPath'); const utils = require('./utils'); +const modelSymbol = require('./helpers/symbols').modelSymbol; + /** * VirtualType constructor * @@ -168,6 +171,32 @@ VirtualType.prototype.applySetters = function(value, doc) { return v; }; +/** + * Get the names of models used to populate this model given a doc + * + * @param {Document} doc + * @return {Array | null} + * @api private + */ + +VirtualType.prototype._getModelNamesForPopulate = function _getModelNamesForPopulate(doc) { + if (this.options.refPath) { + return modelNamesFromRefPath(this.options.refPath, doc, this.path); + } + + let normalizedRef = null; + if (typeof this.options.ref === 'function' && !this.options.ref[modelSymbol]) { + normalizedRef = this.options.ref.call(doc, doc); + } else { + normalizedRef = this.options.ref; + } + if (normalizedRef != null && !Array.isArray(normalizedRef)) { + return [normalizedRef]; + } + + return normalizedRef; +}; + /*! * exports */ diff --git a/test/model.hydrate.test.js b/test/model.hydrate.test.js index bc6632f5b15..40f79464cd9 100644 --- a/test/model.hydrate.test.js +++ b/test/model.hydrate.test.js @@ -117,5 +117,59 @@ describe('model', function() { const C = Company.hydrate(company, null, { hydratedPopulatedDocs: true }); assert.equal(C.users[0].name, 'Val'); }); + it('should hydrate documents in virtual populate (gh-14503)', async function() { + const StorySchema = new Schema({ + userId: { + type: Schema.Types.ObjectId, + ref: 'User' + }, + title: { + type: String + } + }, { timestamps: true }); + + const UserSchema = new Schema({ + name: String + }, { timestamps: true }); + + UserSchema.virtual('stories', { + ref: 'Story', + localField: '_id', + foreignField: 'userId' + }); + UserSchema.virtual('storiesCount', { + ref: 'Story', + localField: '_id', + foreignField: 'userId', + count: true + }); + + const User = db.model('User', UserSchema); + const Story = db.model('Story', StorySchema); + + const user = await User.create({ name: 'Alex' }); + const story1 = await Story.create({ title: 'Ticket 1', userId: user._id }); + const story2 = await Story.create({ title: 'Ticket 2', userId: user._id }); + + const populated = await User.findOne({ name: 'Alex' }).populate(['stories', 'storiesCount']).lean(); + const hydrated = User.hydrate( + JSON.parse(JSON.stringify(populated)) + ); + + assert.equal(hydrated.stories[0]._id.toString(), story1._id.toString()); + assert(typeof hydrated.stories[0]._id == 'object', typeof hydrated.stories[0]._id); + assert(hydrated.stories[0]._id instanceof mongoose.Types.ObjectId); + assert(typeof hydrated.stories[0].createdAt == 'object'); + assert(hydrated.stories[0].createdAt instanceof Date); + + assert.equal(hydrated.stories[1]._id.toString(), story2._id.toString()); + assert(typeof hydrated.stories[1]._id == 'object'); + + assert(hydrated.stories[1]._id instanceof mongoose.Types.ObjectId); + assert(typeof hydrated.stories[1].createdAt == 'object'); + assert(hydrated.stories[1].createdAt instanceof Date); + + assert.strictEqual(hydrated.storiesCount, 2); + }); }); }); From bd8fa0d7d492dc7d43c305ce19285ed2af4b75d4 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 23 Apr 2024 16:20:27 -0400 Subject: [PATCH 2/2] fix: reuse existing hydratedPopulatedDocs option re: #14503 --- lib/model.js | 3 +-- lib/schema.js | 2 +- test/model.hydrate.test.js | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/model.js b/lib/model.js index b4226e85f7f..d07fe2b89fb 100644 --- a/lib/model.js +++ b/lib/model.js @@ -3959,8 +3959,7 @@ Model.hydrate = function(obj, projection, options) { obj = applyProjection(obj, projection); } const document = require('./queryHelpers').createModel(this, obj, projection); - options = options || {}; - document.$init(obj, { ...options, hydrate: true }); + document.$init(obj, options); return document; }; diff --git a/lib/schema.js b/lib/schema.js index 6005e209bd7..04c631eb799 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2317,7 +2317,7 @@ Schema.prototype.virtual = function(name, options) { _v == null ? [] : [_v]; } - if (opts?.hydrate && !options.count) { + if (opts?.hydratedPopulatedDocs && !options.count) { const modelNames = virtual._getModelNamesForPopulate(this); const populatedVal = this.$$populatedVirtuals[name]; if (!Array.isArray(populatedVal) && !populatedVal.$__ && modelNames?.length === 1) { diff --git a/test/model.hydrate.test.js b/test/model.hydrate.test.js index 40f79464cd9..63947f95961 100644 --- a/test/model.hydrate.test.js +++ b/test/model.hydrate.test.js @@ -153,7 +153,9 @@ describe('model', function() { const populated = await User.findOne({ name: 'Alex' }).populate(['stories', 'storiesCount']).lean(); const hydrated = User.hydrate( - JSON.parse(JSON.stringify(populated)) + JSON.parse(JSON.stringify(populated)), + null, + { hydratedPopulatedDocs: true } ); assert.equal(hydrated.stories[0]._id.toString(), story1._id.toString());