diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 56ee9548062..49b0ded06df 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -28,7 +28,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { const model = decideModelByObject(originalModel, op['insertOne']['document']); const doc = new model(op['insertOne']['document']); - if (model.schema.options.timestamps && options.timestamps !== false) { + if (model.schema.options.timestamps && (op['insertOne'].timestamps ?? options.timestamps ?? true)) { doc.initializeTimestamps(); } if (options.session != null) { @@ -70,7 +70,8 @@ module.exports = function castBulkWrite(originalModel, op, options) { const createdAt = model.schema.$timestamps.createdAt; const updatedAt = model.schema.$timestamps.updatedAt; applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateOne']['update'], { - timestamps: op['updateOne'].timestamps + timestamps: op['updateOne'].timestamps, + overwriteImmutable: op['updateOne'].overwriteImmutable }); } @@ -140,7 +141,8 @@ module.exports = function castBulkWrite(originalModel, op, options) { const createdAt = model.schema.$timestamps.createdAt; const updatedAt = model.schema.$timestamps.updatedAt; applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], { - timestamps: op['updateMany'].timestamps + timestamps: op['updateMany'].timestamps, + overwriteImmutable: op['updateMany'].overwriteImmutable }); } if (op['updateMany'].timestamps !== false) { @@ -190,7 +192,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { // set `skipId`, otherwise we get "_id field cannot be changed" const doc = new model(op['replaceOne']['replacement'], strict, true); - if (model.schema.options.timestamps) { + if (model.schema.options.timestamps && (op['replaceOne'].timestamps ?? options.timestamps ?? true)) { doc.initializeTimestamps(); } if (options.session != null) { @@ -279,3 +281,4 @@ function decideModelByObject(model, object) { } return model; } + diff --git a/lib/helpers/update/applyTimestampsToUpdate.js b/lib/helpers/update/applyTimestampsToUpdate.js index b48febafb69..ae83b403117 100644 --- a/lib/helpers/update/applyTimestampsToUpdate.js +++ b/lib/helpers/update/applyTimestampsToUpdate.js @@ -81,33 +81,46 @@ function applyTimestampsToUpdate(now, createdAt, updatedAt, currentUpdate, optio } if (!skipCreatedAt && createdAt) { - if (currentUpdate[createdAt]) { - delete currentUpdate[createdAt]; - } - if (currentUpdate.$set && currentUpdate.$set[createdAt]) { - delete currentUpdate.$set[createdAt]; - } - let timestampSet = false; - if (createdAt.indexOf('.') !== -1) { - const pieces = createdAt.split('.'); - for (let i = 1; i < pieces.length; ++i) { - const remnant = pieces.slice(-i).join('.'); - const start = pieces.slice(0, -i).join('.'); - if (currentUpdate[start] != null) { - currentUpdate[start][remnant] = now; - timestampSet = true; - break; - } else if (currentUpdate.$set && currentUpdate.$set[start]) { - currentUpdate.$set[start][remnant] = now; - timestampSet = true; - break; + const overwriteImmutable = get(options, 'overwriteImmutable', false); + const hasUserCreatedAt = currentUpdate[createdAt] != null || currentUpdate?.$set[createdAt] != null; + + // If overwriteImmutable is true and user provided createdAt, keep their value + if (overwriteImmutable && hasUserCreatedAt) { + // Move createdAt from top-level to $set if needed + if (currentUpdate[createdAt] != null) { + updates.$set[createdAt] = currentUpdate[createdAt]; + delete currentUpdate[createdAt]; + } + // User's value is already in $set, nothing more to do + } else { + if (currentUpdate[createdAt]) { + delete currentUpdate[createdAt]; + } + if (currentUpdate.$set && currentUpdate.$set[createdAt]) { + delete currentUpdate.$set[createdAt]; + } + let timestampSet = false; + if (createdAt.indexOf('.') !== -1) { + const pieces = createdAt.split('.'); + for (let i = 1; i < pieces.length; ++i) { + const remnant = pieces.slice(-i).join('.'); + const start = pieces.slice(0, -i).join('.'); + if (currentUpdate[start] != null) { + currentUpdate[start][remnant] = now; + timestampSet = true; + break; + } else if (currentUpdate.$set && currentUpdate.$set[start]) { + currentUpdate.$set[start][remnant] = now; + timestampSet = true; + break; + } } } - } - if (!timestampSet) { - updates.$setOnInsert = updates.$setOnInsert || {}; - updates.$setOnInsert[createdAt] = now; + if (!timestampSet) { + updates.$setOnInsert = updates.$setOnInsert || {}; + updates.$setOnInsert[createdAt] = now; + } } } diff --git a/test/model.test.js b/test/model.test.js index 04516afa3be..d660960a154 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -5926,6 +5926,52 @@ describe('Model', function() { }); + it('bulkWrite can disable timestamps with insertOne and replaceOne (gh-15782)', async function() { + const userSchema = new Schema({ + name: String + }, { timestamps: true }); + + const User = db.model('User', userSchema); + + const user = await User.create({ name: 'Hafez' }); + + await User.bulkWrite([ + { insertOne: { document: { name: 'insertOne-test' }, timestamps: false } }, + { replaceOne: { filter: { _id: user._id }, replacement: { name: 'replaceOne-test' }, timestamps: false } } + ]); + + const insertedDoc = await User.findOne({ name: 'insertOne-test' }); + assert.strictEqual(insertedDoc.createdAt, undefined); + assert.strictEqual(insertedDoc.updatedAt, undefined); + + const replacedDoc = await User.findOne({ name: 'replaceOne-test' }); + assert.strictEqual(replacedDoc.createdAt, undefined); + assert.strictEqual(replacedDoc.updatedAt, undefined); + }); + + it('bulkWrite insertOne and replaceOne respect per-op timestamps: true when global is false (gh-15782)', async function() { + const userSchema = new Schema({ + name: String + }, { timestamps: true }); + + const User = db.model('User', userSchema); + + const user = await User.create({ name: 'Hafez' }); + + await User.bulkWrite([ + { insertOne: { document: { name: 'insertOne-test' }, timestamps: true } }, + { replaceOne: { filter: { _id: user._id }, replacement: { name: 'replaceOne-test' }, timestamps: true } } + ], { timestamps: false }); + + const insertedDoc = await User.findOne({ name: 'insertOne-test' }); + assert.ok(insertedDoc.createdAt instanceof Date); + assert.ok(insertedDoc.updatedAt instanceof Date); + + const replacedDoc = await User.findOne({ name: 'replaceOne-test' }); + assert.ok(replacedDoc.createdAt instanceof Date); + assert.ok(replacedDoc.updatedAt instanceof Date); + }); + it('bulkwrite should not change updatedAt on subdocs when timestamps set to false (gh-13611)', async function() { const postSchema = new Schema({ diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index 5e5954c6c44..5a3ab96ff3a 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -2707,34 +2707,136 @@ describe('model: updateOne: ', function() { assert.equal(doc.age, 20); }); - it('overwriting immutable createdAt with bulkWrite (gh-15781)', async function() { - const start = new Date().valueOf(); - const schema = Schema({ - createdAt: { - type: mongoose.Schema.Types.Date, - immutable: true - }, - name: String - }, { timestamps: true }); + describe('bulkWrite overwriteImmutable option (gh-15781)', function() { + it('updateOne can update immutable field with overwriteImmutable: true', async function() { + // Arrange + const { User } = createTestContext(); + const user = await User.create({ name: 'John', ssn: '123-45-6789' }); + const customCreatedAt = new Date('2020-01-01'); + + // Act + await User.bulkWrite([{ + updateOne: { + filter: { _id: user._id }, + update: { createdAt: customCreatedAt, ssn: '999-99-9999' }, + overwriteImmutable: true + } + }]); + + // Assert + const updatedUser = await User.findById(user._id); + assert.strictEqual(updatedUser.ssn, '999-99-9999'); + assert.strictEqual(updatedUser.createdAt.valueOf(), customCreatedAt.valueOf()); + }); + + it('updateMany can update immutable field with overwriteImmutable: true', async function() { + // Arrange + const { User } = createTestContext(); + const user = await User.create({ name: 'Alice', ssn: '111-11-1111' }); + const customCreatedAt = new Date('2020-01-01'); + + // Act + await User.bulkWrite([{ + updateMany: { + filter: { _id: user._id }, + update: { createdAt: customCreatedAt, ssn: '000-00-0000' }, + overwriteImmutable: true + } + }]); - const Model = db.model('Test', schema); + // Assert + const updatedUser = await User.findById(user._id); + assert.strictEqual(updatedUser.ssn, '000-00-0000'); + assert.strictEqual(updatedUser.createdAt.valueOf(), customCreatedAt.valueOf()); + }); - await Model.create({ name: 'gh-15781' }); - let doc = await Model.collection.findOne({ name: 'gh-15781' }); - assert.ok(doc.createdAt.valueOf() >= start); + for (const timestamps of [true, false, null, undefined]) { + it(`overwriting immutable createdAt with bulkWrite (gh-15781) when \`timestamps\` is \`${timestamps}\``, async function() { + // Arrange + const schema = Schema({ name: String }, { timestamps: true }); - const createdAt = new Date('2011-06-01'); - assert.ok(createdAt.valueOf() < start.valueOf()); - await Model.bulkWrite([{ - updateOne: { - filter: { _id: doc._id }, - update: { name: 'gh-15781 update', createdAt }, - overwriteImmutable: true, - timestamps: false - } - }]); - doc = await Model.collection.findOne({ name: 'gh-15781 update' }); - assert.equal(doc.createdAt.valueOf(), createdAt.valueOf()); + const Model = db.model('Test', schema); + + const doc1 = await Model.create({ name: 'gh-15781-1' }); + const doc2 = await Model.create({ name: 'gh-15781-2' }); + + // Act + const createdAt = new Date('2011-06-01'); + + await Model.bulkWrite([ + { + updateOne: { + filter: { _id: doc1._id }, + update: { createdAt }, + overwriteImmutable: true, + timestamps + } + }, + { + updateMany: { + filter: { _id: doc2._id }, + update: { createdAt }, + overwriteImmutable: true, + timestamps + } + } + ]); + + // Assert + const updatesDocs = await Model.find({ _id: { $in: [doc1._id, doc2._id] } }); + + assert.equal(updatesDocs[0].createdAt.valueOf(), createdAt.valueOf()); + assert.equal(updatesDocs[1].createdAt.valueOf(), createdAt.valueOf()); + }); + } + + it('can not update immutable fields without overwriteImmutable: true', async function() { + // Arrange + const { User } = createTestContext(); + const users = await User.create([ + { name: 'Bob', ssn: '222-22-2222' }, + { name: 'Eve', ssn: '333-33-3333' } + ]); + const newCreatedAt = new Date('2020-01-01'); + + // Act + await User.bulkWrite([ + { + updateOne: { + filter: { _id: users[0]._id }, + update: { ssn: '888-88-8888', createdAt: newCreatedAt } + } + + }, + { + updateMany: { + filter: { _id: users[1]._id }, + update: { ssn: '777-77-7777', createdAt: newCreatedAt } + } + } + ]); + + + // Assert + const [updatedUser1, updatedUser2] = await Promise.all([ + User.findById(users[0]._id), + User.findById(users[1]._id) + ]); + assert.strictEqual(updatedUser1.ssn, '222-22-2222'); + assert.notStrictEqual(updatedUser1.createdAt.valueOf(), newCreatedAt.valueOf()); + + assert.strictEqual(updatedUser2.ssn, '333-33-3333'); + assert.notStrictEqual(updatedUser2.createdAt.valueOf(), newCreatedAt.valueOf()); + }); + + function createTestContext() { + const userSchema = new Schema({ + name: String, + ssn: { type: String, immutable: true } + }, { timestamps: true }); + const User = db.model('User', userSchema); + return { User }; + } }); it('updates buffers with `runValidators` successfully (gh-8580)', async function() { diff --git a/test/types/models.test.ts b/test/types/models.test.ts index 0e00f16fc74..08fc0824b9b 100644 --- a/test/types/models.test.ts +++ b/test/types/models.test.ts @@ -13,11 +13,13 @@ import mongoose, { Query, UpdateWriteOpResult, AggregateOptions, - StringSchemaDefinition + StringSchemaDefinition, + UpdateOneModel, + UpdateManyModel } from 'mongoose'; import { expectAssignable, expectError, expectType } from 'tsd'; import { AutoTypedSchemaType, autoTypedSchema } from './schema.test'; -import { UpdateOneModel, ChangeStreamInsertDocument, ObjectId } from 'mongodb'; +import { UpdateOneModel as MongoUpdateOneModel, ChangeStreamInsertDocument, ObjectId } from 'mongodb'; function rawDocSyntax(): void { interface ITest { @@ -415,7 +417,7 @@ function gh11911() { const Animal = model('Animal', animalSchema); const changes: UpdateQuery = {}; - expectAssignable({ + expectAssignable({ filter: {}, update: changes }); @@ -766,3 +768,36 @@ async function gh14003() { await TestModel.validate({ name: 'foo' }, ['name']); await TestModel.validate({ name: 'foo' }, { pathsToSkip: ['name'] }); } + +async function gh15781() { + const userSchema = new Schema({ + createdAt: { type: Date, immutable: true }, + name: String + }, { timestamps: true }); + + const User = model('User', userSchema); + + await User.bulkWrite([ + { + updateOne: { + filter: { name: 'John' }, + update: { createdAt: new Date() }, + overwriteImmutable: true, + timestamps: false + } + }, + { + updateMany: { + filter: { name: 'Jane' }, + update: { createdAt: new Date() }, + overwriteImmutable: true, + timestamps: false + } + } + ]); + + expectType({} as UpdateOneModel['timestamps']); + expectType({} as UpdateOneModel['overwriteImmutable']); + expectType({} as UpdateManyModel['timestamps']); + expectType({} as UpdateManyModel['overwriteImmutable']); +} diff --git a/types/models.d.ts b/types/models.d.ts index 7cb082bc372..90e7e1456e7 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -142,6 +142,46 @@ declare module 'mongoose' { interface RemoveOptions extends SessionOption, Omit {} + interface MongooseBulkWritePerOperationOptions { + /** Skip validation for this operation. */ + skipValidation?: boolean; + /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ + timestamps?: boolean; + } + + interface MongooseBulkUpdatePerOperationOptions extends MongooseBulkWritePerOperationOptions { + /** When true, allows updating fields that are marked as `immutable` in the schema. */ + overwriteImmutable?: boolean; + /** When false, do not set default values on insert. */ + setDefaultsOnInsert?: boolean; + } + + export type InsertOneModel = + mongodb.InsertOneModel & MongooseBulkWritePerOperationOptions; + + export type ReplaceOneModel = + mongodb.ReplaceOneModel & MongooseBulkWritePerOperationOptions; + + export type UpdateOneModel = + mongodb.UpdateOneModel & MongooseBulkUpdatePerOperationOptions; + + export type UpdateManyModel = + mongodb.UpdateManyModel & MongooseBulkUpdatePerOperationOptions; + + export type DeleteOneModel = + mongodb.DeleteOneModel; + + export type DeleteManyModel = + mongodb.DeleteManyModel; + + export type AnyBulkWriteOperation = + | { insertOne: InsertOneModel } + | { replaceOne: ReplaceOneModel } + | { updateOne: UpdateOneModel } + | { updateMany: UpdateManyModel } + | { deleteOne: DeleteOneModel } + | { deleteMany: DeleteManyModel }; + const Model: Model; /** @@ -185,11 +225,11 @@ declare module 'mongoose' { * round trip to the MongoDB server. */ bulkWrite( - writes: Array>, + writes: Array>, options: mongodb.BulkWriteOptions & MongooseBulkWriteOptions & { ordered: false } ): Promise; bulkWrite( - writes: Array>, + writes: Array>, options?: mongodb.BulkWriteOptions & MongooseBulkWriteOptions ): Promise;