Source: Mixin.Triggers.js

"use strict";

var Overload = require('./Overload');

/**
 * Provides trigger functionality methods.
 * @mixin
 */
var Triggers = {
	/**
	 * Add a trigger by id, type and phase.
	 * @name addTrigger
	 * @method Triggers.addTrigger
	 * @param {String} id The id of the trigger. This must be unique to the type and
	 * phase of the trigger. Only one trigger may be added with this id per type and
	 * phase.
	 * @param {Constants} type The type of operation to apply the trigger to. See
	 * Mixin.Constants for constants to use.
	 * @param {Constants} phase The phase of an operation to fire the trigger on. See
	 * Mixin.Constants for constants to use.
	 * @param {Triggers.addTriggerCallback} method The method to call when the trigger is fired.
	 * @returns {boolean} True if the trigger was added successfully, false if not.
	 */
	addTrigger: function (id, type, phase, method) {
		var self = this,
			triggerIndex;

		// Check if the trigger already exists
		triggerIndex = self._triggerIndexOf(id, type, phase);

		if (triggerIndex === -1) {
			self.triggerStack = {};

			// The trigger does not exist, create it
			self._trigger = self._trigger || {};
			self._trigger[type] = self._trigger[type] || {};
			self._trigger[type][phase] = self._trigger[type][phase] || [];

			self._trigger[type][phase].push({
				id: id,
				method: method,
				enabled: true
			});

			return true;
		}

		return false;
	},

	/**
	 * Removes a trigger by id, type and phase.
	 * @name removeTrigger
	 * @method Triggers.removeTrigger
	 * @param {String} id The id of the trigger to remove.
	 * @param {Number} type The type of operation to remove the trigger from. See
	 * Mixin.Constants for constants to use.
	 * @param {Number} phase The phase of the operation to remove the trigger from.
	 * See Mixin.Constants for constants to use.
	 * @returns {boolean} True if removed successfully, false if not.
	 */
	removeTrigger: function (id, type, phase) {
		var self = this,
			triggerIndex;

		// Check if the trigger already exists
		triggerIndex = self._triggerIndexOf(id, type, phase);

		if (triggerIndex > -1) {
			// The trigger exists, remove it
			self._trigger[type][phase].splice(triggerIndex, 1);
		}

		return false;
	},

	/**
	 * Tells the current instance to fire or ignore all triggers whether they
	 * are enabled or not.
	 * @param {Boolean} val Set to true to ignore triggers or false to not
	 * ignore them.
	 * @returns {*}
	 */
	ignoreTriggers: function (val) {
		if (val !== undefined) {
			this._ignoreTriggers = val;
			return this;
		}

		return this._ignoreTriggers;
	},

	/**
	 * Generates triggers that fire in the after phase for all CRUD ops
	 * that automatically transform data back and forth and keep both
	 * import and export collections in sync with each other.
	 * @param {String} id The unique id for this link IO.
	 * @param {Object} ioData The settings for the link IO.
	 */
	addLinkIO: function (id, ioData) {
		var self = this,
			matchAll,
			exportData,
			importData,
			exportTriggerMethod,
			importTriggerMethod,
			exportTo,
			importFrom,
			allTypes,
			i;

		// Store the linkIO
		self._linkIO = self._linkIO || {};
		self._linkIO[id] = ioData;

		exportData = ioData['export'];
		importData = ioData['import'];

		if (exportData) {
			exportTo = self.db().collection(exportData.to);
		}

		if (importData) {
			importFrom = self.db().collection(importData.from);
		}

		allTypes = [
			self.TYPE_INSERT,
			self.TYPE_UPDATE,
			self.TYPE_REMOVE
		];

		matchAll = function (data, callback) {
			// Match all
			callback(false, true);
		};

		if (exportData) {
			// Check for export match method
			if (!exportData.match) {
				// No match method found, use the match all method
				exportData.match = matchAll;
			}

			// Check for export types
			if (!exportData.types) {
				exportData.types = allTypes;
			}

			exportTriggerMethod = function (operation, oldDoc, newDoc) {
				// Check if we should execute against this data
				exportData.match(newDoc, function (err, doExport) {
					if (!err && doExport) {
						// Get data to upsert (if any)
						exportData.data(newDoc, operation.type, function (err, data, callback) {
							if (!err && data) {
								// Disable all currently enabled triggers so that we
								// don't go into a trigger loop
								exportTo.ignoreTriggers(true);

								if (operation.type !== 'remove') {
									// Do upsert
									exportTo.upsert(data, callback);
								} else {
									// Do remove
									exportTo.remove(data, callback);
								}

								// Re-enable the previous triggers
								exportTo.ignoreTriggers(false);
							}
						});
					}
				});
			};
		}

		if (importData) {
			// Check for import match method
			if (!importData.match) {
				// No match method found, use the match all method
				importData.match = matchAll;
			}

			// Check for import types
			if (!importData.types) {
				importData.types = allTypes;
			}

			importTriggerMethod = function (operation, oldDoc, newDoc) {
				// Check if we should execute against this data
				importData.match(newDoc, function (err, doExport) {
					if (!err && doExport) {
						// Get data to upsert (if any)
						importData.data(newDoc, operation.type, function (err, data, callback) {
							if (!err && data) {
								// Disable all currently enabled triggers so that we
								// don't go into a trigger loop
								exportTo.ignoreTriggers(true);

								if (operation.type !== 'remove') {
									// Do upsert
									self.upsert(data, callback);
								} else {
									// Do remove
									self.remove(data, callback);
								}

								// Re-enable the previous triggers
								exportTo.ignoreTriggers(false);
							}
						});
					}
				});
			};
		}

		if (exportData) {
			for (i = 0; i < exportData.types.length; i++) {
				self.addTrigger(id + 'export' + exportData.types[i], exportData.types[i], self.PHASE_AFTER, exportTriggerMethod);
			}
		}

		if (importData) {
			for (i = 0; i < importData.types.length; i++) {
				importFrom.addTrigger(id + 'import' + importData.types[i], importData.types[i], self.PHASE_AFTER, importTriggerMethod);
			}
		}
	},

	/**
	 * Removes a previously added link IO via it's ID.
	 * @param {String} id The id of the link IO to remove.
	 * @returns {boolean} True if successful, false if the link IO
	 * was not found.
	 */
	removeLinkIO: function (id) {
		var self = this,
			linkIO = self._linkIO[id],
			exportData,
			importData,
			importFrom,
			i;

		if (linkIO) {
			exportData = linkIO['export'];
			importData = linkIO['import'];

			if (exportData) {
				for (i = 0; i < exportData.types.length; i++) {
					self.removeTrigger(id + 'export' + exportData.types[i], exportData.types[i], self.db.PHASE_AFTER);
				}
			}

			if (importData) {
				importFrom = self.db().collection(importData.from);

				for (i = 0; i < importData.types.length; i++) {
					importFrom.removeTrigger(id + 'import' + importData.types[i], importData.types[i], self.db.PHASE_AFTER);
				}
			}

			delete self._linkIO[id];
			return true;
		}

		return false;
	},

	enableTrigger: new Overload({
		'string': function (id) {
			// Alter all triggers of this type
			var self = this,
				types = self._trigger,
				phases,
				triggers,
				result = false,
				i, k, j;

			if (types) {
				for (j in types) {
					if (types.hasOwnProperty(j)) {
						phases = types[j];

						if (phases) {
							for (i in phases) {
								if (phases.hasOwnProperty(i)) {
									triggers = phases[i];

									// Loop triggers and set enabled flag
									for (k = 0; k < triggers.length; k++) {
										if (triggers[k].id === id) {
											triggers[k].enabled = true;
											result = true;
										}
									}
								}
							}
						}
					}
				}
			}

			return result;
		},

		'number': function (type) {
			// Alter all triggers of this type
			var self = this,
				phases = self._trigger[type],
				triggers,
				result = false,
				i, k;

			if (phases) {
				for (i in phases) {
					if (phases.hasOwnProperty(i)) {
						triggers = phases[i];

						// Loop triggers and set to enabled
						for (k = 0; k < triggers.length; k++) {
							triggers[k].enabled = true;
							result = true;
						}
					}
				}
			}

			return result;
		},

		'number, number': function (type, phase) {
			// Alter all triggers of this type and phase
			var self = this,
				phases = self._trigger[type],
				triggers,
				result = false,
				k;

			if (phases) {
				triggers = phases[phase];

				if (triggers) {
					// Loop triggers and set to enabled
					for (k = 0; k < triggers.length; k++) {
						triggers[k].enabled = true;
						result = true;
					}
				}
			}

			return result;
		},

		'string, number, number': function (id, type, phase) {
			// Check if the trigger already exists
			var self = this,
				triggerIndex = self._triggerIndexOf(id, type, phase);

			if (triggerIndex > -1) {
				// Update the trigger
				self._trigger[type][phase][triggerIndex].enabled = true;

				return true;
			}

			return false;
		}
	}),

	disableTrigger: new Overload({
		'string': function (id) {
			// Alter all triggers of this type
			var self = this,
				types = self._trigger,
				phases,
				triggers,
				result = false,
				i, k, j;

			if (types) {
				for (j in types) {
					if (types.hasOwnProperty(j)) {
						phases = types[j];

						if (phases) {
							for (i in phases) {
								if (phases.hasOwnProperty(i)) {
									triggers = phases[i];

									// Loop triggers and set enabled flag
									for (k = 0; k < triggers.length; k++) {
										if (triggers[k].id === id) {
											triggers[k].enabled = false;
											result = true;
										}
									}
								}
							}
						}
					}
				}
			}

			return result;
		},

		'number': function (type) {
			// Alter all triggers of this type
			var self = this,
				phases = self._trigger[type],
				triggers,
				result = false,
				i, k;

			if (phases) {
				for (i in phases) {
					if (phases.hasOwnProperty(i)) {
						triggers = phases[i];

						// Loop triggers and set to disabled
						for (k = 0; k < triggers.length; k++) {
							triggers[k].enabled = false;
							result = true;
						}
					}
				}
			}

			return result;
		},

		'number, number': function (type, phase) {
			// Alter all triggers of this type and phase
			var self = this,
				phases = self._trigger[type],
				triggers,
				result = false,
				k;

			if (phases) {
				triggers = phases[phase];

				if (triggers) {
					// Loop triggers and set to disabled
					for (k = 0; k < triggers.length; k++) {
						triggers[k].enabled = false;
						result = true;
					}
				}
			}

			return result;
		},

		'string, number, number': function (id, type, phase) {
			// Check if the trigger already exists
			var self = this,
				triggerIndex = self._triggerIndexOf(id, type, phase);

			if (triggerIndex > -1) {
				// Update the trigger
				self._trigger[type][phase][triggerIndex].enabled = false;

				return true;
			}

			return false;
		}
	}),

	/**
	 * Checks if a trigger will fire based on the type and phase provided.
	 * @param {Number} type The type of operation. See Mixin.Constants for
	 * constants to use.
	 * @param {Number} phase The phase of the operation. See Mixin.Constants
	 * for constants to use.
	 * @returns {Boolean} True if the trigger will fire, false otherwise.
	 */
	willTrigger: function (type, phase) {
		if (!this._ignoreTriggers && this._trigger && this._trigger[type] && this._trigger[type][phase] && this._trigger[type][phase].length) {
			// Check if a trigger in this array is enabled
			var arr = this._trigger[type][phase],
				i;

			for (i = 0; i < arr.length; i++) {
				if (arr[i].enabled) {
					return true;
				}
			}
		}

		return false;
	},

	/**
	 * Processes trigger actions based on the operation, type and phase.
	 * @param {Object} operation Operation data to pass to the trigger.
	 * @param {Number} type The type of operation. See Mixin.Constants for
	 * constants to use.
	 * @param {Number} phase The phase of the operation. See Mixin.Constants
	 * for constants to use.
	 * @param {Object} oldDoc The document snapshot before operations are
	 * carried out against the data.
	 * @param {Object} newDoc The document snapshot after operations are
	 * carried out against the data.
	 * @returns {boolean}
	 */
	processTrigger: function (operation, type, phase, oldDoc, newDoc) {
		var self = this,
			triggerArr,
			triggerIndex,
			triggerCount,
			triggerItem,
			response,
			typeName,
			phaseName;

		if (!self._ignoreTriggers && self._trigger && self._trigger[type] && self._trigger[type][phase]) {
			triggerArr = self._trigger[type][phase];
			triggerCount = triggerArr.length;

			for (triggerIndex = 0; triggerIndex < triggerCount; triggerIndex++) {
				triggerItem = triggerArr[triggerIndex];

				// Check if the trigger is enabled
				if (triggerItem.enabled) {
					if (self.debug()) {
						switch (type) {
							case this.TYPE_INSERT:
								typeName = 'insert';
								break;

							case this.TYPE_UPDATE:
								typeName = 'update';
								break;

							case this.TYPE_REMOVE:
								typeName = 'remove';
								break;

							default:
								typeName = '';
								break;
						}

						switch (phase) {
							case this.PHASE_BEFORE:
								phaseName = 'before';
								break;

							case this.PHASE_AFTER:
								phaseName = 'after';
								break;

							default:
								phaseName = '';
								break;
						}

						console.log('Triggers: Processing trigger "' + triggerItem.id + '" for ' + typeName + ' in phase "' + phaseName + '"');
					}

					// Check if the trigger is already in the stack, if it is,
					// don't fire it again (this is so we avoid infinite loops
					// where a trigger triggers another trigger which calls this
					// one and so on)
					if  (self.triggerStack && self.triggerStack[type] && self.triggerStack[type][phase] && self.triggerStack[type][phase][triggerItem.id]) {
						// The trigger is already in the stack, do not fire the trigger again
						if (self.debug()) {
							console.log('Triggers: Will not run trigger "' + triggerItem.id + '" for ' + typeName + ' in phase "' + phaseName + '" as it is already in the stack!');
						}

						continue;
					}

					// Add the trigger to the stack so we don't go down an endless
					// trigger loop
					self.triggerStack = self.triggerStack || {};
					self.triggerStack[type] = {};
					self.triggerStack[type][phase] = {};
					self.triggerStack[type][phase][triggerItem.id] = true;

					// Run the trigger's method and store the response
					response = triggerItem.method.call(self, operation, oldDoc, newDoc);

					// Remove the trigger from the stack
					self.triggerStack = self.triggerStack || {};
					self.triggerStack[type] = {};
					self.triggerStack[type][phase] = {};
					self.triggerStack[type][phase][triggerItem.id] = false;

					// Check the response for a non-expected result (anything other than
					// [undefined, true or false] is considered a throwable error)
					if (response === false) {
						// The trigger wants us to cancel operations
						return false;
					}

					if (response !== undefined && response !== true && response !== false) {
						// Trigger responded with error, throw the error
						throw('ForerunnerDB.Mixin.Triggers: Trigger error: ' + response);
					}
				}
			}

			// Triggers all ran without issue, return a success (true)
			return true;
		}
	},

	/**
	 * Returns the index of a trigger by id based on type and phase.
	 * @param {String} id The id of the trigger to find the index of.
	 * @param {Number} type The type of operation. See Mixin.Constants for
	 * constants to use.
	 * @param {Number} phase The phase of the operation. See Mixin.Constants
	 * for constants to use.
	 * @returns {Number}
	 * @private
	 */
	_triggerIndexOf: function (id, type, phase) {
		var self = this,
			triggerArr,
			triggerCount,
			triggerIndex;

		if (self._trigger && self._trigger[type] && self._trigger[type][phase]) {
			triggerArr = self._trigger[type][phase];
			triggerCount = triggerArr.length;

			for (triggerIndex = 0; triggerIndex < triggerCount; triggerIndex++) {
				if (triggerArr[triggerIndex].id === id) {
					return triggerIndex;
				}
			}
		}

		return -1;
	}
};

/**
 * When called in a before phase the newDoc object can be directly altered
 * to modify the data in it before the operation is carried out.
 * @callback Triggers.addTriggerCallback
 * @param {Object} operation The details about the operation.
 * @param {Object} oldDoc The document before the operation.
 * @param {Object} newDoc The document after the operation.
 */

module.exports = Triggers;