UNPKG

@e10/bookshelf-relations

Version:
502 lines (435 loc) 18.9 kB
const Promise = require('bluebird'); const _ = require('lodash'); const Detector = require('./detector'); const debug = require('@e10/debug')('relations'); const errors = require('../errors'); /** * @TODO: * - add `forUpdate` lock * - add nullable belongsTo FK example **/ /** * @description Relations class. It handles the update of relations for all types (belongsTo, hasOne, belongsToMany, hasMany) */ class Relations { constructor(bookshelf, pluginOpts = {}) { this.bookshelf = bookshelf; this.detector = new Detector(this.bookshelf); this.extendChanged = pluginOpts.extendChanged || null; this.attachPreviousRelations = pluginOpts.attachPreviousRelations; } /** * @description Entry point to start updating relations. * @param {Object} data * @param {Object} data.model * @param {Object} data.relations * @param {Object} [data.pluginOptions] * @param {Object} [options] * @returns {Promise} */ updateRelations({model, relations, pluginOptions = {}}, options = {}) { let opts = _.cloneDeep(options); let promises = []; // CASE: Figure out which type of relation it is and call the helper, which is responsible for this update. Object.keys(relations).forEach((key) => { let value = relations[key]; let relation = (model[key] instanceof Function && (typeof value === 'object' || Array.isArray(value))) ? model[key](model) : null; // NOTE: Bookshelf provides us the "type" based on what you configure on your project e.g. `return this.hasMany`. let type = relation ? relation.relatedData.type : null; if (!type) { throw new errors.BookshelfRelationsError({ message: 'No relation type.', code: 'UNKNOWN_RELATION', context: { key: key } }); } let method = 'set' + type.charAt(0).toUpperCase() + type.slice(1); let setter = this[method]; promises.push(() => { debug(key, method); return setter.bind(this)({ model: model, key: key, value: value, relation: relation, pluginOptions: pluginOptions }, opts).catch((err) => { if (errors.utils.isLuinqError(err) || (_.isArray(err) && errors.utils.isLuinqError(err[0]))) { throw err; } throw new errors.BookshelfRelationsError({ message: 'Unable to update nested relation.', code: 'UPDATE_RELATION', err: err, context: { key: key, tableName: model.tableName, method: method } }); }); }); }); return Promise.each(promises, (promise) => { return promise(); }); } /** * @description Update belongs to many relation. * @param {Object} data * @param {Object} options * @returns {Promise} */ setBelongsTo(data, options) { debug('setBelongsTo:start'); let model = data.model; let key = data.key; let properties = data.value; let relation = data.relation; let opts = _.cloneDeep(options); let Target = relation.relatedData.target; let fk = relation.relatedData.foreignKey; const pluginOptions = data.pluginOptions; // CASE: If there is no foreign key, you have mis-configured your relation via Bookshelf. // The foreign key in this case is the column name in the parent resource. if (!fk) { throw new errors.BookshelfRelationsError({ message: 'Foreign Key not found.', code: 'UNKNOWN_RELATION', help: 'this.hasOne(Target, foreignKey)', context: { key: key, tableName: model.tableName } }); } let targetData = { model: model, Target: Target, fk: {key: 'id', value: model.get(fk)}, properties: properties, key: key, saveValue: null, pluginOptions: pluginOptions }; // CASE 1: Parent resource gets added, just add the relation and connect. // CASE 2: Parent resource gets updated. if (opts.method === 'insert') { return this.detector.saveTarget(targetData, opts) .then(function (result) { // CASE: Set relation id on parent resource. model.set(fk, result.id); // CASE: Remember relations. model.relations[key] = result; debug('setBelongsTo:end'); return model; }); } else { return this.detector.saveTarget(targetData, opts) .then((result) => { if (model.get(fk) === result.id) { return result; } // @TODO: I think it's unnecessary to manually update the relation id, because the parent will get updated anyway. let toSave = model.constructor.forge({id: model.id}); opts.method = 'update'; toSave.set(fk, result.id); return toSave.save(null, opts) .then(() => { return result; }); }) .then((result) => { model.relations[key] = result; debug('setBelongsTo:end'); return model; }); } } /** * @description Update "has one" relation. * @param {Object} data * @param {Object} options * @returns {Promise} */ setHasOne(data, options) { debug('setHasOne:start'); let model = data.model; let key = data.key; let properties = data.value; let relation = data.relation; let Target = relation.relatedData.target; let fk = relation.relatedData.foreignKey; let opts = _.cloneDeep(options); const pluginOptions = data.pluginOptions; // CASE: If there is no foreign key, you have mis-configured your relation via Bookshelf. // The foreign key in this case is the column name in the relation table. if (!fk) { throw new errors.BookshelfRelationsError({ message: 'Foreign Key not found.', code: 'UNKNOWN_RELATION', help: 'this.hasOne(Target, foreignKey)', context: { key: key, tableName: model.tableName } }); } let targetData = { Target: Target, fk: {key: fk, value: model.id}, properties: properties, saveValue: {[fk]: model.id}, key: key, model: model, pluginOptions: pluginOptions }; // CASE: we have a nested relation with properties set, but no ID, check if there is an existing relation before saving if (targetData.properties && !_.isEmpty(targetData.properties) && !targetData.properties.id) { return this.detector.getExistingTargets({model: model, key: key}, opts) .then((existingTargets) => { debug('setHasOne:lookup'); if (existingTargets && existingTargets.id) { targetData.properties.id = existingTargets.id; } return this.detector.saveTarget(targetData, opts) .then((result) => { model.relations[key] = result; debug('setHasOne:end'); return model; }); }); } // CASE: we already have the id of the nested relation, go ahead and update it // CASE: there are no targetData properties at all, which is used for unsetting fields return this.detector.saveTarget(targetData, opts) .then((result) => { model.relations[key] = result; debug('setHasOne:end'); return model; }); } /** * @description Handle updating belongs to many relations. * @param {Object} data * @param {Object} options * @returns {Promise} */ setBelongsToMany(data, options) { debug('setBelongsToMany:start'); let model = data.model; let key = data.key; let newTargets = data.value; let pluginOptions = data.pluginOptions; let opts = _.cloneDeep(options); let existingTargets; let targetsToReturn; const changed = {}; // CASE: Get all existing relations of model. return this.detector.getExistingTargets({model: model, key: key, pluginOptions: pluginOptions}, opts) .then((_existingTargets) => { existingTargets = _existingTargets; // CASE: Plugin option. You can ask to return previous relations to figure out if relations have changed. if (this.attachPreviousRelations) { if (!model._previousRelations) { model._previousRelations = {}; } model._previousRelations[key] = _.cloneDeep(existingTargets); } // CASE: delete all targets (send []), no need to check if targets need to be inserted/updated if (!newTargets.length) { return this.bookshelf.Collection.forge([]); } // CASE: Add relations based on existing & new targets. See detector for more context. // NOTE: This will only add the target relation (e.g. insert tags). This won't connect the parent with the relation. return this.detector.saveTargets({ key: key, existingTargets: existingTargets, newTargets: newTargets, pluginOptions: pluginOptions }, opts); }) .then((targets) => { // NOTE: final relations targetsToReturn = targets; // CASE: enforce parent id to complete bookshelf relation data existingTargets.relatedData.parentId = model.id; existingTargets.relatedData.parentFk = model.id; let targetsToAttach = []; let targetsToDetach = []; // CASE: Figure out which relations we have to attach (we have to connect both tables) _.each(targets.models, (target) => { if (!existingTargets.find(model => model.get('id') === target.id)) { targetsToAttach.push(target); } }); function getFn(obj, nameA, nameB) { if (!obj) { return null; } if (typeof obj[nameA] === 'function') { return { fn: obj[nameA], name: nameA }; } if (typeof obj[nameB] === 'function') { return { fn: obj[nameB], name: nameB }; } return null; } function getHook(name, fallbackName) { const globalHook = getFn(pluginOptions?.hooks?.belongsToMany, name, fallbackName); const modelHook = getFn(model?.hooks?.belongsToMany, name, fallbackName); if (modelHook) { return modelHook.fn.bind(model); } if (globalHook) { return globalHook.fn; } return null; } return Promise.resolve() .then(function () { if (!targetsToAttach.length) { return; } // NOTE: listen on created target models and allow to hook into the process existingTargets.on('creating', (collection, data) => { const hook = getHook('before', 'beforeRelationCreation'); if (hook) { return hook(collection, data, opts); } }); // CASE: Physically connect the two tables (look up table e.g. posts_tags) return existingTargets.attach(targetsToAttach, _.pick(opts, ['transacting'])) .then(function () { if (!changed[key]) { changed[key] = { attached: [], detached: [] }; } changed[key].attached = targetsToAttach; existingTargets.off('creating'); }); }) .then(() => { // CASE: Figure out which relations we have to detach _.each(existingTargets.models, (target) => { if (!targets.find(model => model.get('id') === target.id)) { targetsToDetach.push(target); } }); if (!targetsToDetach.length) { return; } // NOTE: avoid InnoDB deadlock by detaching model by model (e.g. https://www.percona.com/blog/2012/03/27/innodbs-gap-locks/) return Promise.each(targetsToDetach, (targetToDetach) => { if (!changed[key]) { changed[key] = { attached: [], detached: [] }; } changed[key].detached.push(targetToDetach); return existingTargets.detach(targetToDetach, _.pick(opts, ['transacting'])); }); }).then(() => { const hook = getHook('after', 'afterRelationCreated'); if (hook) { return hook(existingTargets, targets, opts); } }); }) .then(() => { // CASE: Plugin option. You can ask to attach the information what has changed. if (this.extendChanged && Object.keys(changed)) { if (!model[this.extendChanged]) { model[this.extendChanged] = {}; } Object.keys(changed).forEach((key) => { model[this.extendChanged][key] = changed[key]; }); } model.relations[key] = targetsToReturn; debug('setBelongsToMany:end'); return model; }); } /** * @description Handle "has many" relations. * @param {Object} data * @param {Object} options * @returns {Promise} */ setHasMany(data, options) { debug('setHasMany:start'); let model = data.model; let key = data.key; let newTargets = data.value; let relation = data.relation; let opts = _.cloneDeep(options); const pluginOptions = data.pluginOptions; let fk = relation.relatedData.foreignKey; let existingTargets; let targets; // CASE: If there is no foreign key, you have mis-configured your relation via Bookshelf. // The foreign key in this case is the column name in the relation table. if (!fk) { throw new errors.BookshelfRelationsError({ message: 'Foreign Key not found.', code: 'UNKNOWN_RELATION', help: 'this.hasMany(Target, foreignKey)', context: { key: key, tableName: model.tableName } }); } // NOTE: "newTargets" are the relations to insert/update. // CASE: Prepare data and attach foreign key. newTargets = newTargets.map((newTarget) => { if (!newTarget[fk]) { newTarget[fk] = model.id; } return newTarget; }); // CASE: Get all existing relations of this model. return this.detector.getExistingTargets({model: model, key: key}, opts) .then((_existingTargets) => { existingTargets = _existingTargets; // CASE: Save targets based on new & existing targets. See detector for more context. return this.detector.saveTargets({ key: key, existingTargets: _existingTargets, newTargets: newTargets, pluginOptions: pluginOptions }, options); }) .then((_targets) => { // NOTE: final relations targets = _targets; targets.forEach((target) => { existingTargets.add(target); }); // CASE: destroy old relations return existingTargets.mapThen((target) => { if (!targets.find(model => model.get('id') === target.id)) { return target.destroy(options); } }); }) .then(() => { model.relations[key] = targets; debug('setHasMany:end'); return model; }); } } module.exports = Relations;