diff --git a/lib/helpers/document/applyVirtuals.js b/lib/helpers/document/applyVirtuals.js new file mode 100644 index 00000000000..5fbe7ca82ba --- /dev/null +++ b/lib/helpers/document/applyVirtuals.js @@ -0,0 +1,146 @@ +'use strict'; + +const mpath = require('mpath'); + +module.exports = applyVirtuals; + +/** + * Apply a given schema's virtuals to a given POJO + * + * @param {Schema} schema + * @param {Object} obj + * @param {Array} [virtuals] optional whitelist of virtuals to apply + * @returns + */ + +function applyVirtuals(schema, obj, virtuals) { + if (obj == null) { + return obj; + } + + let virtualsForChildren = virtuals; + let toApply = null; + + if (Array.isArray(virtuals)) { + virtualsForChildren = []; + toApply = []; + for (const virtual of virtuals) { + if (virtual.length === 1) { + toApply.push(virtual[0]); + } else { + virtualsForChildren.push(virtual); + } + } + } + + applyVirtualsToChildren(schema, obj, virtualsForChildren); + return applyVirtualsToDoc(schema, obj, toApply); +} + +/** + * Apply virtuals to any subdocuments + * + * @param {Schema} schema subdocument schema + * @param {Object} res subdocument + * @param {Array} [virtuals] optional whitelist of virtuals to apply + */ + +function applyVirtualsToChildren(schema, res, virtuals) { + let attachedVirtuals = false; + for (const childSchema of schema.childSchemas) { + const _path = childSchema.model.path; + const _schema = childSchema.schema; + if (!_path) { + continue; + } + const _obj = mpath.get(_path, res); + if (_obj == null || (Array.isArray(_obj) && _obj.flat(Infinity).length === 0)) { + continue; + } + + let virtualsForChild = null; + if (Array.isArray(virtuals)) { + virtualsForChild = []; + for (const virtual of virtuals) { + if (virtual[0] == _path) { + virtualsForChild.push(virtual.slice(1)); + } + } + + if (virtualsForChild.length === 0) { + continue; + } + } + + applyVirtuals(_schema, _obj, virtualsForChild); + attachedVirtuals = true; + } + + if (virtuals && virtuals.length && !attachedVirtuals) { + applyVirtualsToDoc(schema, res, virtuals); + } +} + +/** + * Apply virtuals to a given document. Does not apply virtuals to subdocuments: use `applyVirtualsToChildren` instead + * + * @param {Schema} schema + * @param {Object} doc + * @param {Array} [virtuals] optional whitelist of virtuals to apply + * @returns + */ + +function applyVirtualsToDoc(schema, obj, virtuals) { + if (obj == null || typeof obj !== 'object') { + return; + } + if (Array.isArray(obj)) { + for (const el of obj) { + applyVirtualsToDoc(schema, el, virtuals); + } + return; + } + + if (schema.discriminators && Object.keys(schema.discriminators).length > 0) { + for (const discriminatorKey of Object.keys(schema.discriminators)) { + const discriminator = schema.discriminators[discriminatorKey]; + const key = discriminator.discriminatorMapping.key; + const value = discriminator.discriminatorMapping.value; + if (obj[key] == value) { + schema = discriminator; + break; + } + } + } + + if (virtuals == null) { + virtuals = Object.keys(schema.virtuals); + } + for (const virtual of virtuals) { + if (schema.virtuals[virtual] == null) { + continue; + } + const virtualType = schema.virtuals[virtual]; + const sp = Array.isArray(virtual) + ? virtual + : virtual.indexOf('.') === -1 + ? [virtual] + : virtual.split('.'); + let cur = obj; + for (let i = 0; i < sp.length - 1; ++i) { + cur[sp[i]] = sp[i] in cur ? cur[sp[i]] : {}; + cur = cur[sp[i]]; + } + let val = virtualType.applyGetters(cur[sp[sp.length - 1]], obj); + const isPopulateVirtual = + virtualType.options && (virtualType.options.ref || virtualType.options.refPath); + if (isPopulateVirtual && val === undefined) { + if (virtualType.options.justOne) { + val = null; + } else { + val = []; + } + } + cur[sp[sp.length - 1]] = val; + } +} diff --git a/lib/model.js b/lib/model.js index 6d9458b5d77..1c361bcb495 100644 --- a/lib/model.js +++ b/lib/model.js @@ -31,6 +31,7 @@ const applySchemaCollation = require('./helpers/indexes/applySchemaCollation'); const applyStaticHooks = require('./helpers/model/applyStaticHooks'); const applyStatics = require('./helpers/model/applyStatics'); const applyWriteConcern = require('./helpers/schema/applyWriteConcern'); +const applyVirtualsHelper = require('./helpers/document/applyVirtuals'); const assignVals = require('./helpers/populate/assignVals'); const castBulkWrite = require('./helpers/model/castBulkWrite'); const clone = require('./helpers/clone'); @@ -3488,6 +3489,9 @@ function handleSuccessfulWrite(document) { */ Model.applyDefaults = function applyDefaults(doc) { + if (doc == null) { + return doc; + } if (doc.$__ != null) { applyDefaultsHelper(doc, doc.$__.fields, doc.$__.exclude); @@ -3503,6 +3507,40 @@ Model.applyDefaults = function applyDefaults(doc) { return doc; }; +/** + * Apply this model's virtuals to a given POJO. Virtuals execute with the POJO as the context `this`. + * + * #### Example: + * + * const userSchema = new Schema({ name: String }); + * userSchema.virtual('upper').get(function() { return this.name.toUpperCase(); }); + * const User = mongoose.model('User', userSchema); + * + * const obj = { name: 'John' }; + * User.applyVirtuals(obj); + * obj.name; // 'John' + * obj.upper; // 'JOHN', Mongoose applied the return value of the virtual to the given object + * + * @param {Object} obj object or document to apply virtuals on + * @param {Array} [virtualsToApply] optional whitelist of virtuals to apply + * @returns {Object} obj + * @api public + */ + +Model.applyVirtuals = function applyVirtuals(obj, virtualsToApply) { + if (obj == null) { + return obj; + } + // Nothing to do if this is already a hydrated document - it should already have virtuals + if (obj.$__ != null) { + return obj; + } + + applyVirtualsHelper(this.schema, obj, virtualsToApply); + + return obj; +}; + /** * Cast the given POJO to the model's schema * diff --git a/test/model.test.js b/test/model.test.js index 1e531c097b5..8921bf9cd80 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -7781,6 +7781,165 @@ describe('Model', function() { docs = await User.find(); assert.deepStrictEqual(docs.map(doc => doc.age), [12, 12]); }); + + describe('applyVirtuals', function() { + it('handles basic top-level virtuals', async function() { + const userSchema = new Schema({ + name: String + }); + userSchema.virtual('lowercase').get(function() { + return this.name.toLowerCase(); + }); + userSchema.virtual('uppercase').get(function() { + return this.name.toUpperCase(); + }); + const User = db.model('User', userSchema); + + const res = User.applyVirtuals({ name: 'Taco' }); + assert.equal(res.name, 'Taco'); + assert.equal(res.lowercase, 'taco'); + assert.equal(res.uppercase, 'TACO'); + }); + + it('handles virtuals in subdocuments', async function() { + const userSchema = new Schema({ + name: String + }); + userSchema.virtual('lowercase').get(function() { + return this.name.toLowerCase(); + }); + userSchema.virtual('uppercase').get(function() { + return this.name.toUpperCase(); + }); + const groupSchema = new Schema({ + name: String, + leader: userSchema, + members: [userSchema] + }); + const Group = db.model('Group', groupSchema); + + const res = Group.applyVirtuals({ + name: 'Microsoft', + leader: { name: 'Bill' }, + members: [{ name: 'John' }, { name: 'Steve' }] + }); + assert.equal(res.name, 'Microsoft'); + assert.equal(res.leader.name, 'Bill'); + assert.equal(res.leader.uppercase, 'BILL'); + assert.equal(res.leader.lowercase, 'bill'); + assert.equal(res.members[0].name, 'John'); + assert.equal(res.members[0].uppercase, 'JOHN'); + assert.equal(res.members[0].lowercase, 'john'); + assert.equal(res.members[1].name, 'Steve'); + assert.equal(res.members[1].uppercase, 'STEVE'); + assert.equal(res.members[1].lowercase, 'steve'); + }); + + it('handles virtuals on nested paths', async function() { + const userSchema = new Schema({ + name: { + first: String, + last: String + } + }); + userSchema.virtual('name.firstUpper').get(function() { + return this.name.first.toUpperCase(); + }); + userSchema.virtual('name.lastLower').get(function() { + return this.name.last.toLowerCase(); + }); + const User = db.model('User', userSchema); + + const res = User.applyVirtuals({ + name: { + first: 'Bill', + last: 'Gates' + } + }); + assert.equal(res.name.first, 'Bill'); + assert.equal(res.name.last, 'Gates'); + assert.equal(res.name.firstUpper, 'BILL'); + assert.equal(res.name.lastLower, 'gates'); + }); + + it('supports passing an array of virtuals to apply', async function() { + const userSchema = new Schema({ + name: { + first: String, + last: String + } + }); + userSchema.virtual('fullName').get(function() { + return `${this.name.first} ${this.name.last}`; + }); + userSchema.virtual('name.firstUpper').get(function() { + return this.name.first.toUpperCase(); + }); + userSchema.virtual('name.lastLower').get(function() { + return this.name.last.toLowerCase(); + }); + const User = db.model('User', userSchema); + + let res = User.applyVirtuals({ + name: { + first: 'Bill', + last: 'Gates' + } + }, ['fullName', 'name.firstUpper']); + assert.strictEqual(res.name.first, 'Bill'); + assert.strictEqual(res.name.last, 'Gates'); + assert.strictEqual(res.fullName, 'Bill Gates'); + assert.strictEqual(res.name.firstUpper, 'BILL'); + assert.strictEqual(res.name.lastLower, undefined); + + res = User.applyVirtuals({ + name: { + first: 'Bill', + last: 'Gates' + } + }, ['name.lastLower']); + assert.strictEqual(res.name.first, 'Bill'); + assert.strictEqual(res.name.last, 'Gates'); + assert.strictEqual(res.fullName, undefined); + assert.strictEqual(res.name.firstUpper, undefined); + assert.strictEqual(res.name.lastLower, 'gates'); + }); + + it('sets populate virtuals to `null` if `justOne`', async function() { + const userSchema = new Schema({ + name: { + first: String, + last: String + }, + friendId: { + type: 'ObjectId' + } + }); + userSchema.virtual('fullName').get(function() { + return `${this.name.first} ${this.name.last}`; + }); + userSchema.virtual('friend', { + ref: 'User', + localField: 'friendId', + foreignField: '_id', + justOne: true + }); + const User = db.model('User', userSchema); + + const friendId = new mongoose.Types.ObjectId(); + const res = User.applyVirtuals({ + name: { + first: 'Bill', + last: 'Gates' + }, + friendId + }); + assert.strictEqual(res.name.first, 'Bill'); + assert.strictEqual(res.name.last, 'Gates'); + assert.strictEqual(res.fullName, 'Bill Gates'); + assert.strictEqual(res.friend, null); + }); + }); }); diff --git a/types/models.d.ts b/types/models.d.ts index 4c2403fd51b..0a5e6e3a585 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -290,6 +290,9 @@ declare module 'mongoose' { applyDefaults(obj: AnyObject): AnyObject; applyDefaults(obj: TRawDocType): TRawDocType; + /* Apply virtuals to the given POJO. */ + applyVirtuals(obj: AnyObject, virtalsToApply?: string[]): AnyObject; + /** * Sends multiple `insertOne`, `updateOne`, `updateMany`, `replaceOne`, * `deleteOne`, and/or `deleteMany` operations to the MongoDB server in one