Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(model): add applyTimestamps() function to apply all schema timestamps, including subdocuments, to a given POJO #14943

Merged
merged 4 commits into from
Oct 13, 2024
Merged
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
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 @@ -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
*
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());
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
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