Skip to content
Merged
21 changes: 15 additions & 6 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 @@ -69,7 +69,10 @@ module.exports = function castBulkWrite(originalModel, op, options) {
if (model.schema.$timestamps != null && op['updateOne'].timestamps !== false) {
const createdAt = model.schema.$timestamps.createdAt;
const updatedAt = model.schema.$timestamps.updatedAt;
applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateOne']['update'], {});
applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateOne']['update'], {
timestamps: op['updateOne'].timestamps,
overwriteImmutable: op['updateOne'].overwriteImmutable
});
}

if (op['updateOne'].timestamps !== false) {
Expand Down Expand Up @@ -100,7 +103,8 @@ module.exports = function castBulkWrite(originalModel, op, options) {
op['updateOne']['update'] = castUpdate(model.schema, op['updateOne']['update'], {
strict: strict,
overwrite: false,
upsert: op['updateOne'].upsert
upsert: op['updateOne'].upsert,
overwriteImmutable: op['updateOne'].overwriteImmutable
}, model, op['updateOne']['filter']);
} catch (error) {
return callback(error, null);
Expand Down Expand Up @@ -136,7 +140,10 @@ module.exports = function castBulkWrite(originalModel, op, options) {
if (model.schema.$timestamps != null && op['updateMany'].timestamps !== false) {
const createdAt = model.schema.$timestamps.createdAt;
const updatedAt = model.schema.$timestamps.updatedAt;
applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], {});
applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], {
timestamps: op['updateMany'].timestamps,
overwriteImmutable: op['updateMany'].overwriteImmutable
});
}
if (op['updateMany'].timestamps !== false) {
applyTimestampsToChildren(now, op['updateMany']['update'], model.schema);
Expand All @@ -158,7 +165,8 @@ module.exports = function castBulkWrite(originalModel, op, options) {
op['updateMany']['update'] = castUpdate(model.schema, op['updateMany']['update'], {
strict: strict,
overwrite: false,
upsert: op['updateMany'].upsert
upsert: op['updateMany'].upsert,
overwriteImmutable: op['updateMany'].overwriteImmutable
}, model, op['updateMany']['filter']);
} catch (error) {
return callback(error, null);
Expand All @@ -184,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 @@ -273,3 +281,4 @@ function decideModelByObject(model, object) {
}
return model;
}

4 changes: 2 additions & 2 deletions lib/helpers/query/castUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) {

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

Expand Down Expand Up @@ -335,7 +335,7 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) {
// You can use `$setOnInsert` with immutable keys
if (op !== '$setOnInsert' &&
!options.overwrite &&
handleImmutable(schematype, strict, obj, key, prefix + key, context)) {
handleImmutable(schematype, strict, obj, key, prefix + key, options, context)) {
continue;
}

Expand Down
6 changes: 5 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) {
if (schematype == null || !schematype.options || !schematype.options.immutable) {
return false;
}
Expand All @@ -15,6 +15,10 @@ module.exports = function handleImmutable(schematype, strict, obj, key, fullPath
return false;
}

if (options && options.overwriteImmutable) {
return false;
}

if (strict === false) {
return false;
}
Expand Down
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
133 changes: 133 additions & 0 deletions test/model.updateOne.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2707,6 +2707,139 @@ describe('model: updateOne: ', function() {
assert.equal(doc.age, 20);
});

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
}
}]);

// Assert
const updatedUser = await User.findById(user._id);
assert.strictEqual(updatedUser.ssn, '000-00-0000');
assert.strictEqual(updatedUser.createdAt.valueOf(), customCreatedAt.valueOf());
});

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 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 and timestamps: ${timestamps}`, 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 }
},
timestamps
},
{
updateMany: {
filter: { _id: users[1]._id },
update: { ssn: '777-77-7777', createdAt: newCreatedAt }
},
timestamps
}
]);


// 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() {
const Test = db.model('Test', Schema({
data: { type: Buffer, required: true }
Expand Down
Loading
Loading