Skip to content

Commit

Permalink
Merge pull request #14905 from Automattic/vkarpov15/gh-14818-2
Browse files Browse the repository at this point in the history
feat(model): add `Model.applyVirtuals()` to apply virtuals to a POJO
  • Loading branch information
vkarpov15 authored Sep 26, 2024
2 parents adb4fb0 + 54844e3 commit a4e3308
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 0 deletions.
146 changes: 146 additions & 0 deletions lib/helpers/document/applyVirtuals.js
Original file line number Diff line number Diff line change
@@ -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<string>} [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<String>} [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<String>} [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;
}
}
38 changes: 38 additions & 0 deletions lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);

Expand All @@ -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<string>} [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
*
Expand Down
159 changes: 159 additions & 0 deletions test/model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7859,6 +7859,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);
});
});
});


Expand Down
3 changes: 3 additions & 0 deletions types/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit a4e3308

Please sign in to comment.