From 612ff6513d5319ad739fc16904e3359cc918192f Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 18 Nov 2025 14:53:27 +0100 Subject: [PATCH 1/6] test: add tests for `updatePipeline` global option in model re: #15756 --- test/model.test.js | 166 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/test/model.test.js b/test/model.test.js index 7ae988e544..109503249b 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -6741,6 +6741,172 @@ describe('Model', function() { }); }); + describe('`updatePipeline` global option (gh-15756)', function() { + // Arrange + const originalValue = mongoose.get('updatePipeline'); + + afterEach(() => { + mongoose.set('updatePipeline', originalValue); + }); + + describe('allows update pipelines when global `updatePipeline` is `true`', function() { + it('works with updateOne', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: true }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act + await User.updateOne({ _id: createdUser._id }, [{ $set: { counter: 1 } }]); + const user = await User.findById(createdUser._id); + + // Assert + assert.equal(user.counter, 1); + }); + + it('works with updateMany', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: true }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act + await User.updateMany({ _id: createdUser._id }, [{ $set: { counter: 2 } }]); + const user = await User.findById(createdUser._id); + + // Assert + assert.equal(user.counter, 2); + }); + + it('works with findOneAndUpdate', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: true }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act + const user = await User.findOneAndUpdate({ _id: createdUser._id }, [{ $set: { counter: 3, name: 'Hafez3' } }], { new: true }); + + // Assert + assert.equal(user.counter, 3); + assert.equal(user.name, 'Hafez3'); + }); + + it('works with findByIdAndUpdate', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: true }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act + const user = await User.findByIdAndUpdate(createdUser._id, [{ $set: { counter: 4, name: 'Hafez4' } }], { new: true }); + + // Assert + assert.equal(user.counter, 4); + assert.equal(user.name, 'Hafez4'); + }); + }); + + describe('explicit `updatePipeline` option overrides global setting', function() { + it('explicit false overrides global true for updateOne', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: true }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act & Assert + assert.throws( + () => User.updateOne({ _id: createdUser._id }, [{ $set: { counter: 1 } }], { updatePipeline: false }), + /Cannot pass an array to query updates unless the `updatePipeline` option is set/ + ); + }); + + it('explicit false overrides global true for findOneAndUpdate', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: true }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act & Assert + assert.throws( + () => User.findOneAndUpdate({ _id: createdUser._id }, [{ $set: { counter: 1 } }], { updatePipeline: false }), + /Cannot pass an array to query updates unless the `updatePipeline` option is set/ + ); + }); + }); + + describe('throws error when global `updatePipeline` is false and no explicit option', function() { + it('updateOne should throw error', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: false }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act & Assert + assert.throws( + () => User.updateOne({ _id: createdUser._id }, [{ $set: { counter: 1 } }]), + /Cannot pass an array to query updates unless the `updatePipeline` option is set/ + ); + }); + + it('updateMany should throw error', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: false }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act & Assert + assert.throws( + () => User.updateMany({ _id: createdUser._id }, [{ $set: { counter: 1 } }]), + /Cannot pass an array to query updates unless the `updatePipeline` option is set/ + ); + }); + + it('findOneAndUpdate should throw error', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: false }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act & Assert + assert.throws( + () => User.findOneAndUpdate({ _id: createdUser._id }, [{ $set: { counter: 1 } }]), + /Cannot pass an array to query updates unless the `updatePipeline` option is set/ + ); + }); + }); + + describe('explicit `updatePipeline: true` overrides global `updatePipeline: false`', function() { + it('works with updateOne', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: false }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act + await User.updateOne({ _id: createdUser._id }, [{ $set: { counter: 1 } }], { updatePipeline: true }); + const user = await User.findById(createdUser._id); + + // Assert + assert.equal(user.counter, 1); + }); + + it('works with findOneAndUpdate', async function() { + // Arrange + const { User } = createTestContext({ globalUpdatePipeline: false }); + const createdUser = await User.create({ name: 'Hafez', counter: 0 }); + + // Act + const user = await User.findOneAndUpdate({ _id: createdUser._id }, [{ $set: { counter: 2, name: 'Hafez2' } }], { updatePipeline: true, new: true }); + + // Assert + assert.equal(user.counter, 2); + assert.equal(user.name, 'Hafez2'); + }); + }); + + function createTestContext({ globalUpdatePipeline }) { + mongoose.set('updatePipeline', globalUpdatePipeline); + const userSchema = new Schema({ + name: { type: String }, + counter: { type: Number, default: 0 } + }); + + const User = db.model('User', userSchema); + return { User }; + } + }); + describe('buildBulkWriteOperations() (gh-9673)', () => { it('builds write operations', async() => { From 834feeab273f52fb0543cfe04c76c132b8206f8f Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 18 Nov 2025 14:55:55 +0100 Subject: [PATCH 2/6] feat: implement global `updatePipeline` option re #15756 --- lib/mongoose.js | 5 +++-- lib/query.js | 29 +++++++++++++++++++---------- lib/validOptions.js | 5 +++-- types/mongooseoptions.d.ts | 9 +++++++++ 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/lib/mongoose.js b/lib/mongoose.js index f6f93aa32c..864b686dd5 100644 --- a/lib/mongoose.js +++ b/lib/mongoose.js @@ -230,7 +230,6 @@ Mongoose.prototype.setDriver = function setDriver(driver) { * - `cloneSchemas`: `false` by default. Set to `true` to `clone()` all schemas before compiling into a model. * - `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 arguments 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(', ')})`. * - `id`: If `true`, adds a `id` virtual to all schemas unless overwritten on a per-schema basis. - * - `timestamps.createdAt.immutable`: `true` by default. If `false`, it will change the `createdAt` field to be [`immutable: false`](https://mongoosejs.com/docs/api/schematype.html#SchemaType.prototype.immutable) which means you can update the `createdAt` * - `maxTimeMS`: If set, attaches [maxTimeMS](https://www.mongodb.com/docs/manual/reference/operator/meta/maxTimeMS/) to every query * - `objectIdGetter`: `true` by default. Mongoose adds a getter to MongoDB ObjectId's called `_id` that returns `this` for convenience with populate. Set this to false to remove the getter. * - `overwriteModels`: Set to `true` to default to overwriting models with the same name when calling `mongoose.model()`, as opposed to throwing an `OverwriteModelError`. @@ -238,10 +237,12 @@ Mongoose.prototype.setDriver = function setDriver(driver) { * - `runValidators`: `false` by default. Set to true to enable [update validators](https://mongoosejs.com/docs/validation.html#update-validators) for all validators by default. * - `sanitizeFilter`: `false` by default. Set to true to enable the [sanitization of the query filters](https://mongoosejs.com/docs/api/mongoose.html#Mongoose.prototype.sanitizeFilter()) against query selector injection attacks by wrapping any nested objects that have a property whose name starts with `$` in a `$eq`. * - `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. - * - `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](https://mongoosejs.com/docs/guide.html#strictQuery) mode for schemas. + * - `strict`: `true` by default, may be `false`, `true`, or `'throw'`. Sets the default strict mode for schemas. + * - `timestamps.createdAt.immutable`: `true` by default. If `false`, it will change the `createdAt` field to be [`immutable: false`](https://mongoosejs.com/docs/api/schematype.html#SchemaType.prototype.immutable) which means you can update the `createdAt` * - `toJSON`: `{ transform: true, flattenDecimals: true }` by default. Overwrites default objects to [`toJSON()`](https://mongoosejs.com/docs/api/document.html#Document.prototype.toJSON()), for determining how Mongoose documents get serialized by `JSON.stringify()` * - `toObject`: `{ transform: true, flattenDecimals: true }` by default. Overwrites default objects to [`toObject()`](https://mongoosejs.com/docs/api/document.html#Document.prototype.toObject()) + * - `updatePipeline`: `false` by default. If `true`, allows passing update pipelines (arrays) to update operations by default without explicitly setting `updatePipeline: true` in each query. * * @param {String|Object} key The name of the option or a object of multiple key-value pairs * @param {String|Function|Boolean} value The value of the option, unused if "key" is a object diff --git a/lib/query.js b/lib/query.js index 567b62eb55..dfab11dea9 100644 --- a/lib/query.js +++ b/lib/query.js @@ -3451,15 +3451,16 @@ Query.prototype.findOneAndUpdate = function(filter, update, options) { delete options.fields; } - const returnOriginal = this && - this.model && - this.model.base && - this.model.base.options && - this.model.base.options.returnOriginal; + const returnOriginal = this?.model?.base?.options?.returnOriginal; if (options.new == null && options.returnDocument == null && options.returnOriginal == null && returnOriginal != null) { options.returnOriginal = returnOriginal; } + const updatePipeline = this?.model?.base?.options?.updatePipeline; + if (options.updatePipeline == null && updatePipeline != null) { + options.updatePipeline = updatePipeline; + } + this.setOptions(options); // apply doc @@ -3715,14 +3716,16 @@ Query.prototype.findOneAndReplace = function(filter, replacement, options) { options = options || {}; - const returnOriginal = this && - this.model && - this.model.base && - this.model.base.options && - this.model.base.options.returnOriginal; + const returnOriginal = this?.model?.base?.options?.returnOriginal; if (options.new == null && options.returnDocument == null && options.returnOriginal == null && returnOriginal != null) { options.returnOriginal = returnOriginal; } + + const updatePipeline = this?.model?.base?.options?.updatePipeline; + if (options.updatePipeline == null && updatePipeline != null) { + options.updatePipeline = updatePipeline; + } + this.setOptions(options); return this; @@ -4407,6 +4410,12 @@ function _update(query, op, filter, doc, options, callback) { query.merge(filter); } + const updatePipeline = query?.model?.base?.options?.updatePipeline; + if (updatePipeline != null && (options == null || options.updatePipeline == null)) { + options = options || {}; + options.updatePipeline = updatePipeline; + } + if (utils.isObject(options)) { query.setOptions(options); } diff --git a/lib/validOptions.js b/lib/validOptions.js index 6c09480def..9c66659d13 100644 --- a/lib/validOptions.js +++ b/lib/validOptions.js @@ -19,7 +19,6 @@ const VALID_OPTIONS = Object.freeze([ 'debug', 'forceRepopulate', 'id', - 'timestamps.createdAt.immutable', 'maxTimeMS', 'objectIdGetter', 'overwriteModels', @@ -32,10 +31,12 @@ const VALID_OPTIONS = Object.freeze([ 'strict', 'strictPopulate', 'strictQuery', + 'timestamps.createdAt.immutable', 'toJSON', 'toObject', 'transactionAsyncLocalStorage', - 'translateAliases' + 'translateAliases', + 'updatePipeline' ]); module.exports = VALID_OPTIONS; diff --git a/types/mongooseoptions.d.ts b/types/mongooseoptions.d.ts index 9c35ab8222..31019fd154 100644 --- a/types/mongooseoptions.d.ts +++ b/types/mongooseoptions.d.ts @@ -215,5 +215,14 @@ declare module 'mongoose' { * to their database property names. Defaults to false. */ translateAliases?: boolean; + + /** + * If `true`, allows passing update pipelines (arrays) to update operations by default + * without explicitly setting `updatePipeline: true` in each query. This is the global + * default for the `updatePipeline` query option. + * + * @default false + */ + updatePipeline?: boolean; } } From d8c1ab7c711f5a3b8ac062a7c19455c96c15a917 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 18 Nov 2025 15:53:05 +0100 Subject: [PATCH 3/6] docs: add docs for global updatePipeline options --- docs/migrating_to_9.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/migrating_to_9.md b/docs/migrating_to_9.md index c67a4efbdd..757e9e38d5 100644 --- a/docs/migrating_to_9.md +++ b/docs/migrating_to_9.md @@ -85,6 +85,19 @@ Set `updatePipeline: true` to enable update pipelines. await Model.updateOne({}, [{ $set: { newProp: 'test2' } }], { updatePipeline: true }); ``` +You can also set `updatePipeline` globally to enable update pipelines for all update operations by default. + +```javascript +// Enable update pipelines globally +mongoose.set('updatePipeline', true); + +// Now update pipelines work without needing to specify the option on each query +await Model.updateOne({}, [{ $set: { newProp: 'test2' } }]); + +// You can still override the global setting per query +await Model.updateOne({}, [{ $set: { newProp: 'test2' } }], { updatePipeline: false }); // throws +``` + ## Removed background option for indexes [MongoDB no longer supports the `background` option for indexes as of MongoDB 4.2](https://www.mongodb.com/docs/manual/core/index-creation/#index-operations). Mongoose 9 will no longer set the background option by default and Mongoose 9 no longer supports setting the `background` option on `Schema.prototype.index()`. From 04c7ba5ea1a3327d416a1fdacb8ba581ad8158c4 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 18 Nov 2025 15:54:32 +0100 Subject: [PATCH 4/6] test: add type tests re #15756 --- test/types/base.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/types/base.test.ts b/test/types/base.test.ts index d13c10ea17..1c1d57d359 100644 --- a/test/types/base.test.ts +++ b/test/types/base.test.ts @@ -56,6 +56,11 @@ function gh10139() { mongoose.set('timestamps.createdAt.immutable', false); } +function gh15756() { + mongoose.set('updatePipeline', false); + mongoose.set('updatePipeline', true); +} + function gh12100() { mongoose.syncIndexes({ continueOnError: true, sparse: true }); mongoose.syncIndexes({ continueOnError: false, sparse: true }); @@ -64,7 +69,8 @@ function gh12100() { function setAsObject() { mongoose.set({ debug: true, - autoIndex: false + autoIndex: false, + updatePipeline: true }); expectError(mongoose.set({ invalid: true })); From 9c39f4552440e843c6d171870e606d90d1fb6616 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 18 Nov 2025 17:13:28 -0500 Subject: [PATCH 5/6] fix: avoid pulling in updatePipeline for findOneAndReplace() --- lib/query.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/query.js b/lib/query.js index dfab11dea9..e2d3258492 100644 --- a/lib/query.js +++ b/lib/query.js @@ -3710,10 +3710,6 @@ Query.prototype.findOneAndReplace = function(filter, replacement, options) { ); } - if (replacement != null) { - this._mergeUpdate(replacement); - } - options = options || {}; const returnOriginal = this?.model?.base?.options?.returnOriginal; @@ -3721,11 +3717,6 @@ Query.prototype.findOneAndReplace = function(filter, replacement, options) { options.returnOriginal = returnOriginal; } - const updatePipeline = this?.model?.base?.options?.updatePipeline; - if (options.updatePipeline == null && updatePipeline != null) { - options.updatePipeline = updatePipeline; - } - this.setOptions(options); return this; From b3a74291c30cdd19e4a1b03880ac0cad637ea4be Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 18 Nov 2025 17:17:58 -0500 Subject: [PATCH 6/6] fix tests --- lib/query.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/query.js b/lib/query.js index e2d3258492..57bc291340 100644 --- a/lib/query.js +++ b/lib/query.js @@ -3710,6 +3710,10 @@ Query.prototype.findOneAndReplace = function(filter, replacement, options) { ); } + if (replacement != null) { + this._mergeUpdate(replacement); + } + options = options || {}; const returnOriginal = this?.model?.base?.options?.returnOriginal;