json-rules-engine
Version:
Rules Engine expressed in simple json
518 lines (422 loc) • 21.2 kB
JavaScript
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
var _condition = require('./condition');
var _condition2 = _interopRequireDefault(_condition);
var _events = require('events');
var _lodash = require('lodash.clonedeep');
var _lodash2 = _interopRequireDefault(_lodash);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
var debug = require('debug')('json-rules-engine');
var Rule = function (_EventEmitter) {
_inherits(Rule, _EventEmitter);
/**
* returns a new Rule instance
* @param {object,string} options, or json string that can be parsed into options
* @param {integer} options.priority (>1) - higher runs sooner.
* @param {Object} options.event - event to fire when rule evaluates as successful
* @param {string} options.event.type - name of event to emit
* @param {string} options.event.params - parameters to pass to the event listener
* @param {Object} options.conditions - conditions to evaluate when processing this rule
* @return {Rule} instance
*/
function Rule(options) {
_classCallCheck(this, Rule);
var _this = _possibleConstructorReturn(this, (Rule.__proto__ || Object.getPrototypeOf(Rule)).call(this));
if (typeof options === 'string') {
options = JSON.parse(options);
}
if (options && options.conditions) {
_this.setConditions(options.conditions);
}
if (options && options.onSuccess) {
_this.on('success', options.onSuccess);
}
if (options && options.onFailure) {
_this.on('failure', options.onFailure);
}
var priority = options && options.priority || 1;
_this.setPriority(priority);
var event = options && options.event || { type: 'unknown' };
_this.setEvent(event);
return _this;
}
/**
* Sets the priority of the rule
* @param {integer} priority (>=1) - increasing the priority causes the rule to be run prior to other rules
*/
_createClass(Rule, [{
key: 'setPriority',
value: function setPriority(priority) {
priority = parseInt(priority, 10);
if (priority <= 0) throw new Error('Priority must be greater than zero');
this.priority = priority;
return this;
}
/**
* Sets the conditions to run when evaluating the rule.
* @param {object} conditions - conditions, root element must be a boolean operator
*/
}, {
key: 'setConditions',
value: function setConditions(conditions) {
if (!conditions.hasOwnProperty('all') && !conditions.hasOwnProperty('any')) {
throw new Error('"conditions" root must contain a single instance of "all" or "any"');
}
this.conditions = new _condition2.default(conditions);
return this;
}
/**
* Sets the event to emit when the conditions evaluate truthy
* @param {object} event - event to emit
* @param {string} event.type - event name to emit on
* @param {string} event.params - parameters to emit as the argument of the event emission
*/
}, {
key: 'setEvent',
value: function setEvent(event) {
if (!event) throw new Error('Rule: setEvent() requires event object');
if (!event.hasOwnProperty('type')) throw new Error('Rule: setEvent() requires event object with "type" property');
this.event = {
type: event.type
};
if (event.params) this.event.params = event.params;
return this;
}
/**
* Sets the engine to run the rules under
* @param {object} engine
* @returns {Rule}
*/
}, {
key: 'setEngine',
value: function setEngine(engine) {
this.engine = engine;
return this;
}
}, {
key: 'toJSON',
value: function toJSON() {
var stringify = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
var props = {
conditions: this.conditions.toJSON(false),
priority: this.priority,
event: this.event
};
if (stringify) {
return JSON.stringify(props);
}
return props;
}
/**
* Priorizes an array of conditions based on "priority"
* When no explicit priority is provided on the condition itself, the condition's priority is determine by its fact
* @param {Condition[]} conditions
* @return {Condition[][]} prioritized two-dimensional array of conditions
* Each outer array element represents a single priority(integer). Inner array is
* all conditions with that priority.
*/
}, {
key: 'prioritizeConditions',
value: function prioritizeConditions(conditions) {
var _this2 = this;
var factSets = conditions.reduce(function (sets, condition) {
// if a priority has been set on this specific condition, honor that first
// otherwise, use the fact's priority
var priority = condition.priority;
if (!priority) {
var fact = _this2.engine.getFact(condition.fact);
priority = fact && fact.priority || 1;
}
if (!sets[priority]) sets[priority] = [];
sets[priority].push(condition);
return sets;
}, {});
return Object.keys(factSets).sort(function (a, b) {
return Number(a) > Number(b) ? -1 : 1; // order highest priority -> lowest
}).map(function (priority) {
return factSets[priority];
});
}
/**
* Evaluates the rule, starting with the root boolean operator and recursing down
* All evaluation is done within the context of an almanac
* @return {Promise(RuleResult)} rule evaluation result
*/
}, {
key: 'evaluate',
value: function () {
var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee6(almanac) {
var _this3 = this;
var ruleResult, evaluateCondition, evaluateConditions, prioritizeAndRun, any, all, processResult, result, _result;
return regeneratorRuntime.wrap(function _callee6$(_context6) {
while (1) {
switch (_context6.prev = _context6.next) {
case 0:
ruleResult = {
conditions: (0, _lodash2.default)(this.conditions),
event: (0, _lodash2.default)(this.event),
priority: (0, _lodash2.default)(this.priority)
};
/**
* Evaluates the rule conditions
* @param {Condition} condition - condition to evaluate
* @return {Promise(true|false)} - resolves with the result of the condition evaluation
*/
evaluateCondition = function () {
var _ref2 = _asyncToGenerator(regeneratorRuntime.mark(function _callee(condition) {
var comparisonValue, passes, subConditions, evaluationResult;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
comparisonValue = void 0;
passes = void 0;
if (!condition.isBooleanOperator()) {
_context.next = 16;
break;
}
subConditions = condition[condition.operator];
if (!(condition.operator === 'all')) {
_context.next = 10;
break;
}
_context.next = 7;
return all(subConditions);
case 7:
comparisonValue = _context.sent;
_context.next = 13;
break;
case 10:
_context.next = 12;
return any(subConditions);
case 12:
comparisonValue = _context.sent;
case 13:
// for booleans, rule passing is determined by the all/any result
passes = comparisonValue === true;
_context.next = 31;
break;
case 16:
_context.prev = 16;
_context.next = 19;
return condition.evaluate(almanac, _this3.engine.operators, comparisonValue);
case 19:
evaluationResult = _context.sent;
passes = evaluationResult.result;
condition.factResult = evaluationResult.leftHandSideValue;
_context.next = 31;
break;
case 24:
_context.prev = 24;
_context.t0 = _context['catch'](16);
if (!(_this3.engine.allowUndefinedFacts && _context.t0.code === 'UNDEFINED_FACT')) {
_context.next = 30;
break;
}
passes = false;
_context.next = 31;
break;
case 30:
throw _context.t0;
case 31:
condition.result = passes;
return _context.abrupt('return', passes);
case 33:
case 'end':
return _context.stop();
}
}
}, _callee, _this3, [[16, 24]]);
}));
return function evaluateCondition(_x3) {
return _ref2.apply(this, arguments);
};
}();
/**
* Evalutes an array of conditions, using an 'every' or 'some' array operation
* @param {Condition[]} conditions
* @param {string(every|some)} array method to call for determining result
* @return {Promise(boolean)} whether conditions evaluated truthy or falsey based on condition evaluation + method
*/
evaluateConditions = function () {
var _ref3 = _asyncToGenerator(regeneratorRuntime.mark(function _callee2(conditions, method) {
var conditionResults;
return regeneratorRuntime.wrap(function _callee2$(_context2) {
while (1) {
switch (_context2.prev = _context2.next) {
case 0:
if (!Array.isArray(conditions)) conditions = [conditions];
_context2.next = 3;
return Promise.all(conditions.map(function (condition) {
return evaluateCondition(condition);
}));
case 3:
conditionResults = _context2.sent;
debug('rule::evaluateConditions results', conditionResults);
return _context2.abrupt('return', method.call(conditionResults, function (result) {
return result === true;
}));
case 6:
case 'end':
return _context2.stop();
}
}
}, _callee2, _this3);
}));
return function evaluateConditions(_x4, _x5) {
return _ref3.apply(this, arguments);
};
}();
/**
* Evaluates a set of conditions based on an 'all' or 'any' operator.
* First, orders the top level conditions based on priority
* Iterates over each priority set, evaluating each condition
* If any condition results in the rule to be guaranteed truthy or falsey,
* it will short-circuit and not bother evaluating any additional rules
* @param {Condition[]} conditions - conditions to be evaluated
* @param {string('all'|'any')} operator
* @return {Promise(boolean)} rule evaluation result
*/
prioritizeAndRun = function () {
var _ref4 = _asyncToGenerator(regeneratorRuntime.mark(function _callee3(conditions, operator) {
var method, orderedSets, cursor;
return regeneratorRuntime.wrap(function _callee3$(_context3) {
while (1) {
switch (_context3.prev = _context3.next) {
case 0:
if (!(conditions.length === 0)) {
_context3.next = 2;
break;
}
return _context3.abrupt('return', true);
case 2:
method = Array.prototype.some;
if (operator === 'all') {
method = Array.prototype.every;
}
orderedSets = _this3.prioritizeConditions(conditions);
cursor = Promise.resolve();
orderedSets.forEach(function (set) {
var stop = false;
cursor = cursor.then(function (setResult) {
// after the first set succeeds, don't fire off the remaining promises
if (operator === 'any' && setResult === true || stop) {
debug('prioritizeAndRun::detected truthy result; skipping remaining conditions');
stop = true;
return true;
}
// after the first set fails, don't fire off the remaining promises
if (operator === 'all' && setResult === false || stop) {
debug('prioritizeAndRun::detected falsey result; skipping remaining conditions');
stop = true;
return false;
}
// all conditions passed; proceed with running next set in parallel
return evaluateConditions(set, method);
});
});
return _context3.abrupt('return', cursor);
case 8:
case 'end':
return _context3.stop();
}
}
}, _callee3, _this3);
}));
return function prioritizeAndRun(_x6, _x7) {
return _ref4.apply(this, arguments);
};
}();
/**
* Runs an 'any' boolean operator on an array of conditions
* @param {Condition[]} conditions to be evaluated
* @return {Promise(boolean)} condition evaluation result
*/
any = function () {
var _ref5 = _asyncToGenerator(regeneratorRuntime.mark(function _callee4(conditions) {
return regeneratorRuntime.wrap(function _callee4$(_context4) {
while (1) {
switch (_context4.prev = _context4.next) {
case 0:
return _context4.abrupt('return', prioritizeAndRun(conditions, 'any'));
case 1:
case 'end':
return _context4.stop();
}
}
}, _callee4, _this3);
}));
return function any(_x8) {
return _ref5.apply(this, arguments);
};
}();
/**
* Runs an 'all' boolean operator on an array of conditions
* @param {Condition[]} conditions to be evaluated
* @return {Promise(boolean)} condition evaluation result
*/
all = function () {
var _ref6 = _asyncToGenerator(regeneratorRuntime.mark(function _callee5(conditions) {
return regeneratorRuntime.wrap(function _callee5$(_context5) {
while (1) {
switch (_context5.prev = _context5.next) {
case 0:
return _context5.abrupt('return', prioritizeAndRun(conditions, 'all'));
case 1:
case 'end':
return _context5.stop();
}
}
}, _callee5, _this3);
}));
return function all(_x9) {
return _ref6.apply(this, arguments);
};
}();
/**
* Emits based on rule evaluation result, and decorates ruleResult with 'result' property
* @param {Boolean} result
*/
processResult = function processResult(result) {
ruleResult.result = result;
if (result) _this3.emit('success', ruleResult.event, almanac, ruleResult);else _this3.emit('failure', ruleResult.event, almanac, ruleResult);
return ruleResult;
};
if (!ruleResult.conditions.any) {
_context6.next = 14;
break;
}
_context6.next = 10;
return any(ruleResult.conditions.any);
case 10:
result = _context6.sent;
return _context6.abrupt('return', processResult(result));
case 14:
_context6.next = 16;
return all(ruleResult.conditions.all);
case 16:
_result = _context6.sent;
return _context6.abrupt('return', processResult(_result));
case 18:
case 'end':
return _context6.stop();
}
}
}, _callee6, this);
}));
function evaluate(_x2) {
return _ref.apply(this, arguments);
}
return evaluate;
}()
}]);
return Rule;
}(_events.EventEmitter);
exports.default = Rule;