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/schema.js b/lib/schema.js index 9cb1602e64f..04c631eb799 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?.hydratedPopulatedDocs && !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..63947f95961 100644 --- a/test/model.hydrate.test.js +++ b/test/model.hydrate.test.js @@ -117,5 +117,61 @@ 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)), + null, + { hydratedPopulatedDocs: true } + ); + + 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); + }); }); });