diff --git a/docs/guide.pug b/docs/guide.pug index c4f501f4cdd..39c021d6682 100644 --- a/docs/guide.pug +++ b/docs/guide.pug @@ -434,6 +434,7 @@ block content - [useNestedStrict](#useNestedStrict) - [validateBeforeSave](#validateBeforeSave) - [versionKey](#versionKey) + - [optimisticConcurrency](#optimisticConcurrency) - [collation](#collation) - [selectPopulatedPaths](#selectPopulatedPaths) - [skipVersioning](#skipVersioning) @@ -891,9 +892,8 @@ block content thing.save(); // { _somethingElse: 0, name: 'mongoose v3' } ``` - Note that Mongoose versioning is **not** a full [optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) - solution. Use [mongoose-update-if-current](https://github.com/eoin-obrien/mongoose-update-if-current) - for OCC support. Mongoose versioning only operates on arrays: + Note that Mongoose's default versioning is **not** a full [optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) + solution. Mongoose's default versioning only operates on arrays as shown below. ```javascript // 2 copies of the same document @@ -911,6 +911,8 @@ block content await doc2.save(); ``` + If you need optimistic concurrency support for `save()`, you can set the [`optimisticConcurrency` option](#optimisticConcurrency) + Document versioning can also be disabled by setting the `versionKey` to `false`. _DO NOT disable versioning unless you [know what you are doing](http://aaronheckmann.blogspot.com/2012/06/mongoose-v3-part-1-versioning.html)._ @@ -946,6 +948,70 @@ block content }); ``` +

option: optimisticConcurrency

+ + [Optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) is a strategy to ensure + the document you're updating didn't change between when you loaded it using `find()` or `findOne()`, and when + you update it using `save()`. + + For example, suppose you have a `House` model that contains a list of `photos`, and a `status` that represents + whether this house shows up in searches. Suppose that a house that has status `'APPROVED'` must have at least + two `photos`. You might implement the logic of approving a house document as shown below: + + ```javascript + async function markApproved(id) { + const house = await House.findOne({ _id }); + if (house.photos.length < 2) { + throw new Error('House must have at least two photos!'); + } + + house.status = 'APPROVED'; + await house.save(); + } + ``` + + The `markApproved()` function looks right in isolation, but there might be a potential issue: what if another + function removes the house's photos between the `findOne()` call and the `save()` call? For example, the below + code will succeed: + + ```javascript + const house = await House.findOne({ _id }); + if (house.photos.length < 2) { + throw new Error('House must have at least two photos!'); + } + + const house2 = await House.findOne({ _id }); + house2.photos = []; + await house2.save(); + + // Marks the house as 'APPROVED' even though it has 0 photos! + house.status = 'APPROVED'; + await house.save(); + ``` + + If you set the `optimisticConcurrency` option on the `House` model's schema, the above script will throw an + error. + + ```javascript + const House = mongoose.model('House', Schema({ + status: String, + photos: [String] + }, { optimisticConcurrency: true })); + + const house = await House.findOne({ _id }); + if (house.photos.length < 2) { + throw new Error('House must have at least two photos!'); + } + + const house2 = await House.findOne({ _id }); + house2.photos = []; + await house2.save(); + + // Throws 'VersionError: No matching document found for id "..." version 0' + house.status = 'APPROVED'; + await house.save(); + ``` +

option: collation

Sets a default [collation](https://docs.mongodb.com/manual/reference/collation/) diff --git a/lib/aggregate.js b/lib/aggregate.js index cc31f09b98d..d275c2ed4da 100644 --- a/lib/aggregate.js +++ b/lib/aggregate.js @@ -909,6 +909,32 @@ Aggregate.prototype.facet = function(options) { return this.append({ $facet: options }); }; +/** + * Helper for [Atlas Text Search](https://docs.atlas.mongodb.com/reference/atlas-search/tutorial/)'s + * `$search` stage. + * + * ####Example: + * + * Model.aggregate(). + * search({ + * text: { + * query: 'baseball', + * path: 'plot' + * } + * }); + * + * // Output: [{ plot: '...', title: '...' }] + * + * @param {Object} $search options + * @return {Aggregate} this + * @see $search https://docs.atlas.mongodb.com/reference/atlas-search/tutorial/ + * @api public + */ + +Aggregate.prototype.search = function(options) { + return this.append({ $search: options }); +}; + /** * Returns the current pipeline * diff --git a/lib/cast.js b/lib/cast.js index b047939948f..1c24bc7fe65 100644 --- a/lib/cast.js +++ b/lib/cast.js @@ -9,6 +9,7 @@ const StrictModeError = require('./error/strict'); const Types = require('./schema/index'); const castTextSearch = require('./schema/operators/text'); const get = require('./helpers/get'); +const getSchemaDiscriminatorByValue = require('./helpers/discriminator/getSchemaDiscriminatorByValue'); const isOperator = require('./helpers/query/isOperator'); const util = require('util'); const isObject = require('./helpers/isObject'); @@ -42,6 +43,10 @@ module.exports = function cast(schema, obj, options, context) { delete obj._bsontype; } + if (schema != null && schema.discriminators != null && obj[schema.options.discriminatorKey] != null) { + schema = getSchemaDiscriminatorByValue(schema, obj[schema.options.discriminatorKey]) || schema; + } + const paths = Object.keys(obj); let i = paths.length; let _keys; diff --git a/lib/connection.js b/lib/connection.js index dedcbb10cec..f55570b4145 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -22,6 +22,9 @@ const utils = require('./utils'); const parseConnectionString = require('mongodb/lib/core').parseConnectionString; +const arrayAtomicsSymbol = require('./helpers/symbols').arrayAtomicsSymbol; +const sessionNewDocuments = require('./helpers/symbols').sessionNewDocuments; + let id = 0; /*! @@ -417,6 +420,76 @@ Connection.prototype.startSession = _wrapConnHelper(function startSession(option cb(null, session); }); +/** + * _Requires MongoDB >= 3.6.0._ Executes the wrapped async function + * in a transaction. Mongoose will commit the transaction if the + * async function executes successfully and attempt to retry if + * there was a retriable error. + * + * Calls the MongoDB driver's [`session.withTransaction()`](http://mongodb.github.io/node-mongodb-native/3.5/api/ClientSession.html#withTransaction), + * but also handles resetting Mongoose document state as shown below. + * + * ####Example: + * + * const doc = new Person({ name: 'Will Riker' }); + * await db.transaction(async function setRank(session) { + * doc.rank = 'Captain'; + * await doc.save({ session }); + * doc.isNew; // false + * + * // Throw an error to abort the transaction + * throw new Error('Oops!'); + * }).catch(() => {}); + * + * // true, `transaction()` reset the document's state because the + * // transaction was aborted. + * doc.isNew; + * + * @method transaction + * @param {Function} fn Function to execute in a transaction + * @return {Promise} promise that resolves to the returned value of `fn` + * @api public + */ + +Connection.prototype.transaction = function transaction(fn) { + return this.startSession().then(session => { + session[sessionNewDocuments] = new Map(); + return session.withTransaction(() => fn(session)). + then(res => { + delete session[sessionNewDocuments]; + return res; + }). + catch(err => { + // If transaction was aborted, we need to reset newly + // inserted documents' `isNew`. + for (const doc of session[sessionNewDocuments].keys()) { + const state = session[sessionNewDocuments].get(doc); + if (state.hasOwnProperty('isNew')) { + doc.isNew = state.isNew; + } + if (state.hasOwnProperty('versionKey')) { + doc.set(doc.schema.options.versionKey, state.versionKey); + } + + for (const path of state.modifiedPaths) { + doc.$__.activePaths.paths[path] = 'modify'; + doc.$__.activePaths.states.modify[path] = true; + } + + for (const path of state.atomics.keys()) { + const val = doc.$__getValue(path); + if (val == null) { + continue; + } + val[arrayAtomicsSymbol] = state.atomics.get(path); + } + } + delete session[sessionNewDocuments]; + throw err; + }); + }); +}; + /** * Helper for `dropCollection()`. Will delete the given collection, including * all documents and indexes. @@ -562,9 +635,6 @@ Connection.prototype.onOpen = function() { */ Connection.prototype.openUri = function(uri, options, callback) { - this.readyState = STATES.connecting; - this._closeCalled = false; - if (typeof options === 'function') { callback = options; options = null; @@ -589,6 +659,23 @@ Connection.prototype.openUri = function(uri, options, callback) { typeof callback + '"'); } + if (this.readyState === STATES.connecting || this.readyState === STATES.connected) { + if (this._connectionString !== uri) { + throw new MongooseError('Can\'t call `openUri()` on an active connection with ' + + 'different connection strings. Make sure you aren\'t calling `mongoose.connect()` ' + + 'multiple times. See: https://mongoosejs.com/docs/connections.html#multiple_connections'); + } + + if (typeof callback === 'function') { + callback(null, this); + } + return this; + } + + this._connectionString = uri; + this.readyState = STATES.connecting; + this._closeCalled = false; + const Promise = PromiseProvider.get(); const _this = this; @@ -695,19 +782,6 @@ Connection.prototype.openUri = function(uri, options, callback) { }); }); - const _handleReconnect = () => { - // If we aren't disconnected, we assume this reconnect is due to a - // socket timeout. If there's no activity on a socket for - // `socketTimeoutMS`, the driver will attempt to reconnect and emit - // this event. - if (_this.readyState !== STATES.connected) { - _this.readyState = STATES.connected; - _this.emit('reconnect'); - _this.emit('reconnected'); - _this.onOpen(); - } - }; - const promise = new Promise((resolve, reject) => { const client = new mongodb.MongoClient(uri, options); _this.client = client; @@ -717,111 +791,9 @@ Connection.prototype.openUri = function(uri, options, callback) { return reject(error); } - const db = dbName != null ? client.db(dbName) : client.db(); - _this.db = db; - - // `useUnifiedTopology` events - const type = get(db, 's.topology.s.description.type', ''); - if (options.useUnifiedTopology) { - if (type === 'Single') { - const server = Array.from(db.s.topology.s.servers.values())[0]; - - server.s.topology.on('serverHeartbeatSucceeded', () => { - _handleReconnect(); - }); - server.s.pool.on('reconnect', () => { - _handleReconnect(); - }); - client.on('serverDescriptionChanged', ev => { - const newDescription = ev.newDescription; - if (newDescription.type === 'Standalone') { - _handleReconnect(); - } else { - _this.readyState = STATES.disconnected; - } - }); - } else if (type.startsWith('ReplicaSet')) { - client.on('topologyDescriptionChanged', ev => { - // Emit disconnected if we've lost connectivity to _all_ servers - // in the replica set. - const description = ev.newDescription; - const servers = Array.from(ev.newDescription.servers.values()); - const allServersDisconnected = description.type === 'ReplicaSetNoPrimary' && - servers.reduce((cur, d) => cur || d.type === 'Unknown', false); - if (_this.readyState === STATES.connected && allServersDisconnected) { - // Implicitly emits 'disconnected' - _this.readyState = STATES.disconnected; - } else if (_this.readyState === STATES.disconnected && !allServersDisconnected) { - _handleReconnect(); - } - }); - - db.on('close', function() { - const type = get(db, 's.topology.s.description.type', ''); - if (type !== 'ReplicaSetWithPrimary') { - // Implicitly emits 'disconnected' - _this.readyState = STATES.disconnected; - } - }); - } - } - - // Backwards compat for mongoose 4.x - db.on('reconnect', function() { - _handleReconnect(); - }); - db.s.topology.on('reconnectFailed', function() { - _this.emit('reconnectFailed'); - }); - - if (!options.useUnifiedTopology) { - db.s.topology.on('left', function(data) { - _this.emit('left', data); - }); - } - db.s.topology.on('joined', function(data) { - _this.emit('joined', data); - }); - db.s.topology.on('fullsetup', function(data) { - _this.emit('fullsetup', data); - }); - if (get(db, 's.topology.s.coreTopology.s.pool') != null) { - db.s.topology.s.coreTopology.s.pool.on('attemptReconnect', function() { - _this.emit('attemptReconnect'); - }); - } - if (!options.useUnifiedTopology || !type.startsWith('ReplicaSet')) { - db.on('close', function() { - // Implicitly emits 'disconnected' - _this.readyState = STATES.disconnected; - }); - } - - if (!options.useUnifiedTopology) { - client.on('left', function() { - if (_this.readyState === STATES.connected && - get(db, 's.topology.s.coreTopology.s.replicaSetState.topologyType') === 'ReplicaSetNoPrimary') { - _this.readyState = STATES.disconnected; - } - }); - } - - db.on('timeout', function() { - _this.emit('timeout'); - }); - - delete _this.then; - delete _this.catch; - _this.readyState = STATES.connected; - - for (const i in _this.collections) { - if (utils.object.hasOwnProperty(_this.collections, i)) { - _this.collections[i].onOpen(); - } - } + _setClient(_this, client, options, dbName); resolve(_this); - _this.emit('open'); }); }); @@ -855,6 +827,126 @@ Connection.prototype.openUri = function(uri, options, callback) { return this; }; +function _setClient(conn, client, options, dbName) { + const db = dbName != null ? client.db(dbName) : client.db(); + conn.db = db; + conn.client = client; + + const _handleReconnect = () => { + // If we aren't disconnected, we assume this reconnect is due to a + // socket timeout. If there's no activity on a socket for + // `socketTimeoutMS`, the driver will attempt to reconnect and emit + // this event. + if (conn.readyState !== STATES.connected) { + conn.readyState = STATES.connected; + conn.emit('reconnect'); + conn.emit('reconnected'); + conn.onOpen(); + } + }; + + // `useUnifiedTopology` events + const type = get(db, 's.topology.s.description.type', ''); + if (options.useUnifiedTopology) { + if (type === 'Single') { + const server = Array.from(db.s.topology.s.servers.values())[0]; + server.s.topology.on('serverHeartbeatSucceeded', () => { + _handleReconnect(); + }); + server.s.pool.on('reconnect', () => { + _handleReconnect(); + }); + client.on('serverDescriptionChanged', ev => { + const newDescription = ev.newDescription; + if (newDescription.type === 'Standalone') { + _handleReconnect(); + } else { + conn.readyState = STATES.disconnected; + } + }); + } else if (type.startsWith('ReplicaSet')) { + client.on('topologyDescriptionChanged', ev => { + // Emit disconnected if we've lost connectivity to _all_ servers + // in the replica set. + const description = ev.newDescription; + const servers = Array.from(ev.newDescription.servers.values()); + const allServersDisconnected = description.type === 'ReplicaSetNoPrimary' && + servers.reduce((cur, d) => cur || d.type === 'Unknown', false); + if (conn.readyState === STATES.connected && allServersDisconnected) { + // Implicitly emits 'disconnected' + conn.readyState = STATES.disconnected; + } else if (conn.readyState === STATES.disconnected && !allServersDisconnected) { + _handleReconnect(); + } + }); + + db.on('close', function() { + const type = get(db, 's.topology.s.description.type', ''); + if (type !== 'ReplicaSetWithPrimary') { + // Implicitly emits 'disconnected' + conn.readyState = STATES.disconnected; + } + }); + } + } + + // Backwards compat for mongoose 4.x + db.on('reconnect', function() { + _handleReconnect(); + }); + db.s.topology.on('reconnectFailed', function() { + conn.emit('reconnectFailed'); + }); + + if (!options.useUnifiedTopology) { + db.s.topology.on('left', function(data) { + conn.emit('left', data); + }); + } + db.s.topology.on('joined', function(data) { + conn.emit('joined', data); + }); + db.s.topology.on('fullsetup', function(data) { + conn.emit('fullsetup', data); + }); + if (get(db, 's.topology.s.coreTopology.s.pool') != null) { + db.s.topology.s.coreTopology.s.pool.on('attemptReconnect', function() { + conn.emit('attemptReconnect'); + }); + } + if (!options.useUnifiedTopology || !type.startsWith('ReplicaSet')) { + db.on('close', function() { + // Implicitly emits 'disconnected' + conn.readyState = STATES.disconnected; + }); + } + + if (!options.useUnifiedTopology) { + client.on('left', function() { + if (conn.readyState === STATES.connected && + get(db, 's.topology.s.coreTopology.s.replicaSetState.topologyType') === 'ReplicaSetNoPrimary') { + conn.readyState = STATES.disconnected; + } + }); + } + + db.on('timeout', function() { + conn.emit('timeout'); + }); + + delete conn.then; + delete conn.catch; + conn.readyState = STATES.connected; + + for (const i in conn.collections) { + if (utils.object.hasOwnProperty(conn.collections, i)) { + conn.collections[i].onOpen(); + } + } + + conn.emit('open'); +} + /*! * ignore */ @@ -1269,6 +1361,57 @@ Connection.prototype.optionsProvideAuthenticationData = function(options) { ((options.pass) || this.authMechanismDoesNotRequirePassword()); }; +/** + * Returns the [MongoDB driver `MongoClient`](http://mongodb.github.io/node-mongodb-native/3.5/api/MongoClient.html) instance + * that this connection uses to talk to MongoDB. + * + * ####Example: + * const conn = await mongoose.createConnection('mongodb://localhost:27017/test'); + * + * conn.getClient(); // MongoClient { ... } + * + * @api public + * @return {MongoClient} + */ + +Connection.prototype.getClient = function getClient() { + return this.client; +}; + +/** + * Set the [MongoDB driver `MongoClient`](http://mongodb.github.io/node-mongodb-native/3.5/api/MongoClient.html) instance + * that this connection uses to talk to MongoDB. This is useful if you already have a MongoClient instance, and want to + * reuse it. + * + * ####Example: + * const client = await mongodb.MongoClient.connect('mongodb://localhost:27017/test'); + * + * const conn = mongoose.createConnection().setClient(client); + * + * conn.getClient(); // MongoClient { ... } + * conn.readyState; // 1, means 'CONNECTED' + * + * @api public + * @return {Connection} this + */ + +Connection.prototype.setClient = function setClient(client) { + if (!(client instanceof mongodb.MongoClient)) { + throw new MongooseError('Must call `setClient()` with an instance of MongoClient'); + } + if (this.client != null || this.readyState !== STATES.disconnected) { + throw new MongooseError('Cannot call `setClient()` on a connection that is already connected.'); + } + if (!client.isConnected()) { + throw new MongooseError('Cannot call `setClient()` with a MongoClient that is not connected.'); + } + + this._connectionString = client.s.url; + _setClient(this, client, { useUnifiedTopology: client.s.options.useUnifiedTopology }, client.s.options.dbName); + + return this; +}; + /** * Switches to a different database using the same connection pool. * diff --git a/lib/document.js b/lib/document.js index 0c5946c97db..57060f507d5 100644 --- a/lib/document.js +++ b/lib/document.js @@ -30,6 +30,7 @@ const isExclusive = require('./helpers/projection/isExclusive'); const inspect = require('util').inspect; const internalToObjectOptions = require('./options').internalToObjectOptions; const mpath = require('mpath'); +const queryhelpers = require('./queryhelpers'); const utils = require('./utils'); const isPromise = require('./helpers/isPromise'); @@ -56,7 +57,8 @@ const specialProperties = utils.specialProperties; * * @param {Object} obj the values to set * @param {Object} [fields] optional object containing the fields which were selected in the query returning this document and any populated paths data - * @param {Boolean} [skipId] bool, should we auto create an ObjectId _id + * @param {Object} [options] various configuration options for the document + * @param {Boolean} [options.defaults=true] if `false`, skip applying default values to this document. * @inherits NodeJS EventEmitter http://nodejs.org/api/events.html#events_class_events_eventemitter * @event `init`: Emitted on a document after it has been retrieved from the db and fully hydrated by Mongoose. * @event `save`: Emitted when the document is successfully saved @@ -68,7 +70,9 @@ function Document(obj, fields, skipId, options) { options = skipId; skipId = options.skipId; } - options = options || {}; + options = Object.assign({}, options); + const defaults = get(options, 'defaults', true); + options.defaults = defaults; // Support `browserDocument.js` syntax if (this.schema == null) { @@ -127,9 +131,11 @@ function Document(obj, fields, skipId, options) { // By default, defaults get applied **before** setting initial values // Re: gh-6155 - $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, true, { - isNew: this.isNew - }); + if (defaults) { + $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, true, { + isNew: this.isNew + }); + } } if (obj) { @@ -148,13 +154,13 @@ function Document(obj, fields, skipId, options) { // Function defaults get applied **after** setting initial values so they // see the full doc rather than an empty one, unless they opt out. // Re: gh-3781, gh-6155 - if (options.willInit) { + if (options.willInit && defaults) { EventEmitter.prototype.once.call(this, 'init', () => { $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, false, options.skipDefaults, { isNew: this.isNew }); }); - } else { + } else if (defaults) { $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, false, options.skipDefaults, { isNew: this.isNew }); @@ -542,6 +548,16 @@ Document.prototype.$__init = function(doc, opts) { } else { this.populated(item.path, item._docs[id], item); } + + if (item._childDocs == null) { + continue; + } + for (const child of item._childDocs) { + if (child == null || child.$__ == null) { + continue; + } + child.$__.parent = this; + } } } @@ -3106,6 +3122,10 @@ Document.prototype.$toObject = function(options, json) { applySchemaTypeTransforms(this, ret); } + if (options.useProjection) { + omitDeselectedFields(this, ret); + } + if (transform === true || (schemaOptions.toObject && transform)) { const opts = options.json ? schemaOptions.toJSON : schemaOptions.toObject; @@ -3141,6 +3161,7 @@ Document.prototype.$toObject = function(options, json) { * - `depopulate` depopulate any populated paths, replacing them with their original refs, defaults to false * - `versionKey` whether to include the version key, defaults to true * - `flattenMaps` convert Maps to POJOs. Useful if you want to JSON.stringify() the result of toObject(), defaults to false + * - `useProjection` set to `true` to omit fields that are excluded in this document's projection. Unless you specified a projection, this will omit any field that has `select: false` in the schema. * * ####Getters/Virtuals * @@ -3265,6 +3286,7 @@ Document.prototype.$toObject = function(options, json) { * @param {Boolean} [options.depopulate=false] if true, replace any conventionally populated paths with the original id in the output. Has no affect on virtual populated paths. * @param {Boolean} [options.versionKey=true] if false, exclude the version key (`__v` by default) from the output * @param {Boolean} [options.flattenMaps=false] if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`. + * @param {Boolean} [options.useProjection=false] - If true, omits fields that are excluded in this document's projection. Unless you specified a projection, this will omit any field that has `select: false` in the schema. * @return {Object} js object * @see mongodb.Binary http://mongodb.github.com/node-mongodb-native/api-bson-generated/binary.html * @api public @@ -3468,6 +3490,37 @@ function throwErrorIfPromise(path, transformedValue) { } } +/*! + * ignore + */ + +function omitDeselectedFields(self, json) { + const schema = self.schema; + const paths = Object.keys(schema.paths || {}); + const cur = self._doc; + + if (!cur) { + return json; + } + + let selected = self.$__.selected; + if (selected === void 0) { + selected = {}; + queryhelpers.applyPaths(selected, schema); + } + if (selected == null || Object.keys(selected).length === 0) { + return json; + } + + for (const path of paths) { + if (selected[path] != null && !selected[path]) { + delete json[path]; + } + } + + return json; +} + /** * The return value of this method is used in calls to JSON.stringify(doc). * @@ -3490,6 +3543,20 @@ Document.prototype.toJSON = function(options) { return this.$toObject(options, true); }; +/** + * If this document is a subdocument or populated document, returns the document's + * parent. Returns `undefined` otherwise. + * + * @api public + * @method parent + * @memberOf Document + * @instance + */ + +Document.prototype.parent = function() { + return this.$__.parent; +}; + /** * Helper for console.log * @@ -3863,6 +3930,45 @@ Document.prototype.$__fullPath = function(path) { return path || ''; }; +/** + * Returns the changes that happened to the document + * in the format that will be sent to MongoDB. + * + * ###Example: + * const userSchema = new Schema({ + * name: String, + * age: Number, + * country: String + * }); + * const User = mongoose.model('User', userSchema); + * const user = await User.create({ + * name: 'Hafez', + * age: 25, + * country: 'Egypt' + * }); + * + * // returns an empty object, no changes happened yet + * user.getChanges(); // { } + * + * user.country = undefined; + * user.age = 26; + * + * user.getChanges(); // { $set: { age: 26 }, { $unset: { country: 1 } } } + * + * await user.save(); + * + * user.getChanges(); // { } + * + * @return {Object} changes + */ + +Document.prototype.getChanges = function() { + const delta = this.$__delta(); + + const changes = delta ? delta[1] : {}; + return changes; +}; + /*! * Module exports. */ diff --git a/lib/helpers/discriminator/getSchemaDiscriminatorByValue.js b/lib/helpers/discriminator/getSchemaDiscriminatorByValue.js new file mode 100644 index 00000000000..f3e71a093a9 --- /dev/null +++ b/lib/helpers/discriminator/getSchemaDiscriminatorByValue.js @@ -0,0 +1,24 @@ +'use strict'; + +/*! +* returns discriminator by discriminatorMapping.value +* +* @param {Schema} schema +* @param {string} value +*/ + +module.exports = function getSchemaDiscriminatorByValue(schema, value) { + if (schema == null || schema.discriminators == null) { + return null; + } + for (const key of Object.keys(schema.discriminators)) { + const discriminatorSchema = schema.discriminators[key]; + if (discriminatorSchema.discriminatorMapping == null) { + continue; + } + if (discriminatorSchema.discriminatorMapping.value === value) { + return discriminatorSchema; + } + } + return null; +}; \ No newline at end of file diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 0455562f461..6e7a8300754 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -37,8 +37,12 @@ module.exports = function castBulkWrite(originalModel, op, options) { } else if (op['updateOne']) { return (callback) => { try { - if (!op['updateOne']['filter']) throw new Error('Must provide a filter object.'); - if (!op['updateOne']['update']) throw new Error('Must provide an update object.'); + if (!op['updateOne']['filter']) { + throw new Error('Must provide a filter object.'); + } + if (!op['updateOne']['update']) { + throw new Error('Must provide an update object.'); + } const model = decideModelByObject(originalModel, op['updateOne']['filter']); const schema = model.schema; @@ -70,8 +74,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { strict: strict, overwrite: false, upsert: op['updateOne'].upsert - }); - + }, model, op['updateOne']['filter']); } catch (error) { return callback(error, null); } @@ -81,8 +84,12 @@ module.exports = function castBulkWrite(originalModel, op, options) { } else if (op['updateMany']) { return (callback) => { try { - if (!op['updateMany']['filter']) throw new Error('Must provide a filter object.'); - if (!op['updateMany']['update']) throw new Error('Must provide an update object.'); + if (!op['updateMany']['filter']) { + throw new Error('Must provide a filter object.'); + } + if (!op['updateMany']['update']) { + throw new Error('Must provide an update object.'); + } const model = decideModelByObject(originalModel, op['updateMany']['filter']); const schema = model.schema; @@ -114,7 +121,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { strict: strict, overwrite: false, upsert: op['updateMany'].upsert - }); + }, model, op['updateMany']['filter']); } catch (error) { return callback(error, null); @@ -152,6 +159,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { if (error) { return callback(error, null); } + op['replaceOne']['replacement'] = op['replaceOne']['replacement'].toBSON(); callback(null); }); }; diff --git a/lib/helpers/populate/assignVals.js b/lib/helpers/populate/assignVals.js index 948d8151222..798482197db 100644 --- a/lib/helpers/populate/assignVals.js +++ b/lib/helpers/populate/assignVals.js @@ -104,6 +104,16 @@ module.exports = function assignVals(o) { }, new Map()); } + if (isDoc && Array.isArray(valueToSet)) { + for (const val of valueToSet) { + if (val != null && val.$__ != null) { + val.$__.parent = docs[i]; + } + } + } else if (isDoc && valueToSet != null && valueToSet.$__ != null) { + valueToSet.$__.parent = docs[i]; + } + if (o.isVirtual && isDoc) { docs[i].populated(o.path, o.justOne ? originalIds[0] : originalIds, o.allOptions); // If virtual populate and doc is already init-ed, need to walk through diff --git a/lib/helpers/query/castUpdate.js b/lib/helpers/query/castUpdate.js index 327d9d2f862..8afd60af8f7 100644 --- a/lib/helpers/query/castUpdate.js +++ b/lib/helpers/query/castUpdate.js @@ -105,6 +105,14 @@ module.exports = function castUpdate(schema, obj, options, context, filter) { } } + if (Object.keys(ret).length === 0 && + options.upsert && + Object.keys(filter).length > 0) { + // Trick the driver into allowing empty upserts to work around + // https://github.com/mongodb/node-mongodb-native/pull/2490 + return { $setOnInsert: filter }; + } + return ret; }; diff --git a/lib/helpers/setDefaultsOnInsert.js b/lib/helpers/setDefaultsOnInsert.js index 9c6986ddef6..14e3f1b2572 100644 --- a/lib/helpers/setDefaultsOnInsert.js +++ b/lib/helpers/setDefaultsOnInsert.js @@ -14,6 +14,17 @@ const get = require('./get'); */ module.exports = function(filter, schema, castedDoc, options) { + options = options || {}; + + const shouldSetDefaultsOnInsert = + options.setDefaultsOnInsert != null ? + options.setDefaultsOnInsert : + schema.base.options.setDefaultsOnInsert; + + if (!options.upsert || !shouldSetDefaultsOnInsert) { + return castedDoc; + } + const keys = Object.keys(castedDoc || {}); const updatedKeys = {}; const updatedValues = {}; @@ -22,12 +33,6 @@ module.exports = function(filter, schema, castedDoc, options) { let hasDollarUpdate = false; - options = options || {}; - - if (!options.upsert || !options.setDefaultsOnInsert) { - return castedDoc; - } - for (let i = 0; i < numKeys; ++i) { if (keys[i].startsWith('$')) { modifiedPaths(castedDoc[keys[i]], '', modified); diff --git a/lib/helpers/symbols.js b/lib/helpers/symbols.js index 746f8935557..ef0ddd573f2 100644 --- a/lib/helpers/symbols.js +++ b/lib/helpers/symbols.js @@ -11,5 +11,6 @@ exports.modelSymbol = Symbol('mongoose#Model'); exports.objectIdSymbol = Symbol('mongoose#ObjectId'); exports.populateModelSymbol = Symbol('mongoose.PopulateOptions#Model'); exports.schemaTypeSymbol = Symbol('mongoose#schemaType'); +exports.sessionNewDocuments = Symbol('mongoose:ClientSession#newDocuments'); exports.scopeSymbol = Symbol('mongoose#Document#scope'); exports.validatorErrorSymbol = Symbol('mongoose:validatorError'); \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index a1071653faf..0fa9f2d7567 100644 --- a/lib/index.js +++ b/lib/index.js @@ -34,6 +34,7 @@ const pkg = require('../package.json'); const cast = require('./cast'); const removeSubdocs = require('./plugins/removeSubdocs'); const saveSubdocs = require('./plugins/saveSubdocs'); +const trackTransaction = require('./plugins/trackTransaction'); const validateBeforeSave = require('./plugins/validateBeforeSave'); const Aggregate = require('./aggregate'); @@ -106,7 +107,8 @@ function Mongoose(options) { [saveSubdocs, { deduplicate: true }], [validateBeforeSave, { deduplicate: true }], [shardingPlugin, { deduplicate: true }], - [removeSubdocs, { deduplicate: true }] + [removeSubdocs, { deduplicate: true }], + [trackTransaction, { deduplicate: true }] ] }); } @@ -145,6 +147,7 @@ Mongoose.prototype.driver = require('./driver'); * * Currently supported options are: * - 'debug': If `true`, prints the operations mongoose sends to MongoDB to the console. If a writable stream is passed, it will log to that stream, without colorization. If a callback function is passed, it will receive the collection name, the method name, then all arugments passed to the method. For example, if you wanted to replicate the default logging, you could output from the callback `Mongoose: ${collectionName}.${methodName}(${methodArgs.join(', ')})`. + * - 'returnOriginal': If `false`, changes the default `returnOriginal` option to `findOneAndUpdate()`, `findByIdAndUpdate`, and `findOneAndReplace()` to false. This is equivalent to setting the `new` option to `true` for `findOneAndX()` calls by default. Read our [`findOneAndUpdate()` tutorial](/docs/tutorials/findoneandupdate.html) for more information. * - 'bufferCommands': enable/disable mongoose's buffering mechanism for all connections and models * - 'useCreateIndex': false by default. Set to `true` to make Mongoose's default index build use `createIndex()` instead of `ensureIndex()` to avoid deprecation warnings from the MongoDB driver. * - 'useFindAndModify': true by default. Set to `false` to make `findOneAndUpdate()` and `findOneAndRemove()` use native `findOneAndUpdate()` rather than `findAndModify()`. @@ -158,6 +161,7 @@ Mongoose.prototype.driver = require('./driver'); * - 'toObject': `{ transform: true, flattenDecimals: true }` by default. Overwrites default objects to [`toObject()`](/docs/api.html#document_Document-toObject) * - 'toJSON': `{ transform: true, flattenDecimals: true }` by default. Overwrites default objects to [`toJSON()`](/docs/api.html#document_Document-toJSON), for determining how Mongoose documents get serialized by `JSON.stringify()` * - 'strict': true by default, may be `false`, `true`, or `'throw'`. Sets the default strict mode for schemas. + * - 'strictQuery': false by default, may be `false`, `true`, or `'throw'`. Sets the default [strictQuery](/docs/guide.html#strictQuery) mode for schemas. * - 'selectPopulatedPaths': true by default. Set to false to opt out of Mongoose adding all fields that you `populate()` to your `select()`. The schema-level option `selectPopulatedPaths` overwrites this one. * - 'typePojoToMixed': true by default, may be `false` or `true`. Sets the default typePojoToMixed for schemas. * - 'maxTimeMS': If set, attaches [maxTimeMS](https://docs.mongodb.com/manual/reference/operator/meta/maxTimeMS/) to every query diff --git a/lib/model.js b/lib/model.js index da0c601aee1..00efe95f740 100644 --- a/lib/model.js +++ b/lib/model.js @@ -542,6 +542,11 @@ function operand(self, where, delta, data, val, op) { // already marked for versioning? if (VERSION_ALL === (VERSION_ALL & self.$__.version)) return; + if (self.schema.options.optimisticConcurrency) { + self.$__.version = VERSION_ALL; + return; + } + switch (op) { case '$set': case '$unset': @@ -1357,7 +1362,7 @@ Model.syncIndexes = function syncIndexes(options, callback) { cb = this.$wrapCallback(cb); this.createCollection(err => { - if (err) { + if (err != null && err.codeName !== 'NamespaceExists') { return cb(err); } this.cleanIndexes((err, dropped) => { @@ -2412,7 +2417,7 @@ Model.$where = function $where() { * @param {Object} [conditions] * @param {Object} [update] * @param {Object} [options] optional see [`Query.prototype.setOptions()`](http://mongoosejs.com/docs/api.html#query_Query-setOptions) - * @param {Boolean} [options.new=false] By default, `findOneAndUpdate()` returns the document as it was **before** `update` was applied. If you set `new: true`, `findOneAndUpdate()` will instead give you the object after `update` was applied. + * @param {Boolean} [options.new=false] By default, `findOneAndUpdate()` returns the document as it was **before** `update` was applied. If you set `new: true`, `findOneAndUpdate()` will instead give you the object after `update` was applied. To change the default to `true`, use `mongoose.set('returnOriginal', false);`. * @param {Object} [options.lean] if truthy, mongoose will return the document as a plain JavaScript object rather than a mongoose document. See [`Query.lean()`](/docs/api.html#query_Query-lean) and [the Mongoose lean tutorial](/docs/tutorials/lean.html). * @param {ClientSession} [options.session=null] The session associated with this query. See [transactions docs](/docs/transactions.html). * @param {Boolean|String} [options.strict] overwrites the schema's [strict mode option](http://mongoosejs.com/docs/guide.html#strict) @@ -2555,7 +2560,7 @@ function _decorateUpdateWithVersionKey(update, options, versionKey) { * @param {Object|Number|String} id value of `_id` to query by * @param {Object} [update] * @param {Object} [options] optional see [`Query.prototype.setOptions()`](http://mongoosejs.com/docs/api.html#query_Query-setOptions) - * @param {Boolean} [options.new=false] By default, `findByIdAndUpdate()` returns the document as it was **before** `update` was applied. If you set `new: true`, `findOneAndUpdate()` will instead give you the object after `update` was applied. + * @param {Boolean} [options.new=false] By default, `findByIdAndUpdate()` returns the document as it was **before** `update` was applied. If you set `new: true`, `findOneAndUpdate()` will instead give you the object after `update` was applied. To change the default to `true`, use `mongoose.set('returnOriginal', false);`. * @param {Object} [options.lean] if truthy, mongoose will return the document as a plain JavaScript object rather than a mongoose document. See [`Query.lean()`](/docs/api.html#query_Query-lean) and [the Mongoose lean tutorial](/docs/tutorials/lean.html). * @param {ClientSession} [options.session=null] The session associated with this query. See [transactions docs](/docs/transactions.html). * @param {Boolean|String} [options.strict] overwrites the schema's [strict mode option](http://mongoosejs.com/docs/guide.html#strict) @@ -2754,7 +2759,7 @@ Model.findByIdAndDelete = function(id, options, callback) { * @param {Object} filter Replace the first document that matches this filter * @param {Object} [replacement] Replace with this document * @param {Object} [options] optional see [`Query.prototype.setOptions()`](http://mongoosejs.com/docs/api.html#query_Query-setOptions) - * @param {Boolean} [options.new=false] By default, `findOneAndUpdate()` returns the document as it was **before** `update` was applied. If you set `new: true`, `findOneAndUpdate()` will instead give you the object after `update` was applied. + * @param {Boolean} [options.new=false] By default, `findOneAndReplace()` returns the document as it was **before** `update` was applied. If you set `new: true`, `findOneAndReplace()` will instead give you the object after `update` was applied. To change the default to `true`, use `mongoose.set('returnOriginal', false);`. * @param {Object} [options.lean] if truthy, mongoose will return the document as a plain JavaScript object rather than a mongoose document. See [`Query.lean()`](/docs/api.html#query_Query-lean) and [the Mongoose lean tutorial](/docs/tutorials/lean.html). * @param {ClientSession} [options.session=null] The session associated with this query. See [transactions docs](/docs/transactions.html). * @param {Boolean|String} [options.strict] overwrites the schema's [strict mode option](http://mongoosejs.com/docs/guide.html#strict) @@ -4420,6 +4425,9 @@ function populate(model, docs, options, callback) { for (const arr of params) { const mod = arr[0]; const assignmentOpts = arr[3]; + for (const val of vals) { + mod.options._childDocs.push(val); + } _assign(model, vals, mod, assignmentOpts); } @@ -4840,7 +4848,7 @@ Model.$wrapCallback = function(callback) { if (err != null && err.name === 'MongoServerSelectionError') { arguments[0] = serverSelectionError.assimilateError(err); } - if (err != null && err.name === 'MongoNetworkError' && err.message.endsWith('timed out')) { + if (err != null && err.name === 'MongoNetworkTimeoutError' && err.message.endsWith('timed out')) { _this.db.emit('timeout'); } diff --git a/lib/options/PopulateOptions.js b/lib/options/PopulateOptions.js index b60d45abda6..5b9819460dc 100644 --- a/lib/options/PopulateOptions.js +++ b/lib/options/PopulateOptions.js @@ -5,6 +5,7 @@ const clone = require('../helpers/clone'); class PopulateOptions { constructor(obj) { this._docs = {}; + this._childDocs = []; if (obj == null) { return; diff --git a/lib/plugins/trackTransaction.js b/lib/plugins/trackTransaction.js new file mode 100644 index 00000000000..4a7ddc45c5d --- /dev/null +++ b/lib/plugins/trackTransaction.js @@ -0,0 +1,91 @@ +'use strict'; + +const arrayAtomicsSymbol = require('../helpers/symbols').arrayAtomicsSymbol; +const sessionNewDocuments = require('../helpers/symbols').sessionNewDocuments; + +module.exports = function trackTransaction(schema) { + schema.pre('save', function() { + const session = this.$session(); + if (session == null) { + return; + } + if (session.transaction == null || session[sessionNewDocuments] == null) { + return; + } + + if (!session[sessionNewDocuments].has(this)) { + const initialState = {}; + if (this.isNew) { + initialState.isNew = true; + } + if (this.schema.options.versionKey) { + initialState.versionKey = this.get(this.schema.options.versionKey); + } + + initialState.modifiedPaths = new Set(Object.keys(this.$__.activePaths.states.modify)); + initialState.atomics = _getAtomics(this); + + session[sessionNewDocuments].set(this, initialState); + } else { + const state = session[sessionNewDocuments].get(this); + + for (const path of Object.keys(this.$__.activePaths.states.modify)) { + state.modifiedPaths.add(path); + } + state.atomics = _getAtomics(this, state.atomics); + } + }); +}; + +function _getAtomics(doc, previous) { + const pathToAtomics = new Map(); + previous = previous || new Map(); + + const pathsToCheck = Object.keys(doc.$__.activePaths.init).concat(Object.keys(doc.$__.activePaths.modify)); + + for (const path of pathsToCheck) { + const val = doc.$__getValue(path); + if (val != null && + val instanceof Array && + val.isMongooseDocumentArray && + val.length && + val[arrayAtomicsSymbol] != null && + Object.keys(val[arrayAtomicsSymbol]).length > 0) { + const existing = previous.get(path) || {}; + pathToAtomics.set(path, mergeAtomics(existing, val[arrayAtomicsSymbol])); + } + } + + const dirty = doc.$__dirty(); + for (const dirt of dirty) { + const path = dirt.path; + + const val = dirt.value; + if (val != null && val[arrayAtomicsSymbol] != null && Object.keys(val[arrayAtomicsSymbol]).length > 0) { + const existing = previous.get(path) || {}; + pathToAtomics.set(path, mergeAtomics(existing, val[arrayAtomicsSymbol])); + } + } + + return pathToAtomics; +} + +function mergeAtomics(destination, source) { + destination = destination || {}; + + if (source.$pullAll != null) { + destination.$pullAll = (destination.$pullAll || []).concat(source.$pullAll); + } + if (source.$push != null) { + destination.$push = destination.$push || {}; + destination.$push.$each = (destination.$push.$each || []).concat(source.$push.$each); + } + if (source.$addToSet != null) { + destination.$addToSet = (destination.$addToSet || []).concat(source.$addToSet); + } + if (source.$set != null) { + destination.$set = Object.assign(destination.$set, source.$set); + } + + return destination; +} \ No newline at end of file diff --git a/lib/query.js b/lib/query.js index dbdd0f2ce8a..4bca9b90623 100644 --- a/lib/query.js +++ b/lib/query.js @@ -1293,6 +1293,7 @@ Query.prototype.getOptions = function() { * - [writeConcern](https://docs.mongodb.com/manual/reference/method/db.collection.update/) * - [timestamps](https://mongoosejs.com/docs/guide.html#timestamps): If `timestamps` is set in the schema, set this option to `false` to skip timestamps for that particular update. Has no effect if `timestamps` is not enabled in the schema options. * - omitUndefined: delete any properties whose value is `undefined` when casting an update. In other words, if this is set, Mongoose will delete `baz` from the update in `Model.updateOne({}, { foo: 'bar', baz: undefined })` before sending the update to the server. + * - overwriteDiscriminatorKey: allow setting the discriminator key in the update. Will use the correct discriminator schema if the update changes the discriminator key. * * The following options are only for `find()`, `findOne()`, `findById()`, `findOneAndUpdate()`, and `findByIdAndUpdate()`: * @@ -1361,6 +1362,10 @@ Query.prototype.setOptions = function(options, overwrite) { this._mongooseOptions.setDefaultsOnInsert = options.setDefaultsOnInsert; delete options.setDefaultsOnInsert; } + if ('overwriteDiscriminatorKey' in options) { + this._mongooseOptions.overwriteDiscriminatorKey = options.overwriteDiscriminatorKey; + delete options.overwriteDiscriminatorKey; + } return Query.base.setOptions.call(this, options); }; @@ -2999,20 +3004,25 @@ Query.prototype.findOneAndUpdate = function(criteria, doc, options, callback) { this._mergeUpdate(doc); } - if (options) { - options = utils.clone(options); - if (options.projection) { - this.select(options.projection); - delete options.projection; - } - if (options.fields) { - this.select(options.fields); - delete options.fields; - } + options = options ? utils.clone(options) : {}; - this.setOptions(options); + if (options.projection) { + this.select(options.projection); + delete options.projection; + } + if (options.fields) { + this.select(options.fields); + delete options.fields; } + + const returnOriginal = get(this, 'model.base.options.returnOriginal'); + if (options.returnOriginal == null && returnOriginal != null) { + options.returnOriginal = returnOriginal; + } + + this.setOptions(options); + if (!callback) { return this; } @@ -3330,7 +3340,14 @@ Query.prototype.findOneAndReplace = function(filter, replacement, options, callb this._mergeUpdate(replacement); } - options && this.setOptions(options); + options = options || {}; + + const returnOriginal = get(this, 'model.base.options.returnOriginal'); + if (options.returnOriginal == null && returnOriginal != null) { + options.returnOriginal = returnOriginal; + } + + this.setOptions(options); if (!callback) { return this; @@ -4485,6 +4502,19 @@ Query.prototype._post = function(fn) { Query.prototype._castUpdate = function _castUpdate(obj, overwrite) { let strict; + let schema = this.schema; + + const discriminatorKey = schema.options.discriminatorKey; + const baseSchema = schema._baseSchema ? schema._baseSchema : schema; + if (this._mongooseOptions.overwriteDiscriminatorKey && + obj[discriminatorKey] != null && + baseSchema.discriminators) { + const _schema = baseSchema.discriminators[obj[discriminatorKey]]; + if (_schema != null) { + schema = _schema; + } + } + if ('strict' in this._mongooseOptions) { strict = this._mongooseOptions.strict; } else if (this.schema && this.schema.options) { @@ -4508,7 +4538,6 @@ Query.prototype._castUpdate = function _castUpdate(obj, overwrite) { upsert = this.options.upsert; } - let schema = this.schema; const filter = this._conditions; if (schema != null && utils.hasUserDefinedProperty(filter, schema.options.discriminatorKey) && diff --git a/lib/schema.js b/lib/schema.js index ba67410f265..b7099cb8bac 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -69,7 +69,7 @@ let id = 0; * - [typePojoToMixed](/docs/guide.html#typePojoToMixed) - boolean - defaults to true. Determines whether a type set to a POJO becomes a Mixed path or a Subdocument * - [useNestedStrict](/docs/guide.html#useNestedStrict) - boolean - defaults to false * - [validateBeforeSave](/docs/guide.html#validateBeforeSave) - bool - defaults to `true` - * - [versionKey](/docs/guide.html#versionKey): string - defaults to "__v" + * - [versionKey](/docs/guide.html#versionKey): string or object - defaults to "__v" * - [collation](/docs/guide.html#collation): object - defaults to null (which means use no collation) * - [selectPopulatedPaths](/docs/guide.html#selectPopulatedPaths): boolean - defaults to `true` * - [skipVersioning](/docs/guide.html#skipVersioning): object - paths to exclude from versioning @@ -400,9 +400,11 @@ Schema.prototype.defaultOptions = function(options) { const baseOptions = get(this, 'base.options', {}); options = utils.options({ strict: 'strict' in baseOptions ? baseOptions.strict : true, + strictQuery: 'strictQuery' in baseOptions ? baseOptions.strictQuery : false, bufferCommands: true, capped: false, // { size, max, autoIndexId } versionKey: '__v', + optimisticConcurrency: false, discriminatorKey: '__t', minimize: true, autoIndex: null, @@ -422,6 +424,10 @@ Schema.prototype.defaultOptions = function(options) { options.read = readPref(options.read); } + if (options.optimisticConcurrency && !options.versionKey) { + throw new MongooseError('Must set `versionKey` if using `optimisticConcurrency`'); + } + return options; }; @@ -935,6 +941,14 @@ Schema.prototype.interpretAsType = function(path, obj, options) { if (options.hasOwnProperty('typePojoToMixed')) { childSchemaOptions.typePojoToMixed = options.typePojoToMixed; } + + if (this._userProvidedOptions.hasOwnProperty('_id')) { + childSchemaOptions._id = this._userProvidedOptions._id; + } else if (Schema.Types.DocumentArray.defaultOptions && + Schema.Types.DocumentArray.defaultOptions._id != null) { + childSchemaOptions._id = Schema.Types.DocumentArray.defaultOptions._id; + } + const childSchema = new Schema(cast, childSchemaOptions); childSchema.$implicitlyCreated = true; return new MongooseTypes.DocumentArray(path, childSchema, obj); diff --git a/lib/schema/SingleNestedPath.js b/lib/schema/SingleNestedPath.js index f05b7d272a1..d0108ee0e61 100644 --- a/lib/schema/SingleNestedPath.js +++ b/lib/schema/SingleNestedPath.js @@ -300,6 +300,26 @@ SingleNestedPath.prototype.discriminator = function(name, schema, value) { return this.caster.discriminators[name]; }; +/** + * Sets a default option for all SingleNestedPath instances. + * + * ####Example: + * + * // Make all numbers have option `min` equal to 0. + * mongoose.Schema.Embedded.set('required', true); + * + * @param {String} option - The option you'd like to set the value for + * @param {*} value - value for option + * @return {undefined} + * @function set + * @static + * @api public + */ + +SingleNestedPath.defaultOptions = {}; + +SingleNestedPath.set = SchemaType.set; + /*! * ignore */ diff --git a/lib/schema/documentarray.js b/lib/schema/documentarray.js index 5157684fdd0..066d13f1593 100644 --- a/lib/schema/documentarray.js +++ b/lib/schema/documentarray.js @@ -518,6 +518,26 @@ function scopePaths(array, fields, init) { return hasKeys && selected || undefined; } +/** + * Sets a default option for all DocumentArray instances. + * + * ####Example: + * + * // Make all numbers have option `min` equal to 0. + * mongoose.Schema.DocumentArray.set('_id', false); + * + * @param {String} option - The option you'd like to set the value for + * @param {*} value - value for option + * @return {undefined} + * @function set + * @static + * @api public + */ + +DocumentArrayPath.defaultOptions = {}; + +DocumentArrayPath.set = SchemaType.set; + /*! * Module exports. */ diff --git a/lib/types/subdocument.js b/lib/types/subdocument.js index 4142d82ced1..61b53f0e45e 100644 --- a/lib/types/subdocument.js +++ b/lib/types/subdocument.js @@ -29,7 +29,10 @@ function Subdocument(value, fields, parent, skipId, options) { } if (parent != null) { // If setting a nested path, should copy isNew from parent re: gh-7048 - options = Object.assign({}, options, { isNew: parent.isNew }); + options = Object.assign({}, options, { + isNew: parent.isNew, + defaults: parent.$__.$options.defaults + }); } Document.call(this, value, fields, skipId, options); @@ -50,6 +53,7 @@ function Subdocument(value, fields, parent, skipId, options) { } delete options.priorDoc; + delete this.$__.$options.priorDoc; } } diff --git a/lib/validoptions.js b/lib/validoptions.js index 9464e4d8611..6e50ec6b69d 100644 --- a/lib/validoptions.js +++ b/lib/validoptions.js @@ -14,17 +14,20 @@ const VALID_OPTIONS = Object.freeze([ 'debug', 'maxTimeMS', 'objectIdGetter', + 'returnOriginal', 'runValidators', 'selectPopulatedPaths', + 'setDefaultsOnInsert', 'strict', + 'strictQuery', 'toJSON', 'toObject', + 'typePojoToMixed', 'useCreateIndex', 'useFindAndModify', 'useNewUrlParser', 'usePushEach', - 'useUnifiedTopology', - 'typePojoToMixed' + 'useUnifiedTopology' ]); module.exports = VALID_OPTIONS; diff --git a/package.json b/package.json index b20b2b479db..28eba65c88c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dependencies": { "bson": "^1.1.4", "kareem": "2.3.1", - "mongodb": "3.5.10", + "mongodb": "3.6.0", "mongoose-legacy-pluralize": "1.0.2", "mpath": "0.7.0", "mquery": "3.2.2", diff --git a/test/connection.test.js b/test/connection.test.js index a8a169ce8c9..03c534098c2 100644 --- a/test/connection.test.js +++ b/test/connection.test.js @@ -10,6 +10,7 @@ const Promise = require('bluebird'); const Q = require('q'); const assert = require('assert'); const co = require('co'); +const mongodb = require('mongodb'); const server = require('./common').server; const mongoose = start.mongoose; @@ -1193,4 +1194,20 @@ describe('connections:', function() { assert.equal(db2.config.useCreateIndex, true); }); }); + + it('allows setting client on a disconnected connection (gh-9164)', function() { + return co(function*() { + const client = yield mongodb.MongoClient.connect('mongodb://localhost:27017/mongoose_test', { + useNewUrlParser: true, + useUnifiedTopology: true + }); + const conn = mongoose.createConnection().setClient(client); + + assert.equal(conn.readyState, 1); + + yield conn.createCollection('test'); + const res = yield conn.dropCollection('test'); + assert.ok(res); + }); + }); }); diff --git a/test/document.test.js b/test/document.test.js index 864e53c63f3..b05fe9f98c1 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -9021,6 +9021,52 @@ describe('document', function() { assert.equal(axl.fullName, 'Axl Rose'); }); + describe('Document#getChanges(...) (gh-9096)', function() { + it('returns an empty object when there are no changes', function() { + return co(function*() { + const User = db.model('User', { name: String, age: Number, country: String }); + const user = yield User.create({ name: 'Hafez', age: 25, country: 'Egypt' }); + + const changes = user.getChanges(); + assert.deepEqual(changes, {}); + }); + }); + + it('returns only the changed paths', function() { + return co(function*() { + const User = db.model('User', { name: String, age: Number, country: String }); + const user = yield User.create({ name: 'Hafez', age: 25, country: 'Egypt' }); + + user.country = undefined; + user.age = 26; + + const changes = user.getChanges(); + assert.deepEqual(changes, { $set: { age: 26 }, $unset: { country: 1 } }); + }); + }); + }); + + it('supports skipping defaults on a document (gh-8271)', function() { + const testSchema = new mongoose.Schema({ + testTopLevel: { type: String, default: 'foo' }, + testNested: { + prop: { type: String, default: 'bar' } + }, + testArray: [{ prop: { type: String, defualt: 'baz' } }], + testSingleNested: new Schema({ + prop: { type: String, default: 'qux' } + }) + }); + const Test = db.model('Test', testSchema); + + const doc = new Test({ testArray: [{}], testSingleNested: {} }, null, + { defaults: false }); + assert.ok(!doc.testTopLevel); + assert.ok(!doc.testNested.prop); + assert.ok(!doc.testArray[0].prop); + assert.ok(!doc.testSingleNested.prop); + }); + it('throws an error when `transform` returns a promise (gh-9163)', function() { const userSchema = new Schema({ name: { @@ -9057,6 +9103,18 @@ describe('document', function() { then(doc => assert.strictEqual(doc.obj.key, 2)); }); + it('supports `useProjection` option for `toObject()` (gh-9118)', function() { + const authorSchema = new mongoose.Schema({ + name: String, + hiddenField: { type: String, select: false } + }); + + const Author = db.model('Author', authorSchema); + + const example = new Author({ name: 'John', hiddenField: 'A secret' }); + assert.strictEqual(example.toJSON({ useProjection: true }).hiddenField, void 0); + }); + it('clears out priorDoc after overwriting single nested subdoc (gh-9208)', function() { const TestModel = db.model('Test', Schema({ nested: Schema({ diff --git a/test/es-next/transactions.test.es6.js b/test/es-next/transactions.test.es6.js index 806d9bcac68..560bcbfc33c 100644 --- a/test/es-next/transactions.test.es6.js +++ b/test/es-next/transactions.test.es6.js @@ -1,7 +1,6 @@ 'use strict'; const assert = require('assert'); -const co = require('co'); const start = require('../common'); const mongoose = start.mongoose; @@ -353,4 +352,56 @@ describe('transactions', function() { }); session.endSession(); }); + + it('correct `isNew` after abort (gh-8852)', async function() { + const schema = Schema({ name: String }); + + const Test = db.model('gh8852', schema); + + await Test.createCollection(); + const doc = new Test({ name: 'foo' }); + await db. + transaction(async (session) => { + await doc.save({ session }); + assert.ok(!doc.isNew); + throw new Error('Oops'); + }). + catch(err => assert.equal(err.message, 'Oops')); + assert.ok(doc.isNew); + }); + + it('can save document after aborted transaction (gh-8380)', async function() { + const schema = Schema({ name: String, arr: [String], arr2: [String] }); + + const Test = db.model('gh8380', schema); + + await Test.createCollection(); + await Test.create({ name: 'foo', arr: ['bar'], arr2: ['foo'] }); + const doc = await Test.findOne(); + await db. + transaction(async (session) => { + doc.arr.pull('bar'); + doc.arr2.push('bar'); + + await doc.save({ session }); + doc.name = 'baz'; + throw new Error('Oops'); + }). + catch(err => { + assert.equal(err.message, 'Oops'); + }); + + const changes = doc.$__delta()[1]; + assert.equal(changes.$set.name, 'baz'); + assert.deepEqual(changes.$pullAll.arr, ['bar']); + assert.deepEqual(changes.$push.arr2, { $each: ['bar'] }); + assert.ok(!changes.$set.arr2); + + await doc.save({ session: null }); + + const newDoc = await Test.collection.findOne(); + assert.equal(newDoc.name, 'baz'); + assert.deepEqual(newDoc.arr, []); + assert.deepEqual(newDoc.arr2, ['foo', 'bar']); + }); }); diff --git a/test/index.test.js b/test/index.test.js index 19f9fd8fb7b..322fa4df39d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -760,5 +760,57 @@ describe('mongoose module:', function() { done(); }); }); + + it('can set `setDefaultsOnInsert` as a global option (gh-9032)', function() { + return co(function* () { + const m = new mongoose.Mongoose(); + m.set('setDefaultsOnInsert', true); + const db = yield m.connect('mongodb://localhost:27017/mongoose_test_9032'); + + const schema = new m.Schema({ + title: String, + genre: { type: String, default: 'Action' } + }, { collection: 'movies_1' }); + + const Movie = db.model('Movie', schema); + yield Movie.deleteMany({}); + + yield Movie.updateOne( + {}, + { title: 'Cloud Atlas' }, + { upsert: true } + ); + + // lean is necessary to avoid defaults by casting + const movie = yield Movie.findOne({ title: 'Cloud Atlas' }).lean(); + assert.equal(movie.genre, 'Action'); + }); + }); + + it('setting `setDefaultOnInsert` on operation has priority over base option (gh-9032)', function() { + return co(function* () { + const m = new mongoose.Mongoose(); + m.set('setDefaultsOnInsert', true); + const db = yield m.connect('mongodb://localhost:27017/mongoose_test_9032'); + + const schema = new m.Schema({ + title: String, + genre: { type: String, default: 'Action' } + }, { collection: 'movies_2' }); + + const Movie = db.model('Movie', schema); + + + yield Movie.updateOne( + {}, + { title: 'The Man From Earth' }, + { upsert: true, setDefaultsOnInsert: false } + ); + + // lean is necessary to avoid defaults by casting + const movie = yield Movie.findOne({ title: 'The Man From Earth' }).lean(); + assert.ok(!movie.genre); + }); + }); }); }); \ No newline at end of file diff --git a/test/model.discriminator.querying.test.js b/test/model.discriminator.querying.test.js index c7e981fe39c..4cd246f3b3e 100644 --- a/test/model.discriminator.querying.test.js +++ b/test/model.discriminator.querying.test.js @@ -27,7 +27,7 @@ function BaseSchema() { util.inherits(BaseSchema, Schema); const EventSchema = new BaseSchema(); -const ImpressionEventSchema = new BaseSchema(); +const ImpressionEventSchema = new BaseSchema({ element: String }); const ConversionEventSchema = new BaseSchema({ revenue: Number }); const SecretEventSchema = new BaseSchema({ secret: { type: String, select: false } }); @@ -178,6 +178,28 @@ describe('model', function() { checkHydratesCorrectModels({ name: 1 }, done); }); + it('casts underneath $or if discriminator key in filter (gh-9018)', function() { + return co(function*() { + yield ImpressionEvent.create({ name: 'Impression event', element: '42' }); + yield ConversionEvent.create({ name: 'Conversion event', revenue: 1.337 }); + + let docs = yield BaseEvent.find({ __t: 'Impression', element: 42 }); + assert.equal(docs.length, 1); + assert.equal(docs[0].name, 'Impression event'); + + docs = yield BaseEvent.find({ $or: [{ __t: 'Impression', element: 42 }] }); + assert.equal(docs.length, 1); + assert.equal(docs[0].name, 'Impression event'); + + docs = yield BaseEvent.find({ + $or: [{ __t: 'Impression', element: 42 }, { __t: 'Conversion', revenue: '1.337' }] + }).sort({ __t: 1 }); + assert.equal(docs.length, 2); + assert.equal(docs[0].name, 'Conversion event'); + assert.equal(docs[1].name, 'Impression event'); + }); + }); + describe('discriminator model only finds documents of its type', function() { describe('using "ModelDiscriminator#findById"', function() { diff --git a/test/model.indexes.test.js b/test/model.indexes.test.js index cb6f3993e9b..9e6b63012ea 100644 --- a/test/model.indexes.test.js +++ b/test/model.indexes.test.js @@ -20,7 +20,7 @@ describe('model', function() { before(function() { db = start(); - return db.createCollection('Test'); + return db.createCollection('Test').catch(() => {}); }); after(function(done) { @@ -458,16 +458,18 @@ describe('model', function() { const schema = new Schema({ arr: [childSchema] }); const Model = db.model('Test', schema); - return Model.init(). - then(() => Model.syncIndexes()). - then(() => Model.listIndexes()). - then(indexes => { - assert.equal(indexes.length, 2); - assert.ok(indexes[1].partialFilterExpression); - assert.deepEqual(indexes[1].partialFilterExpression, { - 'arr.name': { $exists: true } - }); + return co(function*() { + yield Model.init(); + + yield Model.syncIndexes(); + const indexes = yield Model.listIndexes(); + + assert.equal(indexes.length, 2); + assert.ok(indexes[1].partialFilterExpression); + assert.deepEqual(indexes[1].partialFilterExpression, { + 'arr.name': { $exists: true } }); + }); }); it('skips automatic indexing on childSchema if autoIndex: false (gh-9150)', function() { diff --git a/test/model.populate.test.js b/test/model.populate.test.js index 972a853bb51..ecf2a52e33e 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -9547,6 +9547,46 @@ describe('model: populate:', function() { }); }); + it('Sets the populated document\'s parent() (gh-8092)', function() { + const schema = new Schema({ + single: { type: Number, ref: 'Child' }, + arr: [{ type: Number, ref: 'Child' }], + docArr: [{ ref: { type: Number, ref: 'Child' } }] + }); + + schema.virtual('myVirtual', { + ref: 'Child', + localField: 'single', + foreignField: '_id', + justOne: true + }); + + const Parent = db.model('Parent', schema); + const Child = db.model('Child', Schema({ _id: Number, name: String })); + + return co(function*() { + yield Child.create({ _id: 1, name: 'test' }); + + yield Parent.create({ single: 1, arr: [1], docArr: [{ ref: 1 }] }); + + let doc = yield Parent.findOne().populate('single'); + assert.ok(doc.single.parent() === doc); + + doc = yield Parent.findOne().populate('arr'); + assert.ok(doc.arr[0].parent() === doc); + + doc = yield Parent.findOne().populate('docArr.ref'); + assert.ok(doc.docArr[0].ref.parent() === doc); + + doc = yield Parent.findOne().populate('myVirtual'); + assert.ok(doc.myVirtual.parent() === doc); + + doc = yield Parent.findOne(); + yield doc.populate('single').execPopulate(); + assert.ok(doc.single.parent() === doc); + }); + }); + it('populates single nested discriminator underneath doc array when populated docs have different model but same id (gh-9244)', function() { const catSchema = Schema({ _id: Number, name: String }); const dogSchema = Schema({ _id: Number, name: String }); diff --git a/test/model.test.js b/test/model.test.js index 6f2632f5197..6fcfb2ca6f2 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -6868,4 +6868,57 @@ describe('Model', function() { assert.deepEqual(user.friends, ['Sam']); }); }); + + describe('returnOriginal (gh-9183)', function() { + const originalValue = mongoose.get('returnOriginal'); + beforeEach(() => { + mongoose.set('returnOriginal', false); + }); + + afterEach(() => { + mongoose.set('returnOriginal', originalValue); + }); + + it('Setting `returnOriginal` works', function() { + return co(function*() { + const userSchema = new Schema({ + name: { type: String } + }); + + const User = db.model('User', userSchema); + + const createdUser = yield User.create({ name: 'Hafez' }); + + const user1 = yield User.findOneAndUpdate({ _id: createdUser._id }, { name: 'Hafez1' }); + assert.equal(user1.name, 'Hafez1'); + + const user2 = yield User.findByIdAndUpdate(createdUser._id, { name: 'Hafez2' }); + assert.equal(user2.name, 'Hafez2'); + + const user3 = yield User.findOneAndReplace({ _id: createdUser._id }, { name: 'Hafez3' }); + assert.equal(user3.name, 'Hafez3'); + }); + }); + + it('`returnOriginal` can be overwritten', function() { + return co(function*() { + const userSchema = new Schema({ + name: { type: String } + }); + + const User = db.model('User', userSchema); + + const createdUser = yield User.create({ name: 'Hafez' }); + + const user1 = yield User.findOneAndUpdate({ _id: createdUser._id }, { name: 'Hafez1' }, { new: false }); + assert.equal(user1.name, 'Hafez'); + + const user2 = yield User.findByIdAndUpdate(createdUser._id, { name: 'Hafez2' }, { new: false }); + assert.equal(user2.name, 'Hafez1'); + + const user3 = yield User.findOneAndReplace({ _id: createdUser._id }, { name: 'Hafez3' }, { new: false }); + assert.equal(user3.name, 'Hafez2'); + }); + }); + }); }); diff --git a/test/model.update.test.js b/test/model.update.test.js index 6b5e7f83b4b..3e1a2aa7f4d 100644 --- a/test/model.update.test.js +++ b/test/model.update.test.js @@ -3479,6 +3479,35 @@ describe('model: updateOne: ', function() { }); }); + describe('overwriteDiscriminatorKey', function() { + it('allows changing discriminator key in update (gh-6087)', function() { + const baseSchema = new Schema({}, { discriminatorKey: 'type' }); + const baseModel = db.model('Test', baseSchema); + + const aSchema = Schema({ aThing: Number }, { _id: false, id: false }); + const aModel = baseModel.discriminator('A', aSchema); + + const bSchema = new Schema({ bThing: String }, { _id: false, id: false }); + const bModel = baseModel.discriminator('B', bSchema); + + return co(function*() { + // Model is created as a type A + let doc = yield baseModel.create({ type: 'A', aThing: 1 }); + + yield aModel.updateOne( + { _id: doc._id }, + { type: 'B', bThing: 'two' }, + { runValidators: true, overwriteDiscriminatorKey: true } + ); + + doc = yield baseModel.findById(doc); + assert.equal(doc.type, 'B'); + assert.ok(doc instanceof bModel); + assert.equal(doc.bThing, 'two'); + }); + }); + }); + it('update validators respect storeSubdocValidationError (gh-9172)', function() { const opts = { storeSubdocValidationError: false }; const Model = db.model('Test', Schema({ diff --git a/test/query.test.js b/test/query.test.js index 97a3defe70c..e7be2f856de 100644 --- a/test/query.test.js +++ b/test/query.test.js @@ -832,7 +832,8 @@ describe('Query', function() { select: undefined, model: undefined, options: undefined, - _docs: {} + _docs: {}, + _childDocs: [] }; q.populate(o); assert.deepEqual(o, q._mongooseOptions.populate['yellow.brick']); @@ -844,7 +845,8 @@ describe('Query', function() { let o = { path: 'yellow.brick', match: { bricks: { $lt: 1000 } }, - _docs: {} + _docs: {}, + _childDocs: [] }; q.populate(Object.assign({}, o)); assert.equal(Object.keys(q._mongooseOptions.populate).length, 1); @@ -853,7 +855,8 @@ describe('Query', function() { q.populate('yellow.brick'); o = { path: 'yellow.brick', - _docs: {} + _docs: {}, + _childDocs: [] }; assert.equal(Object.keys(q._mongooseOptions.populate).length, 1); assert.deepEqual(q._mongooseOptions.populate['yellow.brick'], o); @@ -866,11 +869,13 @@ describe('Query', function() { assert.equal(Object.keys(q._mongooseOptions.populate).length, 2); assert.deepEqual(q._mongooseOptions.populate['yellow.brick'], { path: 'yellow.brick', - _docs: {} + _docs: {}, + _childDocs: [] }); assert.deepEqual(q._mongooseOptions.populate['dirt'], { path: 'dirt', - _docs: {} + _docs: {}, + _childDocs: [] }); done(); }); @@ -1599,7 +1604,7 @@ describe('Query', function() { q.setOptions({ read: ['s', [{ dc: 'eu' }]] }); assert.equal(q.options.thing, 'cat'); - assert.deepEqual(q._mongooseOptions.populate.fans, { path: 'fans', _docs: {} }); + assert.deepEqual(q._mongooseOptions.populate.fans, { path: 'fans', _docs: {}, _childDocs: [] }); assert.equal(q.options.batchSize, 10); assert.equal(q.options.limit, 4); assert.equal(q.options.skip, 3); diff --git a/test/schema.documentarray.test.js b/test/schema.documentarray.test.js index 983bf9634de..89937a538d1 100644 --- a/test/schema.documentarray.test.js +++ b/test/schema.documentarray.test.js @@ -98,4 +98,20 @@ describe('schema.documentarray', function() { assert.equal(doc.nested[0].length, 3); assert.equal(doc.nested[0][1].title, 'second'); }); + + it('supports `set()` (gh-8883)', function() { + mongoose.deleteModel(/Test/); + mongoose.Schema.Types.DocumentArray.set('_id', false); + + const Model = mongoose.model('Test', mongoose.Schema({ + arr: { type: [{ name: String }] } + })); + + const doc = new Model({ arr: [{ name: 'test' }] }); + + assert.equal(doc.arr.length, 1); + assert.ok(!doc.arr[0]._id); + + mongoose.Schema.Types.DocumentArray.defaultOptions = {}; + }); }); diff --git a/test/schema.singlenestedpath.test.js b/test/schema.singlenestedpath.test.js index 882749ef267..242fb3118a5 100644 --- a/test/schema.singlenestedpath.test.js +++ b/test/schema.singlenestedpath.test.js @@ -162,4 +162,23 @@ describe('SingleNestedPath', function() { assert.strictEqual(clone.path('author').requiredValidator, clone.path('author').validators[0].validator); }); + + it('supports `set()` (gh-8883)', function() { + mongoose.deleteModel(/Test/); + mongoose.Schema.Types.Embedded.set('required', true); + + const Model = mongoose.model('Test', mongoose.Schema({ + nested: mongoose.Schema({ + test: String + }) + })); + + const doc = new Model({}); + + const err = doc.validateSync(); + assert.ok(err); + assert.ok(err.errors['nested']); + + mongoose.Schema.Types.Embedded.set('required', false); + }); }); \ No newline at end of file diff --git a/test/schema.test.js b/test/schema.test.js index 94f04335c83..8b3ceafb214 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -2002,13 +2002,13 @@ describe('schema', function() { }); assert.equal(schema.childSchemas.length, 2); - assert.equal(schema.childSchemas[0].schema, schema1); - assert.equal(schema.childSchemas[1].schema, schema2); + assert.strictEqual(schema.childSchemas[0].schema, schema1); + assert.strictEqual(schema.childSchemas[1].schema, schema2); schema = schema.clone(); assert.equal(schema.childSchemas.length, 2); - assert.equal(schema.childSchemas[0].schema, schema1); - assert.equal(schema.childSchemas[1].schema, schema2); + assert.strictEqual(schema.childSchemas[0].schema, schema1); + assert.strictEqual(schema.childSchemas[1].schema, schema2); done(); }); @@ -2426,6 +2426,35 @@ describe('schema', function() { }); }); + describe('mongoose.set(`strictQuery`, value); (gh-6658)', function() { + let strictQueryOriginalValue; + + this.beforeEach(() => strictQueryOriginalValue = mongoose.get('strictQuery')); + this.afterEach(() => mongoose.set('strictQuery', strictQueryOriginalValue)); + + it('setting `strictQuery` on base sets strictQuery to schema (gh-6658)', function() { + // Arrange + mongoose.set('strictQuery', 'some value'); + + // Act + const schema = new Schema(); + + // Assert + assert.equal(schema.get('strictQuery'), 'some value'); + }); + + it('`strictQuery` set on base gets overwritten by option set on schema (gh-6658)', function() { + // Arrange + mongoose.set('strictQuery', 'base option'); + + // Act + const schema = new Schema({}, { strictQuery: 'schema option' }); + + // Assert + assert.equal(schema.get('strictQuery'), 'schema option'); + }); + }); + it('treats dotted paths with no parent as a nested path (gh-9020)', function() { const customerSchema = new Schema({ 'card.brand': String, diff --git a/test/versioning.test.js b/test/versioning.test.js index ec3cd6ea64d..538e49c177c 100644 --- a/test/versioning.test.js +++ b/test/versioning.test.js @@ -593,4 +593,26 @@ describe('versioning', function() { }). catch(done); }); + + it('optimistic concurrency (gh-9001) (gh-5424)', function() { + const schema = new Schema({ name: String }, { optimisticConcurrency: true }); + const M = db.model('Test', schema); + + const doc = new M({ name: 'foo' }); + + return co(function*() { + yield doc.save(); + + const d1 = yield M.findOne(); + const d2 = yield M.findOne(); + + d1.name = 'bar'; + yield d1.save(); + + d2.name = 'qux'; + const err = yield d2.save().then(() => null, err => err); + assert.ok(err); + assert.equal(err.name, 'VersionError'); + }); + }); });