From 50628425920970c7c057b1f7d6cec588ec19f3fa Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 1 Nov 2024 14:21:04 -0400 Subject: [PATCH 1/8] fix(query): add `overwriteImmutable` option to allow updating immutable properties without disabling strict mode Fix #8619 --- docs/timestamps.md | 17 +++++++++++++++++ lib/helpers/query/castUpdate.js | 4 ++-- lib/helpers/query/handleImmutable.js | 5 ++++- lib/query.js | 11 ++++++++++- test/model.updateOne.test.js | 23 +++++++++++++++++++++++ types/query.d.ts | 6 ++++++ 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/timestamps.md b/docs/timestamps.md index 17706a6e0f9..4f8b4433f74 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -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. + +```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. +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 +``` \ No newline at end of file diff --git a/lib/helpers/query/castUpdate.js b/lib/helpers/query/castUpdate.js index 94f374d8b58..d48c5927559 100644 --- a/lib/helpers/query/castUpdate.js +++ b/lib/helpers/query/castUpdate.js @@ -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; } @@ -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; } diff --git a/lib/helpers/query/handleImmutable.js b/lib/helpers/query/handleImmutable.js index 22adb3c50de..3dfd4d24b7d 100644 --- a/lib/helpers/query/handleImmutable.js +++ b/lib/helpers/query/handleImmutable.js @@ -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) { if (schematype == null || !schematype.options || !schematype.options.immutable) { return false; } @@ -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; } diff --git a/lib/query.js b/lib/query.js index d853c0828fe..2c00c92f52b 100644 --- a/lib/query.js +++ b/lib/query.js @@ -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()`: * @@ -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); @@ -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. * @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 @@ -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. * @param {Function} [callback] params are (error, writeOpResult) * @return {Query} this * @see Model.update https://mongoosejs.com/docs/api/model.html#Model.update() @@ -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. * @param {Function} [callback] params are (error, writeOpResult) * @return {Query} this * @see Model.update https://mongoosejs.com/docs/api/model.html#Model.update() @@ -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); }; diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index 0b4223dad5c..e1fdcef1447 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -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: { diff --git a/types/query.d.ts b/types/query.d.ts index 67129e6c7b6..b2d4da347b7 100644 --- a/types/query.d.ts +++ b/types/query.d.ts @@ -20,6 +20,7 @@ declare module 'mongoose' { | 'context' | 'multipleCastError' | 'overwriteDiscriminatorKey' + | 'overwriteImmutable' | 'populate' | 'runValidators' | 'sanitizeProjection' @@ -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; /** * if true, returns the full ModifyResult rather than just the document From 008f708360ac7e9bbb1353bd470734c1ff5207e8 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 1 Nov 2024 16:10:23 -0400 Subject: [PATCH 2/8] fix md lint --- docs/timestamps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/timestamps.md b/docs/timestamps.md index 4f8b4433f74..dcb4a47be83 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -252,4 +252,4 @@ await Model.updateOne({ _id: doc._id }, { createdAt }, { overwriteImmutable: tru doc = await Model.collection.findOne({ _id: doc._id }); doc.createdAt.valueOf() === createdAt.valueOf(); // true -``` \ No newline at end of file +``` From 5dc355b88428454f947333546e8af1c8319ae532 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 4 Nov 2024 13:47:17 -0500 Subject: [PATCH 3/8] Update docs/timestamps.md Co-authored-by: hasezoey --- docs/timestamps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/timestamps.md b/docs/timestamps.md index dcb4a47be83..344996d6099 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -242,7 +242,7 @@ await User.findOneAndUpdate({}, { $setOnInsert: { updatedAt: new Date() } }, { 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. +2. Set `overwriteImmutable` to `true` to allow overwriting `createdAt`, which is an immutable property by default. ```javascript const createdAt = new Date('2011-06-01'); From 50daffd025c22c3a1535c0fae1d292f160e498df Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 4 Nov 2024 13:47:57 -0500 Subject: [PATCH 4/8] Update docs/timestamps.md Co-authored-by: hasezoey --- docs/timestamps.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/timestamps.md b/docs/timestamps.md index 344996d6099..8a722b4b356 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -246,8 +246,8 @@ If you need to disable Mongoose's timestamps and update a document's timestamps ```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. +// Update a document's `createdAt` to a custom value. +// Normally Mongoose would prevent doing this because `createdAt` is immutable. await Model.updateOne({ _id: doc._id }, { createdAt }, { overwriteImmutable: true, timestamps: false }); doc = await Model.collection.findOne({ _id: doc._id }); From 49d07f5669f1246d1b2909da07316bf4f15483b3 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 4 Nov 2024 13:48:09 -0500 Subject: [PATCH 5/8] Update lib/query.js Co-authored-by: hasezoey --- lib/query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/query.js b/lib/query.js index 2c00c92f52b..88b518d9ea9 100644 --- a/lib/query.js +++ b/lib/query.js @@ -3323,7 +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. + * @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. * @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 From 03a45ba157188192a9fd05b19db786578c5cf101 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 4 Nov 2024 13:48:15 -0500 Subject: [PATCH 6/8] Update lib/query.js Co-authored-by: hasezoey --- lib/query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/query.js b/lib/query.js index 88b518d9ea9..4a3babe2cf2 100644 --- a/lib/query.js +++ b/lib/query.js @@ -4022,7 +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. + * @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. * @param {Function} [callback] params are (error, writeOpResult) * @return {Query} this * @see Model.update https://mongoosejs.com/docs/api/model.html#Model.update() From af3ef645738af7eb3f5607daea55b7dabe3898c5 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 4 Nov 2024 13:48:20 -0500 Subject: [PATCH 7/8] Update lib/query.js Co-authored-by: hasezoey --- lib/query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/query.js b/lib/query.js index 4a3babe2cf2..6333c153c68 100644 --- a/lib/query.js +++ b/lib/query.js @@ -4093,7 +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. + * @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. * @param {Function} [callback] params are (error, writeOpResult) * @return {Query} this * @see Model.update https://mongoosejs.com/docs/api/model.html#Model.update() From 388c5b299d98949f8d78d09fac426c8497c1a360 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 4 Nov 2024 13:54:07 -0500 Subject: [PATCH 8/8] docs: add documentation for query/handleImmutable --- lib/helpers/query/handleImmutable.js | 13 +++++++++++++ test/types/queries.test.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/helpers/query/handleImmutable.js b/lib/helpers/query/handleImmutable.js index 3dfd4d24b7d..0102db42a46 100644 --- a/lib/helpers/query/handleImmutable.js +++ b/lib/helpers/query/handleImmutable.js @@ -2,6 +2,19 @@ const StrictModeError = require('../../error/strict'); +/** + * Handle immutable option for a given path when casting updates based on options + * + * @param {SchemaType} schematype the resolved schematype for this path + * @param {Boolean | 'throw' | null} strict whether strict mode is set for this query + * @param {Object} obj the object containing the value being checked so we can delete + * @param {String} key the key in `obj` which we are checking for immutability + * @param {String} fullPath the full path being checked + * @param {Object} options the query options + * @param {Query} ctx the query. Passed as `this` and first param to the `immutable` option, if `immutable` is a function + * @returns true if field was removed, false otherwise + */ + module.exports = function handleImmutable(schematype, strict, obj, key, fullPath, options, ctx) { if (schematype == null || !schematype.options || !schematype.options.immutable) { return false; diff --git a/test/types/queries.test.ts b/test/types/queries.test.ts index 02998362434..35ff6f24d6e 100644 --- a/test/types/queries.test.ts +++ b/test/types/queries.test.ts @@ -334,7 +334,7 @@ function gh14397() { age: number; }; - const id: string = 'Test Id'; + const id = 'Test Id'; let idCondition: Condition['id']>; let filter: FilterQuery>;