Skip to content

Commit a4e3308

Browse files
authored
Merge pull request #14905 from Automattic/vkarpov15/gh-14818-2
feat(model): add `Model.applyVirtuals()` to apply virtuals to a POJO
2 parents adb4fb0 + 54844e3 commit a4e3308

File tree

4 files changed

+346
-0
lines changed

4 files changed

+346
-0
lines changed

lib/helpers/document/applyVirtuals.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
'use strict';
2+
3+
const mpath = require('mpath');
4+
5+
module.exports = applyVirtuals;
6+
7+
/**
8+
* Apply a given schema's virtuals to a given POJO
9+
*
10+
* @param {Schema} schema
11+
* @param {Object} obj
12+
* @param {Array<string>} [virtuals] optional whitelist of virtuals to apply
13+
* @returns
14+
*/
15+
16+
function applyVirtuals(schema, obj, virtuals) {
17+
if (obj == null) {
18+
return obj;
19+
}
20+
21+
let virtualsForChildren = virtuals;
22+
let toApply = null;
23+
24+
if (Array.isArray(virtuals)) {
25+
virtualsForChildren = [];
26+
toApply = [];
27+
for (const virtual of virtuals) {
28+
if (virtual.length === 1) {
29+
toApply.push(virtual[0]);
30+
} else {
31+
virtualsForChildren.push(virtual);
32+
}
33+
}
34+
}
35+
36+
applyVirtualsToChildren(schema, obj, virtualsForChildren);
37+
return applyVirtualsToDoc(schema, obj, toApply);
38+
}
39+
40+
/**
41+
* Apply virtuals to any subdocuments
42+
*
43+
* @param {Schema} schema subdocument schema
44+
* @param {Object} res subdocument
45+
* @param {Array<String>} [virtuals] optional whitelist of virtuals to apply
46+
*/
47+
48+
function applyVirtualsToChildren(schema, res, virtuals) {
49+
let attachedVirtuals = false;
50+
for (const childSchema of schema.childSchemas) {
51+
const _path = childSchema.model.path;
52+
const _schema = childSchema.schema;
53+
if (!_path) {
54+
continue;
55+
}
56+
const _obj = mpath.get(_path, res);
57+
if (_obj == null || (Array.isArray(_obj) && _obj.flat(Infinity).length === 0)) {
58+
continue;
59+
}
60+
61+
let virtualsForChild = null;
62+
if (Array.isArray(virtuals)) {
63+
virtualsForChild = [];
64+
for (const virtual of virtuals) {
65+
if (virtual[0] == _path) {
66+
virtualsForChild.push(virtual.slice(1));
67+
}
68+
}
69+
70+
if (virtualsForChild.length === 0) {
71+
continue;
72+
}
73+
}
74+
75+
applyVirtuals(_schema, _obj, virtualsForChild);
76+
attachedVirtuals = true;
77+
}
78+
79+
if (virtuals && virtuals.length && !attachedVirtuals) {
80+
applyVirtualsToDoc(schema, res, virtuals);
81+
}
82+
}
83+
84+
/**
85+
* Apply virtuals to a given document. Does not apply virtuals to subdocuments: use `applyVirtualsToChildren` instead
86+
*
87+
* @param {Schema} schema
88+
* @param {Object} doc
89+
* @param {Array<String>} [virtuals] optional whitelist of virtuals to apply
90+
* @returns
91+
*/
92+
93+
function applyVirtualsToDoc(schema, obj, virtuals) {
94+
if (obj == null || typeof obj !== 'object') {
95+
return;
96+
}
97+
if (Array.isArray(obj)) {
98+
for (const el of obj) {
99+
applyVirtualsToDoc(schema, el, virtuals);
100+
}
101+
return;
102+
}
103+
104+
if (schema.discriminators && Object.keys(schema.discriminators).length > 0) {
105+
for (const discriminatorKey of Object.keys(schema.discriminators)) {
106+
const discriminator = schema.discriminators[discriminatorKey];
107+
const key = discriminator.discriminatorMapping.key;
108+
const value = discriminator.discriminatorMapping.value;
109+
if (obj[key] == value) {
110+
schema = discriminator;
111+
break;
112+
}
113+
}
114+
}
115+
116+
if (virtuals == null) {
117+
virtuals = Object.keys(schema.virtuals);
118+
}
119+
for (const virtual of virtuals) {
120+
if (schema.virtuals[virtual] == null) {
121+
continue;
122+
}
123+
const virtualType = schema.virtuals[virtual];
124+
const sp = Array.isArray(virtual)
125+
? virtual
126+
: virtual.indexOf('.') === -1
127+
? [virtual]
128+
: virtual.split('.');
129+
let cur = obj;
130+
for (let i = 0; i < sp.length - 1; ++i) {
131+
cur[sp[i]] = sp[i] in cur ? cur[sp[i]] : {};
132+
cur = cur[sp[i]];
133+
}
134+
let val = virtualType.applyGetters(cur[sp[sp.length - 1]], obj);
135+
const isPopulateVirtual =
136+
virtualType.options && (virtualType.options.ref || virtualType.options.refPath);
137+
if (isPopulateVirtual && val === undefined) {
138+
if (virtualType.options.justOne) {
139+
val = null;
140+
} else {
141+
val = [];
142+
}
143+
}
144+
cur[sp[sp.length - 1]] = val;
145+
}
146+
}

lib/model.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const applySchemaCollation = require('./helpers/indexes/applySchemaCollation');
3131
const applyStaticHooks = require('./helpers/model/applyStaticHooks');
3232
const applyStatics = require('./helpers/model/applyStatics');
3333
const applyWriteConcern = require('./helpers/schema/applyWriteConcern');
34+
const applyVirtualsHelper = require('./helpers/document/applyVirtuals');
3435
const assignVals = require('./helpers/populate/assignVals');
3536
const castBulkWrite = require('./helpers/model/castBulkWrite');
3637
const clone = require('./helpers/clone');
@@ -3488,6 +3489,9 @@ function handleSuccessfulWrite(document) {
34883489
*/
34893490

34903491
Model.applyDefaults = function applyDefaults(doc) {
3492+
if (doc == null) {
3493+
return doc;
3494+
}
34913495
if (doc.$__ != null) {
34923496
applyDefaultsHelper(doc, doc.$__.fields, doc.$__.exclude);
34933497

@@ -3503,6 +3507,40 @@ Model.applyDefaults = function applyDefaults(doc) {
35033507
return doc;
35043508
};
35053509

3510+
/**
3511+
* Apply this model's virtuals to a given POJO. Virtuals execute with the POJO as the context `this`.
3512+
*
3513+
* #### Example:
3514+
*
3515+
* const userSchema = new Schema({ name: String });
3516+
* userSchema.virtual('upper').get(function() { return this.name.toUpperCase(); });
3517+
* const User = mongoose.model('User', userSchema);
3518+
*
3519+
* const obj = { name: 'John' };
3520+
* User.applyVirtuals(obj);
3521+
* obj.name; // 'John'
3522+
* obj.upper; // 'JOHN', Mongoose applied the return value of the virtual to the given object
3523+
*
3524+
* @param {Object} obj object or document to apply virtuals on
3525+
* @param {Array<string>} [virtualsToApply] optional whitelist of virtuals to apply
3526+
* @returns {Object} obj
3527+
* @api public
3528+
*/
3529+
3530+
Model.applyVirtuals = function applyVirtuals(obj, virtualsToApply) {
3531+
if (obj == null) {
3532+
return obj;
3533+
}
3534+
// Nothing to do if this is already a hydrated document - it should already have virtuals
3535+
if (obj.$__ != null) {
3536+
return obj;
3537+
}
3538+
3539+
applyVirtualsHelper(this.schema, obj, virtualsToApply);
3540+
3541+
return obj;
3542+
};
3543+
35063544
/**
35073545
* Cast the given POJO to the model's schema
35083546
*

test/model.test.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7859,6 +7859,165 @@ describe('Model', function() {
78597859
docs = await User.find();
78607860
assert.deepStrictEqual(docs.map(doc => doc.age), [12, 12]);
78617861
});
7862+
7863+
describe('applyVirtuals', function() {
7864+
it('handles basic top-level virtuals', async function() {
7865+
const userSchema = new Schema({
7866+
name: String
7867+
});
7868+
userSchema.virtual('lowercase').get(function() {
7869+
return this.name.toLowerCase();
7870+
});
7871+
userSchema.virtual('uppercase').get(function() {
7872+
return this.name.toUpperCase();
7873+
});
7874+
const User = db.model('User', userSchema);
7875+
7876+
const res = User.applyVirtuals({ name: 'Taco' });
7877+
assert.equal(res.name, 'Taco');
7878+
assert.equal(res.lowercase, 'taco');
7879+
assert.equal(res.uppercase, 'TACO');
7880+
});
7881+
7882+
it('handles virtuals in subdocuments', async function() {
7883+
const userSchema = new Schema({
7884+
name: String
7885+
});
7886+
userSchema.virtual('lowercase').get(function() {
7887+
return this.name.toLowerCase();
7888+
});
7889+
userSchema.virtual('uppercase').get(function() {
7890+
return this.name.toUpperCase();
7891+
});
7892+
const groupSchema = new Schema({
7893+
name: String,
7894+
leader: userSchema,
7895+
members: [userSchema]
7896+
});
7897+
const Group = db.model('Group', groupSchema);
7898+
7899+
const res = Group.applyVirtuals({
7900+
name: 'Microsoft',
7901+
leader: { name: 'Bill' },
7902+
members: [{ name: 'John' }, { name: 'Steve' }]
7903+
});
7904+
assert.equal(res.name, 'Microsoft');
7905+
assert.equal(res.leader.name, 'Bill');
7906+
assert.equal(res.leader.uppercase, 'BILL');
7907+
assert.equal(res.leader.lowercase, 'bill');
7908+
assert.equal(res.members[0].name, 'John');
7909+
assert.equal(res.members[0].uppercase, 'JOHN');
7910+
assert.equal(res.members[0].lowercase, 'john');
7911+
assert.equal(res.members[1].name, 'Steve');
7912+
assert.equal(res.members[1].uppercase, 'STEVE');
7913+
assert.equal(res.members[1].lowercase, 'steve');
7914+
});
7915+
7916+
it('handles virtuals on nested paths', async function() {
7917+
const userSchema = new Schema({
7918+
name: {
7919+
first: String,
7920+
last: String
7921+
}
7922+
});
7923+
userSchema.virtual('name.firstUpper').get(function() {
7924+
return this.name.first.toUpperCase();
7925+
});
7926+
userSchema.virtual('name.lastLower').get(function() {
7927+
return this.name.last.toLowerCase();
7928+
});
7929+
const User = db.model('User', userSchema);
7930+
7931+
const res = User.applyVirtuals({
7932+
name: {
7933+
first: 'Bill',
7934+
last: 'Gates'
7935+
}
7936+
});
7937+
assert.equal(res.name.first, 'Bill');
7938+
assert.equal(res.name.last, 'Gates');
7939+
assert.equal(res.name.firstUpper, 'BILL');
7940+
assert.equal(res.name.lastLower, 'gates');
7941+
});
7942+
7943+
it('supports passing an array of virtuals to apply', async function() {
7944+
const userSchema = new Schema({
7945+
name: {
7946+
first: String,
7947+
last: String
7948+
}
7949+
});
7950+
userSchema.virtual('fullName').get(function() {
7951+
return `${this.name.first} ${this.name.last}`;
7952+
});
7953+
userSchema.virtual('name.firstUpper').get(function() {
7954+
return this.name.first.toUpperCase();
7955+
});
7956+
userSchema.virtual('name.lastLower').get(function() {
7957+
return this.name.last.toLowerCase();
7958+
});
7959+
const User = db.model('User', userSchema);
7960+
7961+
let res = User.applyVirtuals({
7962+
name: {
7963+
first: 'Bill',
7964+
last: 'Gates'
7965+
}
7966+
}, ['fullName', 'name.firstUpper']);
7967+
assert.strictEqual(res.name.first, 'Bill');
7968+
assert.strictEqual(res.name.last, 'Gates');
7969+
assert.strictEqual(res.fullName, 'Bill Gates');
7970+
assert.strictEqual(res.name.firstUpper, 'BILL');
7971+
assert.strictEqual(res.name.lastLower, undefined);
7972+
7973+
res = User.applyVirtuals({
7974+
name: {
7975+
first: 'Bill',
7976+
last: 'Gates'
7977+
}
7978+
}, ['name.lastLower']);
7979+
assert.strictEqual(res.name.first, 'Bill');
7980+
assert.strictEqual(res.name.last, 'Gates');
7981+
assert.strictEqual(res.fullName, undefined);
7982+
assert.strictEqual(res.name.firstUpper, undefined);
7983+
assert.strictEqual(res.name.lastLower, 'gates');
7984+
});
7985+
7986+
it('sets populate virtuals to `null` if `justOne`', async function() {
7987+
const userSchema = new Schema({
7988+
name: {
7989+
first: String,
7990+
last: String
7991+
},
7992+
friendId: {
7993+
type: 'ObjectId'
7994+
}
7995+
});
7996+
userSchema.virtual('fullName').get(function() {
7997+
return `${this.name.first} ${this.name.last}`;
7998+
});
7999+
userSchema.virtual('friend', {
8000+
ref: 'User',
8001+
localField: 'friendId',
8002+
foreignField: '_id',
8003+
justOne: true
8004+
});
8005+
const User = db.model('User', userSchema);
8006+
8007+
const friendId = new mongoose.Types.ObjectId();
8008+
const res = User.applyVirtuals({
8009+
name: {
8010+
first: 'Bill',
8011+
last: 'Gates'
8012+
},
8013+
friendId
8014+
});
8015+
assert.strictEqual(res.name.first, 'Bill');
8016+
assert.strictEqual(res.name.last, 'Gates');
8017+
assert.strictEqual(res.fullName, 'Bill Gates');
8018+
assert.strictEqual(res.friend, null);
8019+
});
8020+
});
78628021
});
78638022

78648023

types/models.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,9 @@ declare module 'mongoose' {
290290
applyDefaults(obj: AnyObject): AnyObject;
291291
applyDefaults(obj: TRawDocType): TRawDocType;
292292

293+
/* Apply virtuals to the given POJO. */
294+
applyVirtuals(obj: AnyObject, virtalsToApply?: string[]): AnyObject;
295+
293296
/**
294297
* Sends multiple `insertOne`, `updateOne`, `updateMany`, `replaceOne`,
295298
* `deleteOne`, and/or `deleteMany` operations to the MongoDB server in one

0 commit comments

Comments
 (0)