From cb544ed003f505c196e20b925bd4b95bd3b42e12 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 11 Oct 2024 12:52:15 -0400 Subject: [PATCH] feat: allow defining virtuals on arrays, not just array elements --- lib/schema.js | 10 ++++++++ lib/schema/array.js | 35 ++++++++++++++++++++++++++ lib/schema/documentArray.js | 2 +- lib/types/array/index.js | 5 ++++ lib/types/documentArray/index.js | 7 +++++- test/document.test.js | 43 ++++++++++++++++++++++++++++++++ types/index.d.ts | 3 +++ 7 files changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index bb3480088c6..a9d23fd6199 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2304,6 +2304,7 @@ Schema.prototype.indexes = function() { * @param {Boolean} [options.count=false] Only works with populate virtuals. If [truthy](https://masteringjs.io/tutorials/fundamentals/truthy), this populate virtual will contain the number of documents rather than the documents themselves when you `populate()`. * @param {Function|null} [options.get=null] Adds a [getter](https://mongoosejs.com/docs/tutorials/getters-setters.html) to this virtual to transform the populated doc. * @param {Object|Function} [options.match=null] Apply a default [`match` option to populate](https://mongoosejs.com/docs/populate.html#match), adding an additional filter to the populate query. + * @param {Boolean} [options.applyToArray=false] If true and the given `name` is a direct child of an array, apply the virtual to the array rather than the elements. * @return {VirtualType} */ @@ -2416,6 +2417,15 @@ Schema.prototype.virtual = function(name, options) { return mem[part]; }, this.tree); + if (options && options.applyToArray && parts.length > 1) { + const path = this.path(parts.slice(0, -1).join('.')); + if (path && path.$isMongooseArray) { + return path.virtual(parts[parts.length - 1], options); + } else { + throw new MongooseError(`Path "${path}" is not an array`); + } + } + return virtuals[name]; }; diff --git a/lib/schema/array.js b/lib/schema/array.js index 00774ee3147..e424731e4d6 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -11,9 +11,12 @@ const SchemaArrayOptions = require('../options/schemaArrayOptions'); const SchemaType = require('../schemaType'); const CastError = SchemaType.CastError; const Mixed = require('./mixed'); +const VirtualOptions = require('../options/virtualOptions'); +const VirtualType = require('../virtualType'); const arrayDepth = require('../helpers/arrayDepth'); const cast = require('../cast'); const clone = require('../helpers/clone'); +const getConstructorName = require('../helpers/getConstructorName'); const isOperator = require('../helpers/query/isOperator'); const util = require('util'); const utils = require('../utils'); @@ -217,6 +220,12 @@ SchemaArray._checkRequired = SchemaType.prototype.checkRequired; SchemaArray.checkRequired = SchemaType.checkRequired; +/*! + * Virtuals defined on this array itself. + */ + +SchemaArray.prototype.virtuals = null; + /** * Check if the given value satisfies the `required` validator. * @@ -575,6 +584,32 @@ SchemaArray.prototype.castForQuery = function($conditional, val, context) { } }; +/** + * Add a virtual to this array. Specifically to this array, not the individual elements. + * + * @param {String} name + * @param {Object} [options] + * @api private + */ + +SchemaArray.prototype.virtual = function virtual(name, options) { + if (name instanceof VirtualType || getConstructorName(name) === 'VirtualType') { + return this.virtual(name.path, name.options); + } + options = new VirtualOptions(options); + + if (utils.hasUserDefinedProperty(options, ['ref', 'refPath'])) { + throw new MongooseError('Cannot set populate virtual as a property of an array'); + } + + const virtual = new VirtualType(options, name); + if (this.virtuals === null) { + this.virtuals = {}; + } + this.virtuals[name] = virtual; + return virtual; +}; + function cast$all(val, context) { if (!Array.isArray(val)) { val = [val]; diff --git a/lib/schema/documentArray.js b/lib/schema/documentArray.js index aa0c0d7984a..9a7a5d3181d 100644 --- a/lib/schema/documentArray.js +++ b/lib/schema/documentArray.js @@ -429,7 +429,7 @@ SchemaDocumentArray.prototype.cast = function(value, doc, init, prev, options) { // We need to create a new array, otherwise change tracking will // update the old doc (gh-4449) if (!options.skipDocumentArrayCast || utils.isMongooseDocumentArray(value)) { - value = new MongooseDocumentArray(value, path, doc); + value = new MongooseDocumentArray(value, path, doc, this); } if (prev != null) { diff --git a/lib/types/array/index.js b/lib/types/array/index.js index 4a8c98823a7..1f6e6a54d88 100644 --- a/lib/types/array/index.js +++ b/lib/types/array/index.js @@ -90,6 +90,9 @@ function MongooseArray(values, path, doc, schematype) { if (mongooseArrayMethods.hasOwnProperty(prop)) { return mongooseArrayMethods[prop]; } + if (schematype && schematype.virtuals && schematype.virtuals.hasOwnProperty(prop)) { + return schematype.virtuals[prop].applyGetters(undefined, target); + } if (typeof prop === 'string' && numberRE.test(prop) && schematype?.$embeddedSchemaType != null) { return schematype.$embeddedSchemaType.applyGetters(__array[prop], doc); } @@ -101,6 +104,8 @@ function MongooseArray(values, path, doc, schematype) { mongooseArrayMethods.set.call(proxy, prop, value, false); } else if (internals.hasOwnProperty(prop)) { internals[prop] = value; + } else if (schematype && schematype.virtuals && schematype.virtuals.hasOwnProperty(prop)) { + schematype.virtuals[prop].applySetters(value, target); } else { __array[prop] = value; } diff --git a/lib/types/documentArray/index.js b/lib/types/documentArray/index.js index 4877f1a30ef..863d40ae62b 100644 --- a/lib/types/documentArray/index.js +++ b/lib/types/documentArray/index.js @@ -28,7 +28,7 @@ const numberRE = /^\d+$/; * @see https://bit.ly/f6CnZU */ -function MongooseDocumentArray(values, path, doc) { +function MongooseDocumentArray(values, path, doc, schematype) { const __array = []; const internals = { @@ -84,6 +84,9 @@ function MongooseDocumentArray(values, path, doc) { if (DocumentArrayMethods.hasOwnProperty(prop)) { return DocumentArrayMethods[prop]; } + if (schematype && schematype.virtuals && schematype.virtuals.hasOwnProperty(prop)) { + return schematype.virtuals[prop].applyGetters(undefined, target); + } if (ArrayMethods.hasOwnProperty(prop)) { return ArrayMethods[prop]; } @@ -95,6 +98,8 @@ function MongooseDocumentArray(values, path, doc) { DocumentArrayMethods.set.call(proxy, prop, value, false); } else if (internals.hasOwnProperty(prop)) { internals[prop] = value; + } else if (schematype && schematype.virtuals && schematype.virtuals.hasOwnProperty(prop)) { + schematype.virtuals[prop].applySetters(value, target); } else { __array[prop] = value; } diff --git a/test/document.test.js b/test/document.test.js index f3869b8e58c..5bc50fdd287 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -13978,6 +13978,49 @@ describe('document', function() { assert.ok(Buffer.isBuffer(reloaded.pdfSettings.fileContent)); assert.strictEqual(reloaded.pdfSettings.fileContent.toString('utf8'), 'hello'); }); + + describe('gh-2306', function() { + it('allow define virtual on non-object path', function() { + const schema = new mongoose.Schema({ num: Number, str: String, nums: [Number] }); + schema.path('nums').virtual('last').get(function() { + return this[this.length - 1]; + }); + schema.virtual('nums.first', { applyToArray: true }).get(function() { + return this[0]; + }); + schema.virtual('nums.selectedIndex', { applyToArray: true }) + .get(function() { + return this.__selectedIndex; + }) + .set(function(v) { + this.__selectedIndex = v; + }); + const M = db.model('gh2306', schema); + const m = new M({ num: 2, str: 'a', nums: [1, 2, 3] }); + + assert.strictEqual(m.nums.last, 3); + assert.strictEqual(m.nums.first, 1); + + assert.strictEqual(m.nums.selectedIndex, undefined); + m.nums.selectedIndex = 42; + assert.strictEqual(m.nums.__selectedIndex, 42); + }); + + it('works on document arrays', function() { + const schema = new mongoose.Schema({ books: [{ title: String, author: String }] }); + schema.path('books').virtual('last').get(function() { + return this[this.length - 1]; + }); + schema.virtual('books.first', { applyToArray: true }).get(function() { + return this[0]; + }); + const M = db.model('Test', schema); + const m = new M({ books: [{ title: 'Casino Royale', author: 'Ian Fleming' }, { title: 'The Man With The Golden Gun', author: 'Ian Fleming' }] }); + + assert.strictEqual(m.books.first.title, 'Casino Royale'); + assert.strictEqual(m.books.last.title, 'The Man With The Golden Gun'); + }); + }); }); describe('Check if instance function that is supplied in schema option is available', function() { diff --git a/types/index.d.ts b/types/index.d.ts index 3ec72ac281d..91044d30b1b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -623,6 +623,9 @@ declare module 'mongoose' { /** Additional options like `limit` and `lean`. */ options?: QueryOptions & { match?: AnyObject }; + /** If true and the given `name` is a direct child of an array, apply the virtual to the array rather than the elements. */ + applyToArray?: boolean; + /** Additional options for plugins */ [extra: string]: any; }