From baf94c93007b827aad49530a85282e1cc300c579 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Jul 2026 15:45:12 +0300 Subject: [PATCH 1/5] test(model): add tests for parent-level dotted virtuals with hydrate() virtuals option (gh-15627) --- test/model.test.js | 102 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/test/model.test.js b/test/model.test.js index 3a855029bc5..a2d2843bae3 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -8619,6 +8619,108 @@ describe('Model', function() { ); }); + it('supports parent-level dotted virtuals on document arrays for `hydrate()` (gh-15627)', function() { + const itemSchema = new Schema({ name: String }); + const orderSchema = new Schema({ items: [itemSchema] }); + orderSchema.virtual('items.detail'); + + const Order = db.model('Order', orderSchema); + + const raw = { items: [{ name: 'Keyboard', detail: 'mechanical' }, { name: 'Mouse', detail: 'wireless' }] }; + + const withoutVirtuals = Order.hydrate(raw); + assert.equal(withoutVirtuals.items[0].name, 'Keyboard'); + assert.strictEqual(withoutVirtuals.items[0].detail, undefined); + + const doc = Order.hydrate(raw, undefined, { virtuals: true }); + assert.equal(doc.items.length, 2); + assert.equal(doc.items[0].name, 'Keyboard'); + assert.equal(doc.items[0].detail, 'mechanical'); + assert.equal(doc.items[1].name, 'Mouse'); + assert.equal(doc.items[1].detail, 'wireless'); + assert.strictEqual(doc.items[0].toObject({ virtuals: true }).detail, 'mechanical'); + }); + + it('supports parent-level dotted virtuals on single nested subdocuments for `hydrate()` (gh-15627)', function() { + const addressSchema = new Schema({ city: String }); + const customerSchema = new Schema({ address: addressSchema }); + customerSchema.virtual('address.detail'); + + const Customer = db.model('Customer', customerSchema); + + const raw = { address: { city: 'Cairo', detail: 'near the river' } }; + + const withoutVirtuals = Customer.hydrate(raw); + assert.equal(withoutVirtuals.address.city, 'Cairo'); + assert.strictEqual(withoutVirtuals.address.detail, undefined); + + const doc = Customer.hydrate(raw, undefined, { virtuals: true }); + assert.equal(doc.address.city, 'Cairo'); + assert.equal(doc.address.detail, 'near the river'); + assert.strictEqual(doc.address.toObject({ virtuals: true }).detail, 'near the river'); + }); + + it('supports parent-level dotted virtuals on maps of subdocuments for `hydrate()` (gh-15627)', function() { + const entrySchema = new Schema({ value: String }, { _id: false }); + const reportSchema = new Schema({ + entries: { + type: Map, + of: entrySchema + } + }); + reportSchema.virtual('entries.$*.detail'); + + const Report = db.model('Report', reportSchema); + + const raw = { + entries: { + first: { value: 'ab', detail: 'first detail' }, + second: { value: 'cd', detail: 'second detail' } + } + }; + + const withoutVirtuals = Report.hydrate(raw); + assert.equal(withoutVirtuals.entries.get('first').value, 'ab'); + assert.strictEqual(withoutVirtuals.entries.get('first').detail, undefined); + + const doc = Report.hydrate(raw, undefined, { virtuals: true }); + assert.equal(doc.entries.get('first').value, 'ab'); + assert.equal(doc.entries.get('first').detail, 'first detail'); + assert.equal(doc.entries.get('second').value, 'cd'); + assert.equal(doc.entries.get('second').detail, 'second detail'); + assert.strictEqual(doc.entries.get('first').toObject({ virtuals: true }).detail, 'first detail'); + }); + + it('keeps child schema paths and virtuals intact with parent-level dotted virtuals for `hydrate()` (gh-15627)', function() { + const itemSchema = new Schema({ name: String }); + itemSchema.virtual('nameUpper').get(function() { + return this.name.toUpperCase(); + }); + + const orderSchema = new Schema({ items: [itemSchema] }); + // Same name as an existing child virtual: must not clobber the child getter + orderSchema.virtual('items.nameUpper'); + orderSchema.virtual('items.detail'); + + // Same name as a real child path: still rejected + assert.throws( + () => orderSchema.virtual('items.name'), + /conflicts with a real path/ + ); + + const Order = db.model('Order', orderSchema); + + const doc = Order.hydrate( + { items: [{ name: 'Keyboard', detail: 'mechanical' }] }, + undefined, + { virtuals: true } + ); + + assert.equal(doc.items[0].name, 'Keyboard'); + assert.equal(doc.items[0].nameUpper, 'KEYBOARD'); + assert.equal(doc.items[0].detail, 'mechanical'); + }); + it('sets index collation based on schema collation (gh-7621)', async function() { let testSchema = new Schema( { name: { type: String, index: true } } From b41d8ea2c1c8b547dcbb3c0d34c190d890097b1d Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Jul 2026 15:45:12 +0300 Subject: [PATCH 2/5] fix(schema): mirror parent-level dotted regular virtuals onto embedded schemas (gh-15627) --- lib/schema.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/schema.js b/lib/schema.js index a56bd13f10f..6f717f1a087 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2666,6 +2666,28 @@ Schema.prototype.virtual = function(name, options) { } } + // If virtual is under a document array, single nested subdocument, or map, + // mirror it onto the embedded schema so subdocuments can use it, matching + // the populate virtual handling above. See gh-8198, gh-15627 + let cur = parts[0]; + for (let i = 0; i < parts.length - 1; ++i) { + const schemaType = this.paths[cur]; + if (schemaType != null && (schemaType.$isMongooseDocumentArray || schemaType.$isSingleNested)) { + const remnant = parts.slice(i + 1).join('.'); + if (schemaType.schema.pathType(remnant) === 'adhocOrUndefined') { + schemaType.schema.virtual(remnant, options); + } + break; + } else if (schemaType != null && schemaType.$isSchemaMap && parts[i + 1] === '$*' && schemaType.$__schemaType.schema != null) { + const remnant = parts.slice(i + 2).join('.'); + if (remnant.length > 0 && schemaType.$__schemaType.schema.pathType(remnant) === 'adhocOrUndefined') { + schemaType.$__schemaType.schema.virtual(remnant, options); + } + break; + } + cur += '.' + parts[i + 1]; + } + return virtuals[name]; }; From 0ba0908e3639f6d69798d40aabda8808defbe1f5 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Jul 2026 17:54:43 +0300 Subject: [PATCH 3/5] test: add coverage for dotted virtual mirroring edge cases (gh-15627) --- test/model.populate.test.js | 32 ++++++++++++++++++++++++++++++ test/model.test.js | 22 +++++++++++++++++++++ test/schema.test.js | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/test/model.populate.test.js b/test/model.populate.test.js index 5514c174799..9629ac3d9d9 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -8191,6 +8191,38 @@ describe('model: populate:', function() { assert.equal(asObject.child.bars[0].name, 'bar'); }); + it('accessing populate virtual prop under a nested object (gh-13189) (gh-8198)', async function() { + const FooSchema = new Schema({ + name: String, + nested: { + children: [{ + barId: { type: Schema.Types.ObjectId, ref: 'Test' }, + quantity: Number + }] + } + }); + FooSchema.virtual('nested.children.bar', { + ref: 'Test', + localField: 'nested.children.barId', + foreignField: '_id', + justOne: true + }); + const BarSchema = Schema({ name: String }); + const Foo = db.model('Test1', FooSchema); + const Bar = db.model('Test', BarSchema); + + const bar = await Bar.create({ name: 'bar' }); + const foo = await Foo.create({ + name: 'foo', + nested: { children: [{ barId: bar._id, quantity: 1 }] } + }); + const foo2 = await Foo.findById(foo._id).populate('nested.children.bar'); + assert.equal(foo2.nested.children[0].bar.name, 'bar'); + + const asObject = foo2.toObject({ virtuals: true }); + assert.equal(asObject.nested.children[0].bar.name, 'bar'); + }); + describe('gh-8247', function() { let Author; let Page; diff --git a/test/model.test.js b/test/model.test.js index a2d2843bae3..a5107ff0656 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -8691,6 +8691,28 @@ describe('Model', function() { assert.strictEqual(doc.entries.get('first').toObject({ virtuals: true }).detail, 'first detail'); }); + it('supports parent-level dotted virtuals under nested objects for `hydrate()` (gh-15627)', function() { + const itemSchema = new Schema({ name: String }); + const orderSchema = new Schema({ + meta: { + items: [itemSchema] + } + }); + orderSchema.virtual('meta.items.detail'); + + const Order = db.model('Order', orderSchema); + + const raw = { meta: { items: [{ name: 'Keyboard', detail: 'mechanical' }] } }; + + const withoutVirtuals = Order.hydrate(raw); + assert.equal(withoutVirtuals.meta.items[0].name, 'Keyboard'); + assert.strictEqual(withoutVirtuals.meta.items[0].detail, undefined); + + const doc = Order.hydrate(raw, undefined, { virtuals: true }); + assert.equal(doc.meta.items[0].name, 'Keyboard'); + assert.equal(doc.meta.items[0].detail, 'mechanical'); + }); + it('keeps child schema paths and virtuals intact with parent-level dotted virtuals for `hydrate()` (gh-15627)', function() { const itemSchema = new Schema({ name: String }); itemSchema.virtual('nameUpper').get(function() { diff --git a/test/schema.test.js b/test/schema.test.js index 9511cb2782f..6a65a189d36 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -1146,6 +1146,45 @@ describe('schema', function() { }); + it('does not throw when declaring a dotted populate virtual under a map of primitives (gh-15627)', function() { + const schema = new Schema({ + tags: { + type: Map, + of: String + } + }); + + schema.virtual('tags.$*.owner', { + ref: 'User', + localField: 'tags.$*.ownerId', + foreignField: '_id', + justOne: true + }); + + assert.ok(schema.virtualpath('tags.$*.owner') instanceof VirtualType); + }); + + it('rejects dotted populate virtuals under a map without `$*` because map keys are real paths (gh-15627)', function() { + const entrySchema = new Schema({ ownerId: 'ObjectId' }, { _id: false }); + const schema = new Schema({ + entries: { + type: Map, + of: entrySchema + } + }); + + assert.throws( + () => schema.virtual('entries.owner', { + ref: 'User', + localField: 'entries.ownerId', + foreignField: '_id', + justOne: true + }), + /conflicts with a real path/ + ); + assert.strictEqual(entrySchema.virtualpath(''), null); + }); + describe('id', function() { it('default creation of id can be overridden (gh-298)', function() { assert.doesNotThrow(function() { From c3e121bcff0602bf80654c4bb5de6338325d253c Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Jul 2026 17:54:43 +0300 Subject: [PATCH 4/5] fix(schema): share embedded schema lookup for dotted virtuals, guard maps of primitives (gh-15627) --- lib/schema.js | 63 ++++++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index 6f717f1a087..0f64e0debf0 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2619,24 +2619,9 @@ Schema.prototype.virtual = function(name, options) { // Workaround for gh-8198: if virtual is under document array, make a fake // virtual. See gh-8210, gh-13189 - const parts = name.split('.'); - let cur = parts[0]; - for (let i = 0; i < parts.length - 1; ++i) { - if (this.paths[cur] == null) { - continue; - } - - if (this.paths[cur].$isMongooseDocumentArray || this.paths[cur].$isSingleNested) { - const remnant = parts.slice(i + 1).join('.'); - this.paths[cur].schema.virtual(remnant, options); - break; - } else if (this.paths[cur].$isSchemaMap) { - const remnant = parts.slice(i + 2).join('.'); - this.paths[cur].$__schemaType.schema.virtual(remnant, options); - break; - } - - cur += '.' + parts[i + 1]; + const embedded = getEmbeddedSchemaVirtualTarget(this, name.split('.')); + if (embedded != null) { + embedded.childSchema.virtual(embedded.remnant, options); } return virtual; @@ -2669,27 +2654,43 @@ Schema.prototype.virtual = function(name, options) { // If virtual is under a document array, single nested subdocument, or map, // mirror it onto the embedded schema so subdocuments can use it, matching // the populate virtual handling above. See gh-8198, gh-15627 + const embedded = getEmbeddedSchemaVirtualTarget(this, parts); + if (embedded != null && embedded.childSchema.pathType(embedded.remnant) === 'adhocOrUndefined') { + embedded.childSchema.virtual(embedded.remnant, options); + } + + return virtuals[name]; +}; + +/** + * Find the embedded schema that a dotted virtual `name` should be mirrored + * onto: the child schema of the first document array, single nested + * subdocument, or map of subdocuments along the virtual's path, along with + * the remaining virtual name relative to that schema. Returns `null` if the + * path doesn't cross an embedded schema. + * + * @param {Schema} schema + * @param {Array} parts the virtual name split on `.` + * @return {Object|null} `{ childSchema, remnant }` + * @api private + */ + +function getEmbeddedSchemaVirtualTarget(schema, parts) { let cur = parts[0]; for (let i = 0; i < parts.length - 1; ++i) { - const schemaType = this.paths[cur]; + const schemaType = schema.paths[cur]; if (schemaType != null && (schemaType.$isMongooseDocumentArray || schemaType.$isSingleNested)) { - const remnant = parts.slice(i + 1).join('.'); - if (schemaType.schema.pathType(remnant) === 'adhocOrUndefined') { - schemaType.schema.virtual(remnant, options); - } - break; - } else if (schemaType != null && schemaType.$isSchemaMap && parts[i + 1] === '$*' && schemaType.$__schemaType.schema != null) { + return { childSchema: schemaType.schema, remnant: parts.slice(i + 1).join('.') }; + } + if (schemaType != null && schemaType.$isSchemaMap && parts[i + 1] === '$*' && schemaType.$__schemaType.schema != null) { const remnant = parts.slice(i + 2).join('.'); - if (remnant.length > 0 && schemaType.$__schemaType.schema.pathType(remnant) === 'adhocOrUndefined') { - schemaType.$__schemaType.schema.virtual(remnant, options); - } - break; + return remnant === '' ? null : { childSchema: schemaType.$__schemaType.schema, remnant }; } cur += '.' + parts[i + 1]; } - return virtuals[name]; -}; + return null; +} /** * Returns the virtual type with the given `name`. From a1ec2fd8ac3fee9bd144ae15f2c8a9200241c71a Mon Sep 17 00:00:00 2001 From: Hafez Date: Sat, 18 Jul 2026 18:04:48 +0300 Subject: [PATCH 5/5] test(schema): assert no virtuals leak onto map child schema for rejected dotted virtual (gh-15627) --- test/schema.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/schema.test.js b/test/schema.test.js index 6a65a189d36..536c933bb0b 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -1182,7 +1182,7 @@ describe('schema', function() { }), /conflicts with a real path/ ); - assert.strictEqual(entrySchema.virtualpath(''), null); + assert.deepStrictEqual(Object.keys(entrySchema.virtuals), []); }); describe('id', function() {