diff --git a/lib/helpers/document/applyTimestamps.js b/lib/helpers/document/applyTimestamps.js new file mode 100644 index 0000000000..425e144c86 --- /dev/null +++ b/lib/helpers/document/applyTimestamps.js @@ -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; + } +} diff --git a/lib/model.js b/lib/model.js index 1c361bcb49..22a45affc0 100644 --- a/lib/model.js +++ b/lib/model.js @@ -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'); @@ -3541,6 +3542,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 * diff --git a/test/model.test.js b/test/model.test.js index b81cf8ff60..fc8d9619f4 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -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()); + }); + }); }); diff --git a/types/models.d.ts b/types/models.d.ts index 0a5e6e3a58..fd25412293 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -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