Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/migrating_to_9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
5 changes: 3 additions & 2 deletions lib/mongoose.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,18 +230,19 @@ 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`.
* - `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](https://mongoosejs.com/docs/tutorials/findoneandupdate.html) for more information.
* - `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
Expand Down
29 changes: 19 additions & 10 deletions lib/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global updatePipeline option is applied after _mergeUpdate() is called (line 3714), but _mergeUpdate() checks this._mongooseOptions.updatePipeline to determine whether array updates are allowed. This means the global option won't take effect for findOneAndReplace().

The correct order should be:

  1. Apply global option to options object
  2. Call this.setOptions(options) (which sets this._mongooseOptions.updatePipeline)
  3. Call this._mergeUpdate(replacement)

This is the order used in findOneAndUpdate() (lines 3459-3468) and should be followed here as well.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed this by removing updatePipeline for findOneAndReplace() since findOneAndReplace can't use update pipelines anyway


this.setOptions(options);

return this;
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions lib/validOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const VALID_OPTIONS = Object.freeze([
'debug',
'forceRepopulate',
'id',
'timestamps.createdAt.immutable',
'maxTimeMS',
'objectIdGetter',
'overwriteModels',
Expand All @@ -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;
166 changes: 166 additions & 0 deletions test/model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() => {

Expand Down
8 changes: 7 additions & 1 deletion test/types/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -64,7 +69,8 @@ function gh12100() {
function setAsObject() {
mongoose.set({
debug: true,
autoIndex: false
autoIndex: false,
updatePipeline: true
});

expectError(mongoose.set({ invalid: true }));
Expand Down
9 changes: 9 additions & 0 deletions types/mongooseoptions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading