Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 41 additions & 18 deletions lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2666,9 +2651,47 @@ 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<String>} 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 = schema.paths[cur];
if (schemaType != null && (schemaType.$isMongooseDocumentArray || schemaType.$isSingleNested)) {
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('.');
return remnant === '' ? null : { childSchema: schemaType.$__schemaType.schema, remnant };
}
cur += '.' + parts[i + 1];
}

return null;
}

/**
* Returns the virtual type with the given `name`.
*
Expand Down
32 changes: 32 additions & 0 deletions test/model.populate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
124 changes: 124 additions & 0 deletions test/model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8619,6 +8619,130 @@ 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('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() {
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 } }
Expand Down
39 changes: 39 additions & 0 deletions test/schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.deepStrictEqual(Object.keys(entrySchema.virtuals), []);
});

describe('id', function() {
it('default creation of id can be overridden (gh-298)', function() {
assert.doesNotThrow(function() {
Expand Down
Loading