Skip to content

Commit

Permalink
Merge pull request #14943 from Automattic/vkarpov15/gh-14698-2
Browse files Browse the repository at this point in the history
feat(model): add applyTimestamps() function to apply all schema timestamps, including subdocuments, to a given POJO
  • Loading branch information
vkarpov15 authored Oct 13, 2024
2 parents 02c5efd + cce41a5 commit 84fe02c
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 0 deletions.
105 changes: 105 additions & 0 deletions lib/helpers/document/applyTimestamps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'use strict';

const handleTimestampOption = require('../schema/handleTimestampOption');
const mpath = require('mpath');

module.exports = applyTimestamps;

/**
* Apply a given schema's timestamps to the given POJO
*
* @param {Schema} schema
* @param {Object} obj
* @param {Object} [options]
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
*/

function applyTimestamps(schema, obj, options) {
if (obj == null) {
return obj;
}

applyTimestampsToChildren(schema, obj, options);
return applyTimestampsToDoc(schema, obj, options);
}

/**
* Apply timestamps to any subdocuments
*
* @param {Schema} schema subdocument schema
* @param {Object} res subdocument
* @param {Object} [options]
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
*/

function applyTimestampsToChildren(schema, res, options) {
for (const childSchema of schema.childSchemas) {
const _path = childSchema.model.path;
const _schema = childSchema.schema;
if (!_path) {
continue;
}
const _obj = mpath.get(_path, res);
if (_obj == null || (Array.isArray(_obj) && _obj.flat(Infinity).length === 0)) {
continue;
}

applyTimestamps(_schema, _obj, options);
}
}

/**
* Apply timestamps to a given document. Does not apply timestamps to subdocuments: use `applyTimestampsToChildren` instead
*
* @param {Schema} schema
* @param {Object} obj
* @param {Object} [options]
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
*/

function applyTimestampsToDoc(schema, obj, options) {
if (obj == null || typeof obj !== 'object') {
return;
}
if (Array.isArray(obj)) {
for (const el of obj) {
applyTimestampsToDoc(schema, el, options);
}
return;
}

if (schema.discriminators && Object.keys(schema.discriminators).length > 0) {
for (const discriminatorKey of Object.keys(schema.discriminators)) {
const discriminator = schema.discriminators[discriminatorKey];
const key = discriminator.discriminatorMapping.key;
const value = discriminator.discriminatorMapping.value;
if (obj[key] == value) {
schema = discriminator;
break;
}
}
}

const createdAt = handleTimestampOption(schema.options.timestamps, 'createdAt');
const updatedAt = handleTimestampOption(schema.options.timestamps, 'updatedAt');
const currentTime = options?.currentTime;

let ts = null;
if (currentTime != null) {
ts = currentTime();
} else if (schema.base?.now) {
ts = schema.base.now();
} else {
ts = new Date();
}

if (createdAt && obj[createdAt] == null && !options?.isUpdate) {
obj[createdAt] = ts;
}
if (updatedAt) {
obj[updatedAt] = ts;
}
}
36 changes: 36 additions & 0 deletions lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const applyReadConcern = require('./helpers/schema/applyReadConcern');
const applySchemaCollation = require('./helpers/indexes/applySchemaCollation');
const applyStaticHooks = require('./helpers/model/applyStaticHooks');
const applyStatics = require('./helpers/model/applyStatics');
const applyTimestampsHelper = require('./helpers/document/applyTimestamps');
const applyWriteConcern = require('./helpers/schema/applyWriteConcern');
const applyVirtualsHelper = require('./helpers/document/applyVirtuals');
const assignVals = require('./helpers/populate/assignVals');
Expand Down Expand Up @@ -3540,6 +3541,41 @@ Model.applyVirtuals = function applyVirtuals(obj, virtualsToApply) {
return obj;
};

/**
* Apply this model's timestamps to a given POJO, including subdocument timestamps
*
* #### Example:
*
* const userSchema = new Schema({ name: String }, { timestamps: true });
* const User = mongoose.model('User', userSchema);
*
* const obj = { name: 'John' };
* User.applyTimestamps(obj);
* obj.createdAt; // 2024-06-01T18:00:00.000Z
* obj.updatedAt; // 2024-06-01T18:00:00.000Z
*
* @param {Object} obj object or document to apply virtuals on
* @param {Object} [options]
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
* @returns {Object} obj
* @api public
*/

Model.applyTimestamps = function applyTimestamps(obj, options) {
if (obj == null) {
return obj;
}
// Nothing to do if this is already a hydrated document - it should already have timestamps
if (obj.$__ != null) {
return obj;
}

applyTimestampsHelper(this.schema, obj, options);

return obj;
};

/**
* Cast the given POJO to the model's schema
*
Expand Down
104 changes: 104 additions & 0 deletions test/model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8018,6 +8018,110 @@ describe('Model', function() {
assert.strictEqual(res.friend, null);
});
});

describe('applyTimestamps', function() {
it('handles basic top-level timestamps', async function() {
const startTime = new Date();
const userSchema = new Schema({
name: String
}, { timestamps: true });
const User = db.model('User', userSchema);

const obj = { name: 'test' };
User.applyTimestamps(obj);
assert.equal(obj.name, 'test');
assert.ok(obj.createdAt instanceof Date);
assert.ok(obj.updatedAt instanceof Date);
assert.ok(obj.createdAt.valueOf() >= startTime.valueOf());
assert.ok(obj.updatedAt.valueOf() >= startTime.valueOf());
});

it('no-op if timestamps not set', async function() {
const userSchema = new Schema({
name: String
});
const User = db.model('User', userSchema);

const obj = { name: 'test' };
User.applyTimestamps(obj);
assert.equal(obj.name, 'test');
assert.ok(!('createdAt' in obj));
assert.ok(!('updatedAt' in obj));
});

it('handles custom timestamp property names', async function() {
const startTime = new Date();
const userSchema = new Schema({
name: String
}, { timestamps: { createdAt: 'createdOn', updatedAt: 'updatedOn' } });
const User = db.model('User', userSchema);

const obj = { name: 'test' };
User.applyTimestamps(obj);
assert.equal(obj.name, 'test');
assert.ok(obj.createdOn instanceof Date);
assert.ok(obj.updatedOn instanceof Date);
assert.ok(obj.createdOn.valueOf() >= startTime.valueOf());
assert.ok(obj.updatedOn.valueOf() >= startTime.valueOf());
assert.ok(!('createdAt' in obj));
assert.ok(!('updatedAt' in obj));
});

it('applies timestamps to subdocs', async function() {
const startTime = new Date();
const userSchema = new Schema({
name: String,
posts: [new Schema({
title: String,
content: String
}, { timestamps: true })],
address: new Schema({
city: String,
country: String
}, { timestamps: true })
}, { timestamps: true });
const User = db.model('User', userSchema);

const obj = {
name: 'test',
posts: [{ title: 'Post 1', content: 'Content 1' }],
address: { city: 'New York', country: 'USA' }
};
User.applyTimestamps(obj);
assert.equal(obj.name, 'test');
assert.ok(obj.createdAt instanceof Date);
assert.ok(obj.updatedAt instanceof Date);
assert.ok(obj.createdAt.valueOf() >= startTime.valueOf());
assert.ok(obj.updatedAt.valueOf() >= startTime.valueOf());
assert.ok(obj.posts[0].createdAt instanceof Date);
assert.ok(obj.posts[0].updatedAt instanceof Date);
assert.ok(obj.address.createdAt instanceof Date);
assert.ok(obj.address.updatedAt instanceof Date);
});

it('supports isUpdate and currentTime options', async function() {
const userSchema = new Schema({
name: String,
post: new Schema({
title: String,
content: String
}, { timestamps: true })
}, { timestamps: true });
const User = db.model('User', userSchema);

const obj = {
name: 'test',
post: { title: 'Post 1', content: 'Content 1' }
};
User.applyTimestamps(obj, { isUpdate: true, currentTime: () => new Date('2023-06-01T18:00:00.000Z') });
assert.equal(obj.name, 'test');
assert.ok(!('createdAt' in obj));
assert.ok(obj.updatedAt instanceof Date);
assert.equal(obj.updatedAt.valueOf(), new Date('2023-06-01T18:00:00.000Z').valueOf());
assert.ok(!('createdAt' in obj.post));
assert.ok(obj.post.updatedAt.valueOf(), new Date('2023-06-01T18:00:00.000Z').valueOf());
});
});
});


Expand Down
5 changes: 5 additions & 0 deletions types/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ declare module 'mongoose' {
/* Apply virtuals to the given POJO. */
applyVirtuals(obj: AnyObject, virtalsToApply?: string[]): AnyObject;

/**
* Apply this model's timestamps to a given POJO, including subdocument timestamps
*/
applyTimestamps(obj: AnyObject, options?: { isUpdate?: boolean, currentTime?: () => Date }): AnyObject;

/**
* Sends multiple `insertOne`, `updateOne`, `updateMany`, `replaceOne`,
* `deleteOne`, and/or `deleteMany` operations to the MongoDB server in one
Expand Down

0 comments on commit 84fe02c

Please sign in to comment.