From 8d66fa98b28e193441b37410158efd1adeafbca7 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 8 Oct 2024 14:23:29 -0400 Subject: [PATCH 1/3] feat(model): add applyTimestamps() function to apply all schema timestamps, including subdocuments, to a given POJO Fix #14698 --- lib/helpers/document/applyTimestamps.js | 105 ++++++++++++++++++++++++ lib/model.js | 34 ++++++++ test/model.test.js | 102 +++++++++++++++++++++++ types/models.d.ts | 5 ++ 4 files changed, 246 insertions(+) create mode 100644 lib/helpers/document/applyTimestamps.js diff --git a/lib/helpers/document/applyTimestamps.js b/lib/helpers/document/applyTimestamps.js new file mode 100644 index 0000000000..db9161fe52 --- /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 `applyVirtualsToTimestamps` instead + * + * @param {Schema} schema + * @param {Object} doc + * @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..dba37ab6c7 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,39 @@ 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 {Array} [virtualsToApply] optional whitelist of virtuals to apply + * @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..63f0a8cdcb 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -8018,6 +8018,108 @@ 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()); + }); + + 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 From da75251ce10dab5eefed335e05c4026520929b96 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 9 Oct 2024 18:29:04 -0400 Subject: [PATCH 2/3] Update lib/helpers/document/applyTimestamps.js Co-authored-by: hasezoey --- lib/helpers/document/applyTimestamps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/helpers/document/applyTimestamps.js b/lib/helpers/document/applyTimestamps.js index db9161fe52..7536177d58 100644 --- a/lib/helpers/document/applyTimestamps.js +++ b/lib/helpers/document/applyTimestamps.js @@ -54,7 +54,7 @@ function applyTimestampsToChildren(schema, res, options) { * Apply timestamps to a given document. Does not apply timestamps to subdocuments: use `applyVirtualsToTimestamps` instead * * @param {Schema} schema - * @param {Object} doc + * @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. From c52041b17f78fd787db2e9e5dbf48094a4e2a78f Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 9 Oct 2024 18:31:40 -0400 Subject: [PATCH 3/3] fix code review comments --- lib/helpers/document/applyTimestamps.js | 2 +- lib/model.js | 4 +++- test/model.test.js | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/helpers/document/applyTimestamps.js b/lib/helpers/document/applyTimestamps.js index db9161fe52..ad6897f596 100644 --- a/lib/helpers/document/applyTimestamps.js +++ b/lib/helpers/document/applyTimestamps.js @@ -51,7 +51,7 @@ function applyTimestampsToChildren(schema, res, options) { } /** - * Apply timestamps to a given document. Does not apply timestamps to subdocuments: use `applyVirtualsToTimestamps` instead + * Apply timestamps to a given document. Does not apply timestamps to subdocuments: use `applyTimestampsToChildren` instead * * @param {Schema} schema * @param {Object} doc diff --git a/lib/model.js b/lib/model.js index dba37ab6c7..22a45affc0 100644 --- a/lib/model.js +++ b/lib/model.js @@ -3556,7 +3556,9 @@ Model.applyVirtuals = function applyVirtuals(obj, virtualsToApply) { * obj.updatedAt; // 2024-06-01T18:00:00.000Z * * @param {Object} obj object or document to apply virtuals on - * @param {Array} [virtualsToApply] optional whitelist of virtuals to apply + * @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 */ diff --git a/test/model.test.js b/test/model.test.js index 63f0a8cdcb..fc8d9619f4 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -8063,6 +8063,8 @@ describe('Model', function() { 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() {