Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(query): add overwriteImmutable option to allow updating immutable properties without disabling strict mode #15000

Merged
merged 8 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
17 changes: 17 additions & 0 deletions docs/timestamps.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,20 @@ await User.findOneAndUpdate({}, { $setOnInsert: { updatedAt: new Date() } }, {
timestamps: { createdAt: true, updatedAt: false }
});
```

## Updating Timestamps

If you need to disable Mongoose's timestamps and update a document's timestamps to a different value using `updateOne()` or `findOneAndUpdate()`, you need to do the following:

1. Set the `timestamps` option to `false` to prevent Mongoose from setting `updatedAt`.
2. Set `overwriteImmutable` to `false` to allow overwriting `createdAt`, which is an immutable property by default.
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved

```javascript
const createdAt = new Date('2011-06-01');
// Update a document's `createdAt` to a custom value. Normally Mongoose would prevent doing this because `createdAt`
// is immutable.
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
await Model.updateOne({ _id: doc._id }, { createdAt }, { overwriteImmutable: true, timestamps: false });

doc = await Model.collection.findOne({ _id: doc._id });
doc.createdAt.valueOf() === createdAt.valueOf(); // true
```
4 changes: 2 additions & 2 deletions lib/helpers/query/castUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) {
}

if (op !== '$setOnInsert' &&
handleImmutable(schematype, strict, obj, key, prefix + key, context)) {
handleImmutable(schematype, strict, obj, key, prefix + key, options, context)) {
continue;
}

Expand Down Expand Up @@ -353,7 +353,7 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) {

// You can use `$setOnInsert` with immutable keys
if (op !== '$setOnInsert' &&
handleImmutable(schematype, strict, obj, key, prefix + key, context)) {
handleImmutable(schematype, strict, obj, key, prefix + key, options, context)) {
continue;
}

Expand Down
5 changes: 4 additions & 1 deletion lib/helpers/query/handleImmutable.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const StrictModeError = require('../../error/strict');

module.exports = function handleImmutable(schematype, strict, obj, key, fullPath, ctx) {
module.exports = function handleImmutable(schematype, strict, obj, key, fullPath, options, ctx) {
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
if (schematype == null || !schematype.options || !schematype.options.immutable) {
return false;
}
Expand All @@ -15,6 +15,9 @@ module.exports = function handleImmutable(schematype, strict, obj, key, fullPath
return false;
}

if (options && options.overwriteImmutable) {
return false;
}
if (strict === false) {
return false;
}
Expand Down
11 changes: 10 additions & 1 deletion lib/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -1626,6 +1626,7 @@ Query.prototype.getOptions = function() {
* - [writeConcern](https://www.mongodb.com/docs/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.
* - overwriteDiscriminatorKey: allow setting the discriminator key in the update. Will use the correct discriminator schema if the update changes the discriminator key.
* - overwriteImmutable: allow overwriting properties that are set to `immutable` in the schema. Defaults to false.
*
* The following options are only for `find()`, `findOne()`, `findById()`, `findOneAndUpdate()`, `findOneAndReplace()`, `findOneAndDelete()`, and `findByIdAndUpdate()`:
*
Expand Down Expand Up @@ -1697,6 +1698,10 @@ Query.prototype.setOptions = function(options, overwrite) {
this._mongooseOptions.overwriteDiscriminatorKey = options.overwriteDiscriminatorKey;
delete options.overwriteDiscriminatorKey;
}
if ('overwriteImmutable' in options) {
this._mongooseOptions.overwriteImmutable = options.overwriteImmutable;
delete options.overwriteImmutable;
}
if ('sanitizeProjection' in options) {
if (options.sanitizeProjection && !this._mongooseOptions.sanitizeProjection) {
sanitizeProjection(this._fields);
Expand Down Expand Up @@ -3318,6 +3323,7 @@ function prepareDiscriminatorCriteria(query) {
* @param {Boolean} [options.returnOriginal=null] An alias for the `new` option. `returnOriginal: false` is equivalent to `new: true`.
* @param {Boolean} [options.translateAliases=null] If set to `true`, translates any schema-defined aliases in `filter`, `projection`, `update`, and `distinct`. Throws an error if there are any conflicts where both alias and raw property are defined on the same object.
* @param {Boolean} [options.overwriteDiscriminatorKey=false] Mongoose removes discriminator key updates from `update` by default, set `overwriteDiscriminatorKey` to `true` to allow updating the discriminator key
* @param {Boolean} [options.overwriteImmutable=false] Mongoose removes updated immutable properties from `update` by default (excluding $setOnInsert), set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
* @see Tutorial https://mongoosejs.com/docs/tutorials/findoneandupdate.html
* @see findAndModify command https://www.mongodb.com/docs/manual/reference/command/findAndModify/
* @see ModifyResult https://mongodb.github.io/node-mongodb-native/4.9/interfaces/ModifyResult.html
Expand Down Expand Up @@ -4016,6 +4022,7 @@ Query.prototype._replaceOne = async function _replaceOne() {
* @param {Boolean} [options.timestamps=null] If set to `false` and [schema-level timestamps](https://mongoosejs.com/docs/guide.html#timestamps) are enabled, skip timestamps for this update. Does nothing if schema-level timestamps are not set.
* @param {Boolean} [options.translateAliases=null] If set to `true`, translates any schema-defined aliases in `filter`, `projection`, `update`, and `distinct`. Throws an error if there are any conflicts where both alias and raw property are defined on the same object.
* @param {Boolean} [options.overwriteDiscriminatorKey=false] Mongoose removes discriminator key updates from `update` by default, set `overwriteDiscriminatorKey` to `true` to allow updating the discriminator key
* @param {Boolean} [options.overwriteImmutable=false] Mongoose removes updated immutable properties from `update` by default (excluding $setOnInsert), set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
* @param {Function} [callback] params are (error, writeOpResult)
* @return {Query} this
* @see Model.update https://mongoosejs.com/docs/api/model.html#Model.update()
Expand Down Expand Up @@ -4086,6 +4093,7 @@ Query.prototype.updateMany = function(conditions, doc, options, callback) {
* @param {Boolean} [options.timestamps=null] If set to `false` and [schema-level timestamps](https://mongoosejs.com/docs/guide.html#timestamps) are enabled, skip timestamps for this update. Note that this allows you to overwrite timestamps. Does nothing if schema-level timestamps are not set.
* @param {Boolean} [options.translateAliases=null] If set to `true`, translates any schema-defined aliases in `filter`, `projection`, `update`, and `distinct`. Throws an error if there are any conflicts where both alias and raw property are defined on the same object.
* @param {Boolean} [options.overwriteDiscriminatorKey=false] Mongoose removes discriminator key updates from `update` by default, set `overwriteDiscriminatorKey` to `true` to allow updating the discriminator key
* @param {Boolean} [options.overwriteImmutable=false] Mongoose removes updated immutable properties from `update` by default (excluding $setOnInsert), set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
* @param {Function} [callback] params are (error, writeOpResult)
* @return {Query} this
* @see Model.update https://mongoosejs.com/docs/api/model.html#Model.update()
Expand Down Expand Up @@ -4707,7 +4715,8 @@ Query.prototype._castUpdate = function _castUpdate(obj) {
strict: this._mongooseOptions.strict,
upsert: upsert,
arrayFilters: this.options.arrayFilters,
overwriteDiscriminatorKey: this._mongooseOptions.overwriteDiscriminatorKey
overwriteDiscriminatorKey: this._mongooseOptions.overwriteDiscriminatorKey,
overwriteImmutable: this._mongooseOptions.overwriteImmutable
}, this, this._conditions);
};

Expand Down
23 changes: 23 additions & 0 deletions test/model.updateOne.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2526,6 +2526,29 @@ describe('model: updateOne: ', function() {
assert.ok(doc.createdAt.valueOf() >= start);
});

it('overwriting immutable createdAt (gh-8619)', async function() {
const start = new Date().valueOf();
const schema = Schema({
createdAt: {
type: mongoose.Schema.Types.Date,
immutable: true
},
name: String
}, { timestamps: true });

const Model = db.model('Test', schema);

await Model.create({ name: 'gh-8619' });
let doc = await Model.collection.findOne({ name: 'gh-8619' });
assert.ok(doc.createdAt.valueOf() >= start);

const createdAt = new Date('2011-06-01');
assert.ok(createdAt.valueOf() < start.valueOf());
await Model.updateOne({ _id: doc._id }, { name: 'gh-8619 update', createdAt }, { overwriteImmutable: true, timestamps: false });
doc = await Model.collection.findOne({ name: 'gh-8619 update' });
assert.equal(doc.createdAt.valueOf(), createdAt.valueOf());
});

it('conditional immutable (gh-8001)', async function() {
const schema = Schema({
test: {
Expand Down
6 changes: 6 additions & 0 deletions types/query.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ declare module 'mongoose' {
| 'context'
| 'multipleCastError'
| 'overwriteDiscriminatorKey'
| 'overwriteImmutable'
| 'populate'
| 'runValidators'
| 'sanitizeProjection'
Expand Down Expand Up @@ -154,6 +155,11 @@ declare module 'mongoose' {
new?: boolean;

overwriteDiscriminatorKey?: boolean;
/**
* Mongoose removes updated immutable properties from `update` by default (excluding $setOnInsert).
* Set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
*/
overwriteImmutable?: boolean;
projection?: ProjectionType<DocType>;
/**
* if true, returns the full ModifyResult rather than just the document
Expand Down