diff --git a/lib/document.js b/lib/document.js index 68185b41a4b..15764c75687 100644 --- a/lib/document.js +++ b/lib/document.js @@ -766,6 +766,8 @@ function init(self, obj, doc, opts, prefix) { doc[i] = {}; if (!strict && !(i in docSchema.tree) && !(i in docSchema.methods) && !(i in docSchema.virtuals)) { self[i] = doc[i]; + } else if (opts?.virtuals && (i in docSchema.virtuals)) { + self[i] = doc[i]; } } init(self, value, doc[i], opts, path + '.'); @@ -773,6 +775,8 @@ function init(self, obj, doc, opts, prefix) { doc[i] = value; if (!strict && !prefix) { self[i] = value; + } else if (opts?.virtuals && (i in docSchema.virtuals)) { + self[i] = value; } } else { // Retain order when overwriting defaults @@ -789,9 +793,9 @@ function init(self, obj, doc, opts, prefix) { if (opts && opts.setters) { // Call applySetters with `init = false` because otherwise setters are a noop const overrideInit = false; - doc[i] = schemaType.applySetters(value, self, overrideInit); + doc[i] = schemaType.applySetters(value, self, overrideInit, null, opts); } else { - doc[i] = schemaType.cast(value, self, true); + doc[i] = schemaType.cast(value, self, true, undefined, opts); } } catch (e) { self.invalidate(e.path, new ValidatorError({ diff --git a/lib/model.js b/lib/model.js index e581393eb6d..6edf39e68fa 100644 --- a/lib/model.js +++ b/lib/model.js @@ -3966,6 +3966,7 @@ Model.buildBulkWriteOperations = function buildBulkWriteOperations(documents, op * @param {Object} [options] optional options * @param {Boolean} [options.setters=false] if true, apply schema setters when hydrating * @param {Boolean} [options.hydratedPopulatedDocs=false] if true, populates the docs if passing pre-populated data + * @param {Boolean} [options.virtuals=false] if true, sets any virtuals present on `obj` * @return {Document} document instance * @api public */ @@ -3973,6 +3974,10 @@ Model.buildBulkWriteOperations = function buildBulkWriteOperations(documents, op Model.hydrate = function(obj, projection, options) { _checkContext(this, 'hydrate'); + if (options?.virtuals && options?.hydratedPopulatedDocs === false) { + throw new MongooseError('Cannot set `hydratedPopulatedDocs` option to false if `virtuals` option is truthy because `virtuals: true` also sets populated virtuals'); + } + if (projection != null) { if (obj != null && obj.$__ != null) { obj = obj.toObject(internalToObjectOptions); diff --git a/lib/schema/documentArray.js b/lib/schema/documentArray.js index 6eba45255b2..1a13274fb53 100644 --- a/lib/schema/documentArray.js +++ b/lib/schema/documentArray.js @@ -492,7 +492,7 @@ SchemaDocumentArray.prototype.cast = function(value, doc, init, prev, options) { } subdoc = new Constructor(null, value, initDocumentOptions, selected, i); - rawArray[i] = subdoc.$init(rawArray[i]); + rawArray[i] = subdoc.$init(rawArray[i], options); } else { if (prev && typeof prev.id === 'function') { subdoc = prev.id(rawArray[i]._id); diff --git a/lib/schema/map.js b/lib/schema/map.js index 7fcbd7576c2..752fa07d949 100644 --- a/lib/schema/map.js +++ b/lib/schema/map.js @@ -39,7 +39,7 @@ class SchemaMap extends SchemaType { if (_val == null) { _val = map.$__schemaType._castNullish(_val); } else { - _val = map.$__schemaType.cast(_val, doc, true, null, { path: path + '.' + key }); + _val = map.$__schemaType.cast(_val, doc, true, null, { ...options, path: path + '.' + key }); } map.$init(key, _val); } @@ -49,7 +49,7 @@ class SchemaMap extends SchemaType { if (_val == null) { _val = map.$__schemaType._castNullish(_val); } else { - _val = map.$__schemaType.cast(_val, doc, true, null, { path: path + '.' + key }); + _val = map.$__schemaType.cast(_val, doc, true, null, { ...options, path: path + '.' + key }); } map.$init(key, _val); } diff --git a/lib/schema/subdocument.js b/lib/schema/subdocument.js index 7481ec9bcbc..b8a95a0d61c 100644 --- a/lib/schema/subdocument.js +++ b/lib/schema/subdocument.js @@ -203,7 +203,7 @@ SchemaSubdocument.prototype.cast = function(val, doc, init, priorVal, options) { if (init) { subdoc = new Constructor(void 0, selected, doc, false, { defaults: false }); delete subdoc.$__.defaults; - subdoc.$init(val); + subdoc.$init(val, options); const exclude = isExclusive(selected); applyDefaults(subdoc, selected, exclude); } else { diff --git a/test/model.test.js b/test/model.test.js index 8b363c449d8..5ead1668beb 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -7700,6 +7700,116 @@ describe('Model', function() { assert.equal(doc.text, 'foobar'); }); + it('supports virtuals option for `hydrate()` (gh-15627)', function() { + // 2) virtual in a document array + const arrSchema = new Schema({ + value: String + }); + arrSchema.virtual('valueLower'); + + // 3) virtual in a subdocument + const nestedSchema = new Schema({ + foo: String + }); + nestedSchema.virtual('fooRev'); + + // 4) virtual in a map of subdocuments + const mapSubSchema = new Schema({ + v: String + }, { _id: false }); + mapSubSchema.virtual('vDouble'); + + const schema = Schema({ + name: String, + nested: nestedSchema, + arr: [arrSchema], + map: { + type: Map, + of: mapSubSchema + }, + friendId: { + type: mongoose.Schema.Types.ObjectId + } + }, { toObject: { virtuals: true }, toJSON: { virtuals: true } }); + + // 1) top-level virtual + schema.virtual('topLevel'); + + // 5) top-level populated virtual + schema.virtual('friend', { + ref: 'TestUser', + localField: 'friendId', + foreignField: '_id', + justOne: true + }); + + db.model('User', new Schema({ + _id: mongoose.Schema.Types.ObjectId, + username: String + })); + + const TestModel = db.model('Test', schema); + + const userId = new mongoose.Types.ObjectId(); + + // Hydrate with various virtuals in the raw object + const doc = TestModel.hydrate({ + name: 'Bill', + topLevel: 'test top level virtual', + arr: [ + { value: 'FOO', valueLower: 'foo' }, + { value: 'BAR', valueLower: 'bar' } + ], + nested: { foo: 'baz', fooRev: 'zab' }, + map: { + first: { v: 'ab', vDouble: 'abab' }, + second: { v: 'xy', vDouble: 'xyxy' } + }, + friendId: userId, + friend: { _id: userId, username: 'Populated Friend' } + }, null, { virtuals: true }); // ensure virtuals:true here + + // 1) Top-level virtual + assert.equal(doc.name, 'Bill'); + assert.equal(doc.topLevel, 'test top level virtual'); + assert.strictEqual(doc.toObject().topLevel, 'test top level virtual'); + + // 2) Document array virtuals + assert.equal(doc.arr.length, 2); + assert.equal(doc.arr[0].value, 'FOO'); + assert.equal(doc.arr[0].valueLower, 'foo'); + assert.strictEqual(doc.arr[0].toObject().valueLower, 'foo'); + assert.equal(doc.arr[1].value, 'BAR'); + assert.equal(doc.arr[1].valueLower, 'bar'); + assert.strictEqual(doc.arr[1].toObject().valueLower, 'bar'); + + // 3) Virtual in subdocument + assert.ok(doc.nested); + assert.equal(doc.nested.foo, 'baz'); + assert.equal(doc.nested.fooRev, 'zab'); + assert.strictEqual(doc.nested.toObject().fooRev, 'zab'); + + // 4) Virtual in map of subdocuments + assert.ok(doc.map instanceof Map); + assert.equal(doc.map.get('first').v, 'ab'); + assert.equal(doc.map.get('first').vDouble, 'abab'); + assert.strictEqual(doc.map.get('first').toObject().vDouble, 'abab'); + assert.equal(doc.map.get('second').v, 'xy'); + assert.equal(doc.map.get('second').vDouble, 'xyxy'); + assert.strictEqual(doc.map.get('second').toObject().vDouble, 'xyxy'); + + // 5) Top-level populated virtual + assert.equal(doc.friendId.toString(), userId.toString()); + assert.ok(doc.friend); + assert.equal(doc.friend._id.toString(), userId.toString()); + assert.equal(doc.friend.username, 'Populated Friend'); + + assert.throws( + () => TestModel.hydrate({}, null, { virtuals: true, hydratedPopulatedDocs: false }), + /Cannot set `hydratedPopulatedDocs` option to false if `virtuals` option is truthy/ + ); + }); + it('sets index collation based on schema collation (gh-7621)', async function() { let testSchema = new Schema( { name: { type: String, index: true } } diff --git a/types/models.d.ts b/types/models.d.ts index 03f27bacf1f..b276501896a 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -58,6 +58,7 @@ declare module 'mongoose' { interface HydrateOptions { setters?: boolean; hydratedPopulatedDocs?: boolean; + virtuals?: boolean; } interface InsertManyOptions extends