diff --git a/README.md b/README.md index c61e543..b3dd37a 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,28 @@ whereas the options are listed here (with default value). ```js { - /* runs the insert within the sequelize hook chain, disable - for increased performance without warranties */ - blocking: true, - /* By default sequelize-temporal persist only changes, and saves the previous state in the history table. - The "full" option saves all transactions into the temporal database - (i.e. this includes the latest state.) - This allows to only query the hostory table to get the full history of an entity. - */ - full: false + /** + * Runs the insert within the sequelize hook chain, disable for increased performance without warranties + */ + blocking: true, + /** + * By default sequelize-temporal persist only changes, and saves the previous state in the history table. + * The "full" option saves all transactions into the temporal database (i.e. this includes the latest state.) + * This allows to only query the hostory table to get the full history of an entity. + */ + full: false, + /** + * Whether to skip inserting an History line if the update() call is silent. + */ + skipIfSilent: false, + /** + * This library will sometimes reload() an updated entity because it misses a few attributes. In order to minimize + * the number of extra SQL queries, an heuristic is applied to perform the reload() only if necessary. This mainly + * boils down to "is this attribute missing ?". + * The `reloadIgnoredAttributes` can be used to ignore certain attributes from this check. + */ + reloadIgnoredAttributes: ['deletedAt'] +} ``` Details diff --git a/index.js b/index.js index 0465fac..d879c8b 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,8 @@ var temporalDefaultOptions = { // for increased performance blocking: true, full: false, - skipIfSilent: false + skipIfSilent: false, + reloadIgnoredAttributes: ['deletedAt'] }; var excludeAttributes = function(obj, attrsToExclude){ @@ -36,8 +37,9 @@ var Temporal = function(model, sequelize, temporalOptions){ } }; + var attributes = model.getAttributes(); var excludedAttributes = ["Model","unique","primaryKey","autoIncrement", "set", "get", "_modelAttribute"]; - var historyAttributes = _(model.rawAttributes).mapValues(function(v){ + var historyAttributes = _(attributes).mapValues(function(v){ v = excludeAttributes(v, excludedAttributes); // remove the "NOW" defaultValue for the default timestamps // we want to save them, but just a copy from our master record @@ -70,8 +72,31 @@ var Temporal = function(model, sequelize, temporalOptions){ return; } - var dataValues = (!temporalOptions.full && obj._previousDataValues) || obj.dataValues; - var historyRecord = modelHistory.create(dataValues, {transaction: options.transaction}); + async function getDataValues() { + if (!temporalOptions.full) { + return obj._previousDataValues || obj.dataValues; + } + + const attributesToReload = Object.keys(attributes).filter(attribute => { + if (!temporalOptions.reloadIgnoredAttributes.includes(attribute) && !(attribute in obj.dataValues)) { + return true; + } + }); + + /** + * In 'full' mode, it's important that we are able to save a History version even though the caller initially fetched + * a partial instance. We do so at the cost of an extra SELECT to get up-to-date values (remember that in 'full' mode + * we are using afterX hooks, so this works). + * Model.reload() will do its magic to merge the newly fetched values directly in dataValues. #gg + */ + if (attributesToReload.length > 0) { + await obj.reload({transaction: options.transaction, attributes: attributesToReload, paranoid: false}) + } + + return obj.dataValues; + } + + var historyRecord = getDataValues().then(dataValues => modelHistory.create(dataValues, {transaction: options.transaction})); if(temporalOptions.blocking){ return historyRecord; diff --git a/test/test.js b/test/test.js index e6a6114..9d149b6 100644 --- a/test/test.js +++ b/test/test.js @@ -21,7 +21,8 @@ describe('Read-only API', function(){ // overwrites the old SQLite DB sequelize = new Sequelize('', '', '', { dialect: 'sqlite', - storage: __dirname + '/.test.sqlite' + storage: __dirname + '/.test.sqlite', + logging: process.env.LOGGING === 'true' ? console.log : false }); User = Temporal(sequelize.define('User', { name: Sequelize.TEXT @@ -325,6 +326,30 @@ describe('Read-only API', function(){ .then(assertCount(UserHistory,0)); }); + it('onUpdate: should store the previous version to the historyDB even if entity was partially loaded' , async function(){ + const created = await User.create({ name: 'name' }); + const user = await User.findByPk(created.id, { attributes: ['id', 'name'] }); // Don't fetch timestamps + + await user.update({ name: 'newName' }); + await user.update({ name: 'thirdName' }); + + const history = await UserHistory.findAll(); + + assert.equal(history.length, 3, 'initial revision and to updates saved'); + + const [initial, firstUpdate, secondUpdate] = history; + + assert.equal(+initial.createdAt, +firstUpdate.createdAt, 'createdAt was saved during first update, despite not being eagerly loaded'); + assert.equal(+initial.createdAt, +secondUpdate.createdAt, 'createdAt was saved during second update, despite not being eagerly loaded'); + + assert.isAtLeast(firstUpdate.updatedAt, initial.createdAt, 'updatedAt was saved during first update'); + assert.isAtLeast(secondUpdate.updatedAt, firstUpdate.updatedAt, 'updatedAt was saved during second update'); + + assert.equal('name', initial.name); + assert.equal('newName', firstUpdate.name); + assert.equal('thirdName', secondUpdate.name); + }); + }); describe('silent mode', function(){