@codebit-programando-solucoes/sequelize-paper-trail
Version:
Track changes to your Sequelize models data. Perfect for auditing or versioning.
773 lines (679 loc) • 19.7 kB
JavaScript
import Sequelize from 'sequelize';
import cls from 'continuation-local-storage';
import * as jsdiff from 'diff';
import _ from 'lodash';
import helpers from './helpers';
let failHard = false;
exports.init = (sequelize, optionsArg) => {
// In case that options is being parsed as a readonly attribute.
// Or it is not passed at all
const optsArg = _.cloneDeep(optionsArg || {});
const defaultOptions = {
debug: false,
log: null,
exclude: [
'id',
'createdAt',
'updatedAt',
'deletedAt',
'created_at',
'updated_at',
'deleted_at',
'revision',
],
revisionAttribute: 'revision',
revisionModel: 'Revision',
revisionChangeModel: 'RevisionChange',
enableRevisionChangeModel: false,
UUID: false,
underscored: false,
underscoredAttributes: false,
defaultAttributes: {
documentId: 'documentId',
revisionId: 'revisionId',
},
userModel: false,
userModelAttribute: 'userId',
enableCompression: false,
enableMigration: false,
enableStrictDiff: true,
enablePreviousDocument: false,
continuationNamespace: null,
continuationKey: 'userId',
metaDataFields: null,
metaDataContinuationKey: 'metaData',
documentFieldType: 'postgres',
};
let ns = null;
if (optsArg.continuationNamespace) {
ns = cls.getNamespace(optsArg.continuationNamespace);
if (!ns) {
ns = cls.createNamespace(optsArg.continuationNamespace);
}
}
if (optsArg.underscoredAttributes) {
helpers.toUnderscored(defaultOptions.defaultAttributes);
}
// keep the compatibility with old project
if (optsArg.mysql && !optsArg.documentFieldType) {
optsArg.documentFieldType = 'legacy';
}
const options = _.defaults(optsArg, defaultOptions);
// if (optionsArg.defaultAttributes) {
// if (optionsArg.defaultAttributes.documentId) {
// defaultAttributes.documentId =
// optionsArg.defaultAttributes.documentId;
// }
// if (optionsArg.defaultAttributes.revisionId) {
// defaultAttributes.revisionId =
// optionsArg.defaultAttributes.revisionId;
// }
// }
// // if no options are passed the function
// if (!options) options = {};
// enable debug logging
// let debug = false;
// const { debug } = options;
// TODO: implement logging option
const log = options.log || console.log;
// show the current sequelize and options objects
// if (options.debug) {
// // log('sequelize object:');
// // log(sequelize);
// log('options object:');
// log(options);
// }
// // attribute name for revision number in the models
// if (!options.revisionAttribute) {
// options.revisionAttribute = 'revision';
// }
// // fields we want to exclude from audit trails
// if (!options.exclude) {
// options.exclude = [
// 'id',
// 'createdAt',
// 'updatedAt',
// 'deletedAt', // if the model is paranoid
// 'created_at',
// 'updated_at',
// 'deleted_at',
// options.revisionAttribute,
// ];
// }
// // model name for revision table
// if (!options.revisionModel) {
// options.revisionModel = 'Revision';
// }
// // model name for revision changes tables
// if (!options.revisionChangeModel) {
// options.revisionChangeModel = 'RevisionChange';
// }
// if (!options.enableRevisionChangeModel) {
// options.enableRevisionChangeModel = false;
// }
// // support UUID for postgresql
// if (options.UUID === undefined) {
// options.UUID = false;
// }
// // underscored created and updated attributes
// if (!options.underscored) {
// options.underscored = false;
// }
// if (!options.underscoredAttributes) {
// options.underscoredAttributes = false;
// options.defaultAttributes = defaultAttributes;
// } else {
// options.defaultAttributes = helpers.toUnderscored(defaultAttributes);
// }
// // To track the user that made the changes
// if (!options.userModel) {
// options.userModel = false;
// }
// // full revisions or compressed revisions (track only the difference in models)
// // default: full revisions
// if (!options.enableCompression) {
// options.enableCompression = false;
// }
// // add the column to the database if it doesn't exist
// if (!options.enableMigration) {
// options.enableMigration = false;
// }
// // enable strict diff
// // when true: 10 !== '10'
// // when false: 10 == '10'
// // default: true
// if (!options.enableStrictDiff) {
// options.enableStrictDiff = true;
// }
// let ns;
// if (options.continuationNamespace) {
// ns = cls.getNamespace(options.continuationNamespace);
// if (!ns) {
// ns = cls.createNamespace(options.continuationNamespace);
// }
// if (!options.continuationKey) {
// options.continuationKey = 'userId';
// }
// }
// if (options.debug) {
// log('parsed options:');
// log(options);
// }
function createBeforeHook(operation) {
const beforeHook = function beforeHook(instance, opt) {
if (options.debug) {
log('beforeHook called');
log('instance:', instance);
log('opt:', opt);
}
if (opt.noPaperTrail) {
if (options.debug) {
log('noPaperTrail opt: is true, not logging');
}
return;
}
const destroyOperation = operation === 'destroy';
let previousVersion = {};
let currentVersion = {};
if (!destroyOperation && options.enableCompression) {
_.forEach(opt.defaultFields, a => {
previousVersion[a] = instance._previousDataValues[a];
currentVersion[a] = instance.dataValues[a];
});
} else {
previousVersion = instance._previousDataValues;
currentVersion = instance.dataValues;
}
// Supported nested models.
previousVersion = _.omitBy(
previousVersion,
i => i != null && typeof i === 'object' && !(i instanceof Date),
);
previousVersion = _.omit(previousVersion, options.exclude);
currentVersion = _.omitBy(
currentVersion,
i => i != null && typeof i === 'object' && !(i instanceof Date),
);
currentVersion = _.omit(currentVersion, options.exclude);
// Disallow change of revision
instance.set(
options.revisionAttribute,
instance._previousDataValues[options.revisionAttribute],
);
// Get diffs
const delta = helpers.calcDelta(
previousVersion,
currentVersion,
options.exclude,
options.enableStrictDiff,
);
const currentRevisionId = instance.get(options.revisionAttribute);
if (failHard && !currentRevisionId && opt.type === 'UPDATE') {
throw new Error('Revision Id was undefined');
}
if (options.debug) {
log('delta:', delta);
log('revisionId', currentRevisionId);
}
// Check if all required fields have been provided to the opts / CLS
if (options.metaDataFields) {
// get all required field keys as an array
const requiredFields = _.keys(
_.pickBy(
options.metaDataFields,
function isMetaDataFieldRequired(required) {
return required;
},
),
);
if (requiredFields && requiredFields.length) {
const metaData =
(ns && ns.get(options.metaDataContinuationKey)) ||
opt.metaData;
const requiredFieldsProvided = _.filter(
requiredFields,
function isMetaDataFieldNonUndefined(field) {
return metaData[field] !== undefined;
},
);
if (
requiredFieldsProvided.length !== requiredFields.length
) {
log(
'Required fields: ',
options.metaDataFields,
requiredFields,
);
log(
'Required fields provided: ',
metaData,
requiredFieldsProvided,
);
throw new Error(
'Not all required fields are provided to paper trail!',
);
}
}
}
if (destroyOperation || (delta && delta.length > 0)) {
const revisionId = (currentRevisionId || 0) + 1;
instance.set(options.revisionAttribute, revisionId);
if (!instance.context) {
instance.context = {};
}
instance.context.delta = delta;
}
if (options.debug) {
log('end of beforeHook');
}
};
return beforeHook;
}
function createAfterHook(operation) {
const afterHook = function afterHook(instance, opt) {
if (options.debug) {
log('afterHook called');
log('instance:', instance);
log('opt:', opt);
if (ns) {
log(
`CLS ${options.continuationKey}:`,
ns.get(options.continuationKey),
);
}
}
const destroyOperation = operation === 'destroy';
if (
instance.context &&
((instance.context.delta &&
instance.context.delta.length > 0) ||
destroyOperation)
) {
const Revision = sequelize.model(options.revisionModel);
let RevisionChange;
if (options.enableRevisionChangeModel) {
RevisionChange = sequelize.model(
options.revisionChangeModel,
);
}
const { delta } = instance.context;
let previousVersion = {};
let currentVersion = {};
if (!destroyOperation && options.enableCompression) {
_.forEach(opt.defaultFields, a => {
previousVersion[a] = instance._previousDataValues[a];
currentVersion[a] = instance.dataValues[a];
});
} else {
previousVersion = instance._previousDataValues;
currentVersion = instance.dataValues;
}
// Supported nested models.
previousVersion = _.omitBy(
previousVersion,
i =>
i != null &&
typeof i === 'object' &&
!(i instanceof Date),
);
previousVersion = _.omit(previousVersion, options.exclude);
currentVersion = _.omitBy(
currentVersion,
i =>
i != null &&
typeof i === 'object' &&
!(i instanceof Date),
);
currentVersion = _.omit(currentVersion, options.exclude);
if (failHard && ns && !ns.get(options.continuationKey)) {
throw new Error(
`The CLS continuationKey ${options.continuationKey} was not defined.`,
);
}
let document = currentVersion;
let previousDocument = previousVersion;
if (options.documentFieldType === 'legacy') {
document = JSON.stringify(document);
}
// Build revision
const query = {
model: this.name,
document,
operation,
};
// add previous version if enable
if (options.enablePreviousDocument) {
const keys = Object.keys(previousDocument);
let preventNulls = {};
if (operation == 'update') {
keys.forEach(key => {
if (previousDocument[key] != null)
preventNulls[key] = previousDocument[key];
});
if (options.documentFieldType === 'legacy') {
previousDocument = JSON.stringify(preventNulls);
}
} else {
preventNulls = null;
}
query.previousDocument = preventNulls;
}
// Add all extra data fields to the query object
if (options.metaDataFields) {
const metaData =
(ns && ns.get(options.metaDataContinuationKey)) ||
opt.metaData;
if (metaData) {
_.forEach(
options.metaDataFields,
function getMetaDataValues(required, field) {
const value = metaData[field];
if (options.debug) {
log(
`Adding metaData field to Revision - ${field} => ${value}`,
);
}
if (!(field in query)) {
query[field] = value;
} else if (options.debug) {
log(
`Revision object already has a value at ${field} => ${query[field]}`,
);
log('Not overwriting the original value');
}
},
);
}
}
// in case of custom user models that are not 'userId'
query[options.userModelAttribute] =
(ns && ns.get(options.continuationKey)) || opt.userId;
query[options.defaultAttributes.documentId] = instance.id;
const revision = Revision.build(query);
revision[options.revisionAttribute] = instance.get(
options.revisionAttribute,
);
// Save revision
return revision
.save({ transaction: opt.transaction })
.then(objectRevision => {
// Loop diffs and create a revision-diff for each
if (options.enableRevisionChangeModel) {
_.forEach(delta, difference => {
const o = helpers.diffToString(
difference.item
? difference.item.lhs
: difference.lhs,
);
const n = helpers.diffToString(
difference.item
? difference.item.rhs
: difference.rhs,
);
// let document = difference;
document = difference;
let diff = o || n ? jsdiff.diffChars(o, n) : [];
if (options.documentFieldType === 'legacy') {
document = JSON.stringify(document);
diff = JSON.stringify(diff);
}
const d = RevisionChange.build({
path: difference.path[0],
document,
diff,
revisionId: objectRevision.id,
});
d.save({ transaction: opt.transaction })
.then(savedD => {
// Add diff to revision
objectRevision[
`add${helpers.capitalizeFirstLetter(
options.revisionChangeModel,
)}`
](savedD);
return null;
})
.catch(err => {
log('RevisionChange save error', err);
throw err;
});
});
}
return null;
})
.catch(err => {
log('Revision save error', err);
throw err;
});
}
if (options.debug) {
log('end of afterHook');
}
return null;
};
return afterHook;
}
// order in which sequelize processes the hooks
// (1)
// beforeBulkCreate(instances, options, fn)
// beforeBulkDestroy(instances, options, fn)
// beforeBulkUpdate(instances, options, fn)
// (2)
// beforeValidate(instance, options, fn)
// (-)
// validate
// (3)
// afterValidate(instance, options, fn)
// - or -
// validationFailed(instance, options, error, fn)
// (4)
// beforeCreate(instance, options, fn)
// beforeDestroy(instance, options, fn)
// beforeUpdate(instance, options, fn)
// (-)
// create
// destroy
// update
// (5)
// afterCreate(instance, options, fn)
// afterDestroy(instance, options, fn)
// afterUpdate(instance, options, fn)
// (6)
// afterBulkCreate(instances, options, fn)
// afterBulkDestroy(instances, options, fn)
// afterBulkUpdate(instances, options, fn)
// Extend model prototype with "hasPaperTrail" function
// Call model.hasPaperTrail() to enable revisions for model
_.assignIn(Sequelize.Model, {
hasPaperTrail: function hasPaperTrail() {
if (options.debug) {
log('Enabling paper trail on', this.name);
}
this.rawAttributes[options.revisionAttribute] = {
type: Sequelize.INTEGER,
defaultValue: 0,
};
this.revisionable = true;
// not sure if we need this
this.refreshAttributes();
if (options.enableMigration) {
const tableName = this.getTableName();
const queryInterface = sequelize.getQueryInterface();
queryInterface.describeTable(tableName).then(attributes => {
if (!attributes[options.revisionAttribute]) {
if (options.debug) {
log('adding revision attribute to the database');
}
queryInterface
.addColumn(tableName, options.revisionAttribute, {
type: Sequelize.INTEGER,
defaultValue: 0,
})
.then(() => null)
.catch(err => {
log('something went really wrong..', err);
return null;
});
}
return null;
});
}
this.addHook('beforeCreate', createBeforeHook('create'));
this.addHook('beforeDestroy', createBeforeHook('destroy'));
this.addHook('beforeUpdate', createBeforeHook('update'));
this.addHook('afterCreate', createAfterHook('create'));
this.addHook('afterDestroy', createAfterHook('destroy'));
this.addHook('afterUpdate', createAfterHook('update'));
// create association
return this.hasMany(sequelize.models[options.revisionModel], {
foreignKey: options.defaultAttributes.documentId,
constraints: false,
scope: {
model: this.name,
},
});
},
});
return {
// Return defineModels()
defineModels: function defineModels(db) {
// Attributes for RevisionModel
let attributes = {
model: {
type: Sequelize.TEXT,
allowNull: false,
},
document: {
type: Sequelize.JSONB,
allowNull: false,
},
operation: Sequelize.STRING(7),
};
if (options.enablePreviousDocument) {
attributes.previousDocument = {
type: Sequelize.JSONB,
allowNull: true,
};
}
if (options.documentFieldType === 'legacy') {
attributes.document.type = Sequelize.TEXT('MEDIUMTEXT');
} else if (options.documentFieldType === 'mysql') {
attributes.document.type = Sequelize.JSON;
if (options.enablePreviousDocument) {
attributes.previousDocument.type = Sequelize.JSON;
}
}
attributes[options.defaultAttributes.documentId] = {
type: Sequelize.INTEGER,
allowNull: false,
};
attributes[options.revisionAttribute] = {
type: Sequelize.INTEGER,
allowNull: false,
};
if (options.UUID) {
attributes.id = {
primaryKey: true,
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
};
attributes[options.defaultAttributes.documentId].type =
Sequelize.UUID;
}
if (options.debug) {
log('attributes', attributes);
}
// Revision model
const Revision = sequelize.define(
options.revisionModel,
attributes,
{
underscored: options.underscored,
tableName: options.tableName,
},
);
Revision.associate = function associate(models) {
if (options.debug) {
log('models', models);
}
Revision.belongsTo(
sequelize.model(options.userModel),
options.belongsToUserOptions,
);
};
if (options.enableRevisionChangeModel) {
// Attributes for RevisionChangeModel
attributes = {
path: {
type: Sequelize.TEXT,
allowNull: false,
},
document: {
type: Sequelize.JSONB,
allowNull: false,
},
diff: {
type: Sequelize.JSONB,
allowNull: false,
},
};
if (options.documentFieldType === 'legacy') {
attributes.document.type = Sequelize.TEXT('MEDIUMTEXT');
attributes.diff.type = Sequelize.TEXT('MEDIUMTEXT');
} else if (options.documentFieldType === 'mysql') {
attributes.document.type = Sequelize.JSON;
attributes.diff.type = Sequelize.JSON;
}
if (options.UUID) {
attributes.id = {
primaryKey: true,
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
};
}
// RevisionChange model
const RevisionChange = sequelize.define(
options.revisionChangeModel,
attributes,
{
underscored: options.underscored,
},
);
// Set associations
Revision.hasMany(RevisionChange, {
foreignKey: options.defaultAttributes.revisionId,
constraints: false,
});
// https://github.com/nielsgl/sequelize-paper-trail/issues/10
// RevisionChange.belongsTo(Revision, {
// foreignKey: options.defaultAttributes.revisionId,
// });
RevisionChange.belongsTo(Revision);
if (db) db[RevisionChange.name] = RevisionChange;
}
if (db) db[Revision.name] = Revision;
/*
* We could extract this to a separate function so that having a
* user model doesn't require different loading
*
* or perhaps we could omit this because we are creating the
* association through the associate call above.
*/
if (options.userModel) {
Revision.belongsTo(
sequelize.model(options.userModel),
options.belongsToUserOptions,
);
}
return Revision;
},
};
};
/**
* Throw exceptions when the user identifier from CLS is not set or if the
* revisionAttribute was not loaded on the model.
*/
exports.enableFailHard = () => {
failHard = true;
};
module.exports = exports;