Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 7 additions & 4 deletions lib/helpers/model/castBulkWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
});
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -279,3 +281,4 @@ function decideModelByObject(model, object) {
}
return model;
}

61 changes: 37 additions & 24 deletions lib/helpers/update/applyTimestampsToUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
46 changes: 46 additions & 0 deletions test/model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
152 changes: 127 additions & 25 deletions test/model.updateOne.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
41 changes: 38 additions & 3 deletions test/types/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -415,7 +417,7 @@ function gh11911() {
const Animal = model<IAnimal>('Animal', animalSchema);

const changes: UpdateQuery<IAnimal> = {};
expectAssignable<UpdateOneModel>({
expectAssignable<MongoUpdateOneModel>({
filter: {},
update: changes
});
Expand Down Expand Up @@ -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<boolean | undefined>({} as UpdateOneModel['timestamps']);
expectType<boolean | undefined>({} as UpdateOneModel['overwriteImmutable']);
expectType<boolean | undefined>({} as UpdateManyModel['timestamps']);
expectType<boolean | undefined>({} as UpdateManyModel['overwriteImmutable']);
}
Loading
Loading