diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js index d767c369578..14e8cafd79f 100644 --- a/lib/helpers/populate/getModelsMapForPopulate.js +++ b/lib/helpers/populate/getModelsMapForPopulate.js @@ -355,114 +355,118 @@ function _virtualPopulate(model, docs, options, _virtualRes) { const virtual = _virtualRes.virtual; for (const doc of docs) { - let modelNames = null; - const data = {}; - - // localField and foreignField - let localField; - const virtualPrefix = _virtualRes.nestedSchemaPath ? - _virtualRes.nestedSchemaPath + '.' : ''; - if (typeof virtual.options.localField === 'function') { - localField = virtualPrefix + virtual.options.localField.call(doc, doc); - } else if (Array.isArray(virtual.options.localField)) { - localField = virtual.options.localField.map(field => virtualPrefix + field); - } else { - localField = virtualPrefix + virtual.options.localField; - } - data.count = virtual.options.count; - - if (virtual.options.skip != null && !options.hasOwnProperty('skip')) { - options.skip = virtual.options.skip; - } - if (virtual.options.limit != null && !options.hasOwnProperty('limit')) { - options.limit = virtual.options.limit; - } - if (virtual.options.perDocumentLimit != null && !options.hasOwnProperty('perDocumentLimit')) { - options.perDocumentLimit = virtual.options.perDocumentLimit; - } - let foreignField = virtual.options.foreignField; + // Use the correct target doc/sub-doc for dynamic ref on nested schema. See gh-12363 + let subDocs = _virtualRes.nestedSchemaPath ? + utils.getValue(_virtualRes.nestedSchemaPath, doc) : doc; + subDocs = Array.isArray(subDocs) ? subDocs : subDocs ? [subDocs] : []; + for (const subDoc of subDocs) { + let modelNames = null; + const data = {}; + + // localField and foreignField + let localField = virtual.options.localField; + const virtualPrefix = _virtualRes.nestedSchemaPath ? + _virtualRes.nestedSchemaPath + '.' : ''; + if (typeof localField === 'function') { + localField = localField.call(subDoc, subDoc); + } + if (Array.isArray(localField)) { + localField = localField.map(field => virtualPrefix + field); + } else { + localField = virtualPrefix + localField; + } + data.count = virtual.options.count; - if (!localField || !foreignField) { - return new MongooseError('If you are populating a virtual, you must set the ' + - 'localField and foreignField options'); - } + if (virtual.options.skip != null && !options.hasOwnProperty('skip')) { + options.skip = virtual.options.skip; + } + if (virtual.options.limit != null && !options.hasOwnProperty('limit')) { + options.limit = virtual.options.limit; + } + if (virtual.options.perDocumentLimit != null && !options.hasOwnProperty('perDocumentLimit')) { + options.perDocumentLimit = virtual.options.perDocumentLimit; + } + let foreignField = virtual.options.foreignField; - if (typeof localField === 'function') { - localField = localField.call(doc, doc); - } - if (typeof foreignField === 'function') { - foreignField = foreignField.call(doc, doc); - } + if (!localField || !foreignField) { + return new MongooseError('If you are populating a virtual, you must set the ' + + 'localField and foreignField options'); + } - data.isRefPath = false; + if (typeof foreignField === 'function') { + foreignField = foreignField.call(subDoc, subDoc); + } - // `justOne = null` means we don't know from the schema whether the end - // result should be an array or a single doc. This can result from - // populating a POJO using `Model.populate()` - let justOne = null; - if ('justOne' in options && options.justOne !== void 0) { - justOne = options.justOne; - } + data.isRefPath = false; - 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 = null` means we don't know from the schema whether the end + // result should be an array or a single doc. This can result from + // populating a POJO using `Model.populate()` + let justOne = null; + if ('justOne' in options && options.justOne !== void 0) { + justOne = options.justOne; } - 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; - data.virtual = virtual; - data.justOne = justOne; + if (virtual.options.refPath) { + modelNames = + modelNamesFromRefPath(virtualPrefix + 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(subDoc, subDoc); + } 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]; + } + } - // `match` - let match = get(options, 'match', null) || - get(data, 'virtual.options.match', null) || - get(data, 'virtual.options.options.match', null); + data.isVirtual = true; + data.virtual = virtual; + data.justOne = justOne; - let hasMatchFunction = typeof match === 'function'; - if (hasMatchFunction) { - match = match.call(doc, doc); - } + // `match` + let match = get(options, 'match', null) || + get(data, 'virtual.options.match', null) || + get(data, 'virtual.options.options.match', null); - if (Array.isArray(localField) && Array.isArray(foreignField) && localField.length === foreignField.length) { - match = Object.assign({}, match); - for (let i = 1; i < localField.length; ++i) { - match[foreignField[i]] = convertTo_id(mpath.get(localField[i], doc, lookupLocalFields), model.schema); - hasMatchFunction = true; + let hasMatchFunction = typeof match === 'function'; + if (hasMatchFunction) { + match = match.call(subDoc, subDoc); } - localField = localField[0]; - foreignField = foreignField[0]; - } + if (Array.isArray(localField) && Array.isArray(foreignField) && localField.length === foreignField.length) { + match = Object.assign({}, match); + for (let i = 1; i < localField.length; ++i) { + match[foreignField[i]] = convertTo_id(mpath.get(localField[i], doc, lookupLocalFields), model.schema); + hasMatchFunction = true; + } - data.localField = localField; - data.foreignField = foreignField; - data.match = match; - data.hasMatchFunction = hasMatchFunction; + localField = localField[0]; + foreignField = foreignField[0]; + } - // Get local fields - const ret = _getLocalFieldValues(doc, localField, model, options, virtual); + data.localField = localField; + data.foreignField = foreignField; + data.match = match; + data.hasMatchFunction = hasMatchFunction; - try { - addModelNamesToMap(model, map, available, modelNames, options, data, ret, doc); - } catch (err) { - return err; + // Get local fields + const ret = _getLocalFieldValues(doc, localField, model, options, virtual); + + try { + addModelNamesToMap(model, map, available, modelNames, options, data, ret, doc); + } catch (err) { + return err; + } } } diff --git a/test/model.populate.test.js b/test/model.populate.test.js index 58c808c2fb1..db6dcccb23b 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -7350,7 +7350,7 @@ describe('model: populate:', function() { clickedSchema.virtual('users_$', { ref: function(doc) { - return doc.events[0].users[0].refKey; + return doc.users[0].refKey; }, localField: 'users.ID', foreignField: 'employeeId' @@ -7414,7 +7414,7 @@ describe('model: populate:', function() { clickedSchema.virtual('users_$', { ref: function(doc) { - const refKeys = doc.events[0].users.map(user => user.refKey); + const refKeys = doc.users.map(user => user.refKey); return refKeys; }, localField: 'users.ID', @@ -7436,6 +7436,15 @@ describe('model: populate:', function() { { ID: 1, refKey: 'User' }, { ID: 2, refKey: 'Author' } ] + }, + { + kind: 'Clicked', + element: '#hero', + message: 'hello', + users: [ + { ID: 2, refKey: 'Author' }, + { ID: 1, refKey: 'User' } + ] } ] }; @@ -7447,6 +7456,8 @@ describe('model: populate:', function() { const doc = await Batch.findOne({}).populate('events.users_$'); assert.strictEqual(doc.events[0].users_$[0].name, 'Test name'); assert.strictEqual(doc.events[0].users_$[1].name, 'Author Name'); + assert.strictEqual(doc.events[1].users_$[0].name, 'Author Name'); + assert.strictEqual(doc.events[1].users_$[1].name, 'Test name'); }); it('uses getter if one is defined on the localField (gh-6618)', async function() { @@ -10829,4 +10840,184 @@ describe('model: populate:', function() { assert.ok(err.message.includes('strictPopulate'), err.message); }); }); + + describe('dynamic virtual populate on nested schema (gh-12363)', function() { + const referredSchemaA = new Schema({ + name: String, + }); + + const referredSchemaB = new Schema({ + name: String, + }); + + const nestedSchema = new Schema({ + name: String, + refType: String, + refId: Schema.Types.ObjectId, + refName: String, + }, { + virtuals: { + dynRef: { + options: { + ref: doc => doc.refType, + localField: 'refId', + foreignField: '_id', + justOne: true, + } + }, + refPath: { + options: { + refPath: 'refType', + localField: 'refId', + foreignField: '_id', + justOne: true, + } + }, + dynRefFields: { + options: { + refPath: 'refType', + localField: doc => doc.refName ? 'refName' : 'refId', + foreignField: doc => doc.refName ? 'name' : '_id', + justOne: true, + } + }, + dynMatch: { + options: { + ref: doc => doc.refType, + localField: 'refId', + foreignField: '_id', + match: doc => ({ name: doc.refName }), + justOne: true, + } + }, + dynRefMultiLocalField: { + options: { + ref: doc => doc.refType, + localField: doc => ['refName', 'refId'], + foreignField: ['name', '_id'], + justOne: true, + } + } + } + }); + + it('populate virtual on sub-document', async function() { + const ReferredA = db.model('ReferredA', referredSchemaA); + const ReferredB = db.model('ReferredB', referredSchemaB); + const referredA1 = await ReferredA.create({ name: 'referredA1' }); + const referredB1 = await ReferredB.create({ name: 'referredB1' }); + + const NestedTest = db.model('NestedTest', new Schema({ + nested: nestedSchema, + })); + const nestedTest1 = new NestedTest({ + nested: { + refType: 'ReferredA', + refId: referredA1._id, + } + }); + await nestedTest1.populate('nested.dynRef'); + await nestedTest1.populate('nested.refPath'); + await nestedTest1.populate('nested.dynRefFields'); + assert.equal(nestedTest1.nested.dynRef.name, referredA1.name); + assert.equal(nestedTest1.nested.refPath.name, referredA1.name); + assert.equal(nestedTest1.nested.dynRefFields.name, referredA1.name); + + const nestedTest2 = new NestedTest({ + nested: { + refType: 'ReferredB', + refId: referredB1._id, + refName: referredB1.name, + } + }); + await nestedTest2.populate(['nested.dynRef', 'nested.refPath', 'nested.dynMatch', 'nested.dynRefMultiLocalField']); + assert.equal(nestedTest2.nested.dynRef.name, referredB1.name); + assert.equal(nestedTest2.nested.refPath.name, referredB1.name); + assert.equal(nestedTest2.nested.dynMatch.name, referredB1.name); + assert.equal(nestedTest2.nested.dynRefMultiLocalField.name, referredB1.name); + + const nestedTest3 = new NestedTest({}); + await nestedTest3.populate('nested.dynRef'); + assert.equal(nestedTest3.nested?.dynRef, undefined); + }); + + it('populate virtual on sub-document array', async function() { + const ReferredA = db.model('ReferredA', referredSchemaA); + const ReferredB = db.model('ReferredB', referredSchemaB); + const referredA1 = await ReferredA.create({ name: 'referredA1' }); + const referredB1 = await ReferredB.create({ name: 'referredB1' }); + + const NestedTest = db.model('NestedTest', new Schema({ + nested: [nestedSchema], + })); + const nestedTest1 = new NestedTest({ + nested: [{ + refType: 'ReferredA', + refId: referredA1._id, + }, { + refType: 'ReferredA', + refId: referredA1._id, + refName: referredA1.name, + }, { + refType: 'ReferredB', + refId: referredB1._id, + refName: referredB1.name, + }] + }); + await nestedTest1.populate([ + 'nested.dynRef', + 'nested.refPath', + ]); + assert.equal(nestedTest1.nested[0].dynRef.name, referredA1.name); + assert.equal(nestedTest1.nested[0].refPath.name, referredA1.name); + assert.equal(nestedTest1.nested[1].dynRef.name, referredA1.name); + assert.equal(nestedTest1.nested[1].refPath.name, referredA1.name); + assert.equal(nestedTest1.nested[2].dynRef.name, referredB1.name); + assert.equal(nestedTest1.nested[2].refPath.name, referredB1.name); + }); + + it('populate virtual on deeply nested sub-document', async function() { + const ReferredA = db.model('ReferredA', referredSchemaA); + const ReferredB = db.model('ReferredB', referredSchemaB); + const referredA1 = await ReferredA.create({ name: 'referredA1' }); + const referredA2 = await ReferredA.create({ name: 'referredA2' }); + const referredB1 = await ReferredB.create({ name: 'referredB1' }); + + const NestedTest = db.model('NestedTest', new Schema({ + nested1: [new Schema({ + nested2: nestedSchema, + })], + })); + const nestedTest1 = new NestedTest({ + nested1: [{ + nested2: { + refType: 'ReferredA', + refId: referredA1._id, + } + }, { + nested2: { + refType: 'ReferredA', + refId: referredA2._id, + refName: referredA2.name, + } + }, { + nested2: { + refType: 'ReferredB', + refId: referredB1._id, + refName: referredB1.name, + } + }] + }); + await nestedTest1.populate([ + 'nested1.nested2.dynRef', + 'nested1.nested2.refPath', + ]); + assert.equal(nestedTest1.nested1[0].nested2.dynRef.name, referredA1.name); + assert.equal(nestedTest1.nested1[0].nested2.refPath.name, referredA1.name); + assert.equal(nestedTest1.nested1[1].nested2.dynRef.name, referredA2.name); + assert.equal(nestedTest1.nested1[1].nested2.refPath.name, referredA2.name); + assert.equal(nestedTest1.nested1[2].nested2.dynRef.name, referredB1.name); + assert.equal(nestedTest1.nested1[2].nested2.refPath.name, referredB1.name); + }); + }); });