diff --git a/docs/populate.md b/docs/populate.md index 1f9d6cdfd41..c17710a397a 100644 --- a/docs/populate.md +++ b/docs/populate.md @@ -119,6 +119,33 @@ story.author = author; console.log(story.author.name); // prints "Ian Fleming" ``` +You can also push documents or POJOs onto a populated array, and Mongoose will add those documents if their `ref` matches. + +```javascript +const fan1 = await Person.create({ name: 'Sean' }); +await Story.updateOne({ title: 'Casino Royale' }, { $push: { fans: { $each: [fan1._id] } } }); + +const story = await Story.findOne({ title: 'Casino Royale' }).populate('fans'); +story.fans[0].name; // 'Sean' + +const fan2 = await Person.create({ name: 'George' }); +story.fans.push(fan2); +story.fans[1].name; // 'George' + +story.fans.push({ name: 'Roger' }); +story.fans[2].name; // 'Roger' +``` + +If you push a non-POJO and non-document value, like an ObjectId, Mongoose `>= 8.7.0` will depopulate the entire array. + +```javascript +const fan4 = await Person.create({ name: 'Timothy' }); +story.fans.push(fan4._id); // Push the `_id`, not the full document + +story.fans[0].name; // undefined, `fans[0]` is now an ObjectId +story.fans[0].toString() === fan1._id.toString(); // true +``` + ## Checking Whether a Field is Populated {#checking-populated} You can call the `populated()` function to check whether a field is populated. diff --git a/lib/connection.js b/lib/connection.js index 2e0caf5c592..0b3ec6ae0ea 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -106,6 +106,15 @@ Object.setPrototypeOf(Connection.prototype, EventEmitter.prototype); Object.defineProperty(Connection.prototype, 'readyState', { get: function() { + // If connection thinks it is connected, but we haven't received a heartbeat in 2 heartbeat intervals, + // that likely means the connection is stale (potentially due to frozen AWS Lambda container) + if ( + this._readyState === STATES.connected && + this._lastHeartbeatAt != null && + typeof this.client?.topology?.s?.description?.heartbeatFrequencyMS === 'number' && + Date.now() - this._lastHeartbeatAt >= this.client.topology.s.description.heartbeatFrequencyMS * 2) { + return STATES.disconnected; + } return this._readyState; }, set: function(val) { diff --git a/lib/drivers/node-mongodb-native/connection.js b/lib/drivers/node-mongodb-native/connection.js index 505053da4fd..641703e4b11 100644 --- a/lib/drivers/node-mongodb-native/connection.js +++ b/lib/drivers/node-mongodb-native/connection.js @@ -23,6 +23,11 @@ const utils = require('../../utils'); function NativeConnection() { MongooseConnection.apply(this, arguments); this._listening = false; + // Tracks the last time (as unix timestamp) the connection received a + // serverHeartbeatSucceeded or serverHeartbeatFailed event from the underlying MongoClient. + // If we haven't received one in a while (like due to a frozen AWS Lambda container) then + // `readyState` is likely stale. + this._lastHeartbeatAt = null; } /** @@ -106,6 +111,7 @@ NativeConnection.prototype.useDb = function(name, options) { _opts.noListener = options.noListener; } newConn.db = _this.client.db(name, _opts); + newConn._lastHeartbeatAt = _this._lastHeartbeatAt; newConn.onOpen(); } @@ -409,6 +415,9 @@ function _setClient(conn, client, options, dbName) { } }); } + client.on('serverHeartbeatSucceeded', () => { + conn._lastHeartbeatAt = Date.now(); + }); if (options.monitorCommands) { client.on('commandStarted', (data) => conn.emit('commandStarted', data)); @@ -417,6 +426,9 @@ function _setClient(conn, client, options, dbName) { } conn.onOpen(); + if (client.topology?.s?.state === 'connected') { + conn._lastHeartbeatAt = Date.now(); + } for (const i in conn.collections) { if (utils.object.hasOwnProperty(conn.collections, i)) { diff --git a/lib/error/bulkSaveIncompleteError.js b/lib/error/bulkSaveIncompleteError.js new file mode 100644 index 00000000000..c4b88e5d7bb --- /dev/null +++ b/lib/error/bulkSaveIncompleteError.js @@ -0,0 +1,44 @@ +/*! + * Module dependencies. + */ + +'use strict'; + +const MongooseError = require('./mongooseError'); + + +/** + * If the underwriting `bulkWrite()` for `bulkSave()` succeeded, but wasn't able to update or + * insert all documents, we throw this error. + * + * @api private + */ + +class MongooseBulkSaveIncompleteError extends MongooseError { + constructor(modelName, documents, bulkWriteResult) { + const matchedCount = bulkWriteResult?.matchedCount ?? 0; + const insertedCount = bulkWriteResult?.insertedCount ?? 0; + let preview = documents.map(doc => doc._id).join(', '); + if (preview.length > 100) { + preview = preview.slice(0, 100) + '...'; + } + + const numDocumentsNotUpdated = documents.length - matchedCount - insertedCount; + super(`${modelName}.bulkSave() was not able to update ${numDocumentsNotUpdated} of the given documents due to incorrect version or optimistic concurrency, document ids: ${preview}`); + + this.modelName = modelName; + this.documents = documents; + this.bulkWriteResult = bulkWriteResult; + this.numDocumentsNotUpdated = numDocumentsNotUpdated; + } +} + +Object.defineProperty(MongooseBulkSaveIncompleteError.prototype, 'name', { + value: 'MongooseBulkSaveIncompleteError' +}); + +/*! + * exports + */ + +module.exports = MongooseBulkSaveIncompleteError; 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/helpers/query/castUpdate.js b/lib/helpers/query/castUpdate.js index eb69bc89a09..94f374d8b58 100644 --- a/lib/helpers/query/castUpdate.js +++ b/lib/helpers/query/castUpdate.js @@ -2,6 +2,7 @@ const CastError = require('../../error/cast'); const MongooseError = require('../../error/mongooseError'); +const SchemaString = require('../../schema/string'); const StrictModeError = require('../../error/strict'); const ValidationError = require('../../error/validation'); const castNumber = require('../../cast/number'); @@ -307,6 +308,20 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) { continue; } + hasKeys = true; + } else if (op === '$rename') { + const schematype = new SchemaString(`${prefix}${key}.$rename`); + try { + obj[key] = castUpdateVal(schematype, val, op, key, context, prefix + key); + } catch (error) { + aggregatedError = _appendError(error, context, key, aggregatedError); + } + + if (obj[key] === void 0) { + delete obj[key]; + continue; + } + hasKeys = true; } else { const pathToCheck = (prefix + key); @@ -372,10 +387,12 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) { delete obj[key]; } } else { - // gh-1845 temporary fix: ignore $rename. See gh-3027 for tracking - // improving this. if (op === '$rename') { - hasKeys = true; + if (obj[key] == null) { + throw new CastError('String', obj[key], `${prefix}${key}.$rename`); + } + const schematype = new SchemaString(`${prefix}${key}.$rename`); + obj[key] = schematype.castForQuery(null, obj[key], context); continue; } diff --git a/lib/model.js b/lib/model.js index 0a1091ab2bb..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'); @@ -64,6 +65,7 @@ const STATES = require('./connectionState'); const util = require('util'); const utils = require('./utils'); const minimize = require('./helpers/minimize'); +const MongooseBulkSaveIncompleteError = require('./error/bulkSaveIncompleteError'); const modelCollectionSymbol = Symbol('mongoose#Model#collection'); const modelDbSymbol = Symbol('mongoose#Model#db'); @@ -3418,11 +3420,10 @@ Model.bulkSave = async function bulkSave(documents, options) { const matchedCount = bulkWriteResult?.matchedCount ?? 0; const insertedCount = bulkWriteResult?.insertedCount ?? 0; - if (writeOperations.length > 0 && matchedCount + insertedCount === 0 && !bulkWriteError) { - throw new DocumentNotFoundError( - writeOperations.filter(op => op.updateOne).map(op => op.updateOne.filter), + if (writeOperations.length > 0 && matchedCount + insertedCount < writeOperations.length && !bulkWriteError) { + throw new MongooseBulkSaveIncompleteError( this.modelName, - writeOperations.length, + documents, bulkWriteResult ); } @@ -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/lib/schemaType.js b/lib/schemaType.js index f95ecbb3226..e5aa476468f 100644 --- a/lib/schemaType.js +++ b/lib/schemaType.js @@ -1724,6 +1724,25 @@ SchemaType.prototype.clone = function() { return schematype; }; +/** + * Returns the embedded schema type, if any. For arrays, document arrays, and maps, `getEmbeddedSchemaType()` + * returns the schema type of the array's elements (or map's elements). For other types, `getEmbeddedSchemaType()` + * returns `undefined`. + * + * #### Example: + * + * const schema = new Schema({ name: String, tags: [String] }); + * schema.path('name').getEmbeddedSchemaType(); // undefined + * schema.path('tags').getEmbeddedSchemaType(); // SchemaString { path: 'tags', ... } + * + * @returns {SchemaType} embedded schematype + * @api public + */ + +SchemaType.prototype.getEmbeddedSchemaType = function getEmbeddedSchemaType() { + return this.$embeddedSchemaType; +}; + /*! * Module exports. */ diff --git a/lib/types/array/methods/index.js b/lib/types/array/methods/index.js index cf31914bb7e..3322bbe56e8 100644 --- a/lib/types/array/methods/index.js +++ b/lib/types/array/methods/index.js @@ -410,6 +410,7 @@ const methods = { addToSet() { _checkManualPopulation(this, arguments); + _depopulateIfNecessary(this, arguments); const values = [].map.call(arguments, this._mapCast, this); const added = []; @@ -691,6 +692,7 @@ const methods = { } _checkManualPopulation(this, values); + _depopulateIfNecessary(this, values); values = [].map.call(values, this._mapCast, this); let ret; @@ -1009,6 +1011,30 @@ function _checkManualPopulation(arr, docs) { } } +/*! + * If `docs` isn't all instances of the right model, depopulate `arr` + */ + +function _depopulateIfNecessary(arr, docs) { + const ref = arr == null ? + null : + arr[arraySchemaSymbol] && arr[arraySchemaSymbol].caster && arr[arraySchemaSymbol].caster.options && arr[arraySchemaSymbol].caster.options.ref || null; + const parentDoc = arr[arrayParentSymbol]; + const path = arr[arrayPathSymbol]; + if (!ref || !parentDoc.populated(path)) { + return; + } + for (const doc of docs) { + if (doc == null) { + continue; + } + if (typeof doc !== 'object' || doc instanceof String || doc instanceof Number || doc instanceof Buffer || utils.isMongooseType(doc)) { + parentDoc.depopulate(path); + break; + } + } +} + const returnVanillaArrayMethods = [ 'filter', 'flat', diff --git a/package.json b/package.json index abfd730669d..ee29538ad81 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dependencies": { "bson": "^6.7.0", "kareem": "2.6.3", - "mongodb": "6.8.0", + "mongodb": "6.9.0", "mpath": "0.9.0", "mquery": "5.0.0", "ms": "2.1.3", diff --git a/test/model.populate.setting.test.js b/test/model.populate.setting.test.js index bcf072e8d0c..d1c6700e044 100644 --- a/test/model.populate.setting.test.js +++ b/test/model.populate.setting.test.js @@ -152,7 +152,7 @@ describe('model: populate:', function() { assert.equal(doc.fans[6], null); const _id = construct[id](); - doc.fans.addToSet(_id); + doc.fans.addToSet({ _id }); if (Buffer.isBuffer(_id)) { assert.equal(doc.fans[7]._id.toString('utf8'), _id.toString('utf8')); } else { diff --git a/test/model.populate.test.js b/test/model.populate.test.js index be7933882f1..c32c23d9cce 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -11132,6 +11132,32 @@ describe('model: populate:', function() { assert.equal(posts.length, 2); }); + it('depopulates if pushing ObjectId to a populated array (gh-1635)', async function() { + const ParentModel = db.model('Test', mongoose.Schema({ + name: String, + children: [{ type: 'ObjectId', ref: 'Child' }] + })); + const ChildModel = db.model('Child', mongoose.Schema({ name: String })); + + const children = await ChildModel.create([{ name: 'Luke' }, { name: 'Leia' }]); + const newChild = await ChildModel.create({ name: 'Taco' }); + const { _id } = await ParentModel.create({ name: 'Anakin', children }); + + const doc = await ParentModel.findById(_id).populate('children'); + doc.children.push(newChild._id); + + assert.ok(doc.children[0] instanceof mongoose.Types.ObjectId); + assert.ok(doc.children[1] instanceof mongoose.Types.ObjectId); + assert.ok(doc.children[2] instanceof mongoose.Types.ObjectId); + + await doc.save(); + + const fromDb = await ParentModel.findById(_id); + assert.equal(fromDb.children[0].toHexString(), children[0]._id.toHexString()); + assert.equal(fromDb.children[1].toHexString(), children[1]._id.toHexString()); + assert.equal(fromDb.children[2].toHexString(), newChild._id.toHexString()); + }); + it('handles converting uuid documents to strings when calling toObject() (gh-14869)', async function() { const nodeSchema = new Schema({ _id: { type: 'UUID' }, name: 'String' }); const rootSchema = new Schema({ diff --git a/test/model.test.js b/test/model.test.js index cca70e32fd8..b81cf8ff609 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -7072,9 +7072,35 @@ describe('Model', function() { foo.bar = 2; const err = await TestModel.bulkSave([foo]).then(() => null, err => err); - assert.equal(err.name, 'DocumentNotFoundError'); - assert.equal(err.numAffected, 1); - assert.ok(Array.isArray(err.filter)); + assert.equal(err.name, 'MongooseBulkSaveIncompleteError'); + assert.equal(err.numDocumentsNotUpdated, 1); + }); + it('should error if not all documents were inserted or updated (gh-14763)', async function() { + const fooSchema = new mongoose.Schema({ + bar: { type: Number } + }, { optimisticConcurrency: true }); + const TestModel = db.model('Test', fooSchema); + + const errorDoc = await TestModel.create({ bar: 0 }); + const okDoc = await TestModel.create({ bar: 0 }); + + // update 1 + errorDoc.bar = 1; + await errorDoc.save(); + + // parallel update + const errorDocCopy = await TestModel.findById(errorDoc._id); + errorDocCopy.bar = 99; + await errorDocCopy.save(); + + errorDoc.bar = 2; + okDoc.bar = 2; + const err = await TestModel.bulkSave([errorDoc, okDoc]).then(() => null, err => err); + assert.equal(err.name, 'MongooseBulkSaveIncompleteError'); + assert.equal(err.numDocumentsNotUpdated, 1); + + const updatedOkDoc = await TestModel.findById(okDoc._id); + assert.equal(updatedOkDoc.bar, 2); }); it('should error if there is a validation error', async function() { const fooSchema = new mongoose.Schema({ @@ -7833,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); + }); + }); }); diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index e9ae3ce43a5..0b4223dad5c 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -1078,9 +1078,29 @@ describe('model: updateOne:', function() { const Model = db.model('Test', schema); const update = { $rename: { foo: 'bar' } }; - await Model.create({ foo: Date.now() }); - const res = await Model.updateOne({}, update, { multi: true }); - assert.equal(res.modifiedCount, 1); + const foo = Date.now(); + const { _id } = await Model.create({ foo }); + await Model.updateOne({}, update); + const doc = await Model.findById(_id); + assert.equal(doc.bar.valueOf(), foo.valueOf()); + assert.equal(doc.foo, undefined); + }); + + it('throws CastError if $rename fails to cast to string (gh-1845)', async function() { + const schema = new Schema({ foo: Date, bar: Date }); + const Model = db.model('Test', schema); + + let err = await Model.updateOne({}, { $rename: { foo: { prop: 'baz' } } }).then(() => null, err => err); + assert.equal(err.name, 'CastError'); + assert.ok(err.message.includes('foo.$rename')); + + err = await Model.updateOne({}, { $rename: { foo: null } }).then(() => null, err => err); + assert.equal(err.name, 'CastError'); + assert.ok(err.message.includes('foo.$rename')); + + err = await Model.updateOne({}, { $rename: { foo: undefined } }).then(() => null, err => err); + assert.equal(err.name, 'CastError'); + assert.ok(err.message.includes('foo.$rename')); }); it('allows objects with positional operator (gh-3185)', async function() { diff --git a/test/schematype.test.js b/test/schematype.test.js index ad8367d0f61..725e21966a4 100644 --- a/test/schematype.test.js +++ b/test/schematype.test.js @@ -315,4 +315,13 @@ describe('schematype', function() { /password must be at least six characters/ ); }); + + it('supports getEmbeddedSchemaType() (gh-8389)', function() { + const schema = new Schema({ name: String, tags: [String] }); + assert.strictEqual(schema.path('name').getEmbeddedSchemaType(), undefined); + const schemaType = schema.path('tags').getEmbeddedSchemaType(); + assert.ok(schemaType); + assert.equal(schemaType.instance, 'String'); + assert.equal(schemaType.path, 'tags'); + }); }); diff --git a/test/types/document.test.ts b/test/types/document.test.ts index cf45b9ce857..48456bf4806 100644 --- a/test/types/document.test.ts +++ b/test/types/document.test.ts @@ -360,6 +360,22 @@ function gh13738() { expectType<{ theme: string; alerts: { sms: boolean } }>(person.get('settings')); } +async function gh12959() { + const subdocSchema = new Schema({ foo: { type: 'string', required: true } }); + + const schema = new Schema({ + subdocArray: { type: [subdocSchema], required: true } + }); + + const Model = model('test', schema); + + const doc = await Model.findById('id').orFail(); + expectType(doc._id); + expectType(doc.__v); + + expectError(doc.subdocArray[0].__v); +} + async function gh14876() { type CarObjectInterface = { make: string; diff --git a/test/types/schema.test.ts b/test/types/schema.test.ts index 5a399a0c7db..f1b9c3c0d03 100644 --- a/test/types/schema.test.ts +++ b/test/types/schema.test.ts @@ -1635,6 +1635,13 @@ function gh14825() { expectAssignable({} as SchemaType); } +function gh8389() { + const schema = new Schema({ name: String, tags: [String] }); + + expectAssignable | undefined>(schema.path('name').getEmbeddedSchemaType()); + expectAssignable | undefined>(schema.path('tags').getEmbeddedSchemaType()); +} + function gh14879() { Schema.Types.String.setters.push((val?: unknown) => typeof val === 'string' ? val.trim() : val); } diff --git a/types/document.d.ts b/types/document.d.ts index 20f5de4c429..0d263ce1dd1 100644 --- a/types/document.d.ts +++ b/types/document.d.ts @@ -24,9 +24,6 @@ declare module 'mongoose' { /** This documents _id. */ _id: T; - /** This documents __v. */ - __v?: any; - /** Assert that a given path or paths is populated. Throws an error if not populated. */ $assertPopulated(path: string | string[], values?: Partial): Omit & Paths; diff --git a/types/index.d.ts b/types/index.d.ts index 02c975a4eb3..3ec72ac281d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -138,6 +138,10 @@ declare module 'mongoose' { ? IfAny> : T & { _id: Types.ObjectId }; + export type Default__v = T extends { __v?: infer U } + ? T + : T & { __v?: number }; + /** Helper type for getting the hydrated document type from the raw document type. The hydrated document type is what `new MyModel()` returns. */ export type HydratedDocument< DocType, @@ -147,12 +151,12 @@ declare module 'mongoose' { DocType, any, TOverrides extends Record ? - Document & Require_id : + Document & Default__v> : IfAny< TOverrides, - Document & Require_id, + Document & Default__v>, Document & MergeType< - Require_id, + Default__v>, TOverrides > > 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 diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index f5ca8ec0da9..aff686e1ec9 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -232,6 +232,9 @@ declare module 'mongoose' { /** Adds a getter to this schematype. */ get(fn: Function): this; + /** Gets this SchemaType's embedded SchemaType, if any */ + getEmbeddedSchemaType(): SchemaType | undefined; + /** * Defines this path as immutable. Mongoose prevents you from changing * immutable paths unless the parent document has [`isNew: true`](/docs/api/document.html#document_Document-isNew).