From 50920abe4616bbab43cd57e3ec08cc0238425d57 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Tue, 17 Oct 2023 10:07:41 -0400 Subject: [PATCH 1/4] Add factPriority to almanac This brings getting the priority of a fact into line with getting the value of a fact. --- src/almanac.js | 12 ++++++++++++ test/almanac.test.js | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/almanac.js b/src/almanac.js index cc8fdda..47b744e 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -163,6 +163,18 @@ export default class Almanac { return factValuePromise } + /** + * Returns the priority of the fact if one exists. + * @param {string} factId - fact identifier + */ + factPriority (factId) { + const fact = this._getFact(factId) + if (fact === undefined) { + return undefined + } + return fact.priority + } + /** * Interprets value as either a primitive, or if a fact, retrieves the fact value */ diff --git a/test/almanac.test.js b/test/almanac.test.js index d538101..7a062e1 100644 --- a/test/almanac.test.js +++ b/test/almanac.test.js @@ -1,6 +1,7 @@ import { Fact } from '../src/index' import Almanac from '../src/almanac' import sinon from 'sinon' +import { expect } from 'chai' describe('Almanac', () => { let almanac @@ -189,4 +190,22 @@ describe('Almanac', () => { }) }) }) + + describe('factPriority()', () => { + function setup (priority) { + const fact = new Fact('foo', 5, { priority }) + const factMap = new Map([[fact.id, fact]]) + return new Almanac(factMap) + } + + it('returns the priority if the fact exists', () => { + const almanac = setup(3) + expect(almanac.factPriority('foo')).to.equal(3) + }) + + it("returns undefined if the fact doesn't exist", () => { + const almanac = setup(6) + expect(almanac.factPriority('bar')).to.equal(undefined) + }) + }) }) From 6042caded419ed225f491b1b145a5d5f0e1f910f Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Tue, 17 Oct 2023 10:09:16 -0400 Subject: [PATCH 2/4] Split conditions into classes Split conditions into classes that can be independently evaluated. This will allow new condition classes to be added by changing the implementation of the condition constructor. --- src/condition.js | 156 --------------- src/condition/all-condition.js | 62 ++++++ src/condition/any-condition.js | 62 ++++++ src/condition/comparison-condition.js | 107 ++++++++++ src/condition/condition-constructor.js | 32 +++ src/condition/condition-reference.js | 44 ++++ src/condition/condition.js | 74 +++++++ src/condition/index.js | 19 ++ src/condition/never-condition.js | 4 + src/condition/not-condition.js | 58 ++++++ src/condition/prioritize-and-run.js | 116 +++++++++++ src/engine.js | 5 +- src/rule-result.js | 14 +- src/rule.js | 265 ++++--------------------- test/acceptance/acceptance.js | 179 +++++++++-------- test/condition.test.js | 125 ++++++------ test/engine-event.test.js | 45 ++++- test/rule.test.js | 37 ---- 18 files changed, 823 insertions(+), 581 deletions(-) delete mode 100644 src/condition.js create mode 100644 src/condition/all-condition.js create mode 100644 src/condition/any-condition.js create mode 100644 src/condition/comparison-condition.js create mode 100644 src/condition/condition-constructor.js create mode 100644 src/condition/condition-reference.js create mode 100644 src/condition/condition.js create mode 100644 src/condition/index.js create mode 100644 src/condition/never-condition.js create mode 100644 src/condition/not-condition.js create mode 100644 src/condition/prioritize-and-run.js diff --git a/src/condition.js b/src/condition.js deleted file mode 100644 index 5f5775d..0000000 --- a/src/condition.js +++ /dev/null @@ -1,156 +0,0 @@ -'use strict' - -import debug from './debug' - -export default class Condition { - constructor (properties) { - if (!properties) throw new Error('Condition: constructor options required') - const booleanOperator = Condition.booleanOperator(properties) - Object.assign(this, properties) - if (booleanOperator) { - const subConditions = properties[booleanOperator] - const subConditionsIsArray = Array.isArray(subConditions) - if (booleanOperator !== 'not' && !subConditionsIsArray) { throw new Error(`"${booleanOperator}" must be an array`) } - if (booleanOperator === 'not' && subConditionsIsArray) { throw new Error(`"${booleanOperator}" cannot be an array`) } - this.operator = booleanOperator - // boolean conditions always have a priority; default 1 - this.priority = parseInt(properties.priority, 10) || 1 - if (subConditionsIsArray) { - this[booleanOperator] = subConditions.map((c) => new Condition(c)) - } else { - this[booleanOperator] = new Condition(subConditions) - } - } else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) { - if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') } - if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') } - if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') } - - // a non-boolean condition does not have a priority by default. this allows - // priority to be dictated by the fact definition - if (Object.prototype.hasOwnProperty.call(properties, 'priority')) { - properties.priority = parseInt(properties.priority, 10) - } - } - } - - /** - * Converts the condition into a json-friendly structure - * @param {Boolean} stringify - whether to return as a json string - * @returns {string,object} json string or json-friendly object - */ - toJSON (stringify = true) { - const props = {} - if (this.priority) { - props.priority = this.priority - } - if (this.name) { - props.name = this.name - } - const oper = Condition.booleanOperator(this) - if (oper) { - if (Array.isArray(this[oper])) { - props[oper] = this[oper].map((c) => c.toJSON(false)) - } else { - props[oper] = this[oper].toJSON(false) - } - } else if (this.isConditionReference()) { - props.condition = this.condition - } else { - props.operator = this.operator - props.value = this.value - props.fact = this.fact - if (this.factResult !== undefined) { - props.factResult = this.factResult - } - if (this.result !== undefined) { - props.result = this.result - } - if (this.params) { - props.params = this.params - } - if (this.path) { - props.path = this.path - } - } - if (stringify) { - return JSON.stringify(props) - } - return props - } - - /** - * Takes the fact result and compares it to the condition 'value', using the operator - * LHS OPER RHS - * - * - * @param {Almanac} almanac - * @param {Map} operatorMap - map of available operators, keyed by operator name - * @returns {Boolean} - evaluation result - */ - evaluate (almanac, operatorMap) { - if (!almanac) return Promise.reject(new Error('almanac required')) - if (!operatorMap) return Promise.reject(new Error('operatorMap required')) - if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) } - - const op = operatorMap.get(this.operator) - if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) } - - return Promise.all([ - almanac.getValue(this.value), - almanac.factValue(this.fact, this.params, this.path) - ]).then(([rightHandSideValue, leftHandSideValue]) => { - const result = op.evaluate(leftHandSideValue, rightHandSideValue) - debug( - `condition::evaluate <${JSON.stringify(leftHandSideValue)} ${ - this.operator - } ${JSON.stringify(rightHandSideValue)}?> (${result})` - ) - return { - result, - leftHandSideValue, - rightHandSideValue, - operator: this.operator - } - }) - } - - /** - * Returns the boolean operator for the condition - * If the condition is not a boolean condition, the result will be 'undefined' - * @return {string 'all', 'any', or 'not'} - */ - static booleanOperator (condition) { - if (Object.prototype.hasOwnProperty.call(condition, 'any')) { - return 'any' - } else if (Object.prototype.hasOwnProperty.call(condition, 'all')) { - return 'all' - } else if (Object.prototype.hasOwnProperty.call(condition, 'not')) { - return 'not' - } - } - - /** - * Returns the condition's boolean operator - * Instance version of Condition.isBooleanOperator - * @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition) - */ - booleanOperator () { - return Condition.booleanOperator(this) - } - - /** - * Whether the operator is boolean ('all', 'any', 'not') - * @returns {Boolean} - */ - isBooleanOperator () { - return Condition.booleanOperator(this) !== undefined - } - - /** - * Whether the condition represents a reference to a condition - * @returns {Boolean} - */ - isConditionReference () { - return Object.prototype.hasOwnProperty.call(this, 'condition') - } -} diff --git a/src/condition/all-condition.js b/src/condition/all-condition.js new file mode 100644 index 0000000..ffa456d --- /dev/null +++ b/src/condition/all-condition.js @@ -0,0 +1,62 @@ +'use strict' + +import Condition from './condition' +import prioritizeAndRun from './prioritize-and-run' + +export default class AllCondition extends Condition { + constructor (properties, conditionConstructor) { + super(properties) + if (!Object.prototype.hasOwnProperty.call(properties, 'all')) { + throw new Error('AllCondition: constructor "all" property required') + } + if (!Array.isArray(properties.all)) { + throw new Error('AllCondition: constructor "all" must be an array') + } + this.all = properties.all.map((c) => conditionConstructor.construct(c)) + this.operator = 'all' + // boolean conditions always have a priority; default 1 + this.priority = this.priority || 1 + } + + /** + * Converts the condition into a json-friendly structure + * @param {Boolean} stringify - whether to return as a json string + * @returns {string,object} json string or json-friendly object + */ + toJSON (stringify = true) { + const props = super.toJSON(false) + props.all = this.all.map((c) => c.toJSON(false)) + if (stringify) { + return JSON.stringify(props) + } + return props + } + + /** + * Takes the fact result and compares it to the condition 'value', using the operator + * LHS OPER RHS + * + * + * @param {Almanac} almanac + * @param {Map} operatorMap - map of available operators, keyed by operator name + * @returns {Boolean} - evaluation result + */ + evaluate (almanac, operatorMap, conditionMap) { + return Promise.all([ + prioritizeAndRun(this.all, 'all', almanac, operatorMap, conditionMap), + super.evaluate(almanac, operatorMap, conditionMap) + ]).then(([result, evaluateResult]) => { + evaluateResult.result = result.result + evaluateResult.all = result.conditions + evaluateResult.operator = 'all' + return evaluateResult + }) + } + + skip () { + const skipResult = super.skip() + skipResult.all = this.all.map((c) => c.skip()) + skipResult.operator = 'all' + return skipResult + } +} diff --git a/src/condition/any-condition.js b/src/condition/any-condition.js new file mode 100644 index 0000000..6fd05c2 --- /dev/null +++ b/src/condition/any-condition.js @@ -0,0 +1,62 @@ +'use strict' + +import Condition from './condition' +import prioritizeAndRun from './prioritize-and-run' + +export default class AnyCondition extends Condition { + constructor (properties, conditionConstructor) { + super(properties) + if (!Object.prototype.hasOwnProperty.call(properties, 'any')) { + throw new Error('AnyCondition: constructor "any" property required') + } + if (!Array.isArray(properties.any)) { + throw new Error('AnyCondition: constructor "any" must be an array') + } + this.any = properties.any.map((c) => conditionConstructor.construct(c)) + this.operator = 'any' + // boolean conditions always have a priority; default 1 + this.priority = this.priority || 1 + } + + /** + * Converts the condition into a json-friendly structure + * @param {Boolean} stringify - whether to return as a json string + * @returns {string,object} json string or json-friendly object + */ + toJSON (stringify = true) { + const props = super.toJSON(false) + props.any = this.any.map((c) => c.toJSON(false)) + if (stringify) { + return JSON.stringify(props) + } + return props + } + + /** + * Takes the fact result and compares it to the condition 'value', using the operator + * LHS OPER RHS + * + * + * @param {Almanac} almanac + * @param {Map} operatorMap - map of available operators, keyed by operator name + * @returns {Boolean} - evaluation result + */ + evaluate (almanac, operatorMap, conditionMap) { + return Promise.all([ + prioritizeAndRun(this.any, 'any', almanac, operatorMap, conditionMap), + super.evaluate(almanac, operatorMap, conditionMap) + ]).then(([result, evaluateResult]) => { + evaluateResult.any = result.conditions + evaluateResult.operator = 'any' + evaluateResult.result = result.result + return evaluateResult + }) + } + + skip () { + const skipResult = super.skip() + skipResult.any = this.any.map((c) => c.skip()) + skipResult.operator = 'any' + return skipResult + } +} diff --git a/src/condition/comparison-condition.js b/src/condition/comparison-condition.js new file mode 100644 index 0000000..2d25009 --- /dev/null +++ b/src/condition/comparison-condition.js @@ -0,0 +1,107 @@ +'use strict' + +import deepClone from 'clone' +import debug from '../debug' +import Condition from './condition' + +export default class ComparisonCondition extends Condition { + constructor (properties) { + super(properties) + if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') } + if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') } + if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') } + this.fact = properties.fact + if (Object.prototype.hasOwnProperty.call(properties, 'params')) { + this.params = properties.params + } + if (Object.prototype.hasOwnProperty.call(properties, 'path')) { + this.path = properties.path + } + this.operator = properties.operator + this.value = properties.value + } + + /** + * Converts the condition into a json-friendly structure + * @param {Boolean} stringify - whether to return as a json string + * @returns {string,object} json string or json-friendly object + */ + toJSON (stringify = true) { + const props = super.toJSON(false) + props.operator = this.operator + props.value = this.value + props.fact = this.fact + if (this.params) { + props.params = this.params + } + if (this.path) { + props.path = this.path + } + if (stringify) { + return JSON.stringify(props) + } + return props + } + + getPriority (almanac) { + const priority = super.getPriority(almanac) + return priority !== undefined ? priority : almanac.factPriority(this.fact) + } + + /** + * Takes the fact result and compares it to the condition 'value', using the operator + * LHS OPER RHS + * + * + * @param {Almanac} almanac + * @param {Map} operatorMap - map of available operators, keyed by operator name + * @returns {Boolean} - evaluation result + */ + evaluate (almanac, operatorMap, conditionMap) { + if (!almanac) return Promise.reject(new Error('almanac required')) + if (!operatorMap) return Promise.reject(new Error('operatorMap required')) + + const op = operatorMap.get(this.operator) + if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) } + + return Promise.all([ + almanac.getValue(this.value), + almanac.factValue(this.fact, this.params, this.path), + super.evaluate(almanac, operatorMap, conditionMap) + ]).then(([rightHandSideValue, leftHandSideValue, evaluateResult]) => { + const result = op.evaluate(leftHandSideValue, rightHandSideValue) + debug( + `condition::evaluate <${JSON.stringify(leftHandSideValue)} ${ + this.operator + } ${JSON.stringify(rightHandSideValue)}?> (${result})` + ) + evaluateResult.result = result + evaluateResult.fact = this.fact + if (this.params) { + evaluateResult.params = deepClone(this.params) + } + if (this.path) { + evaluateResult.path = this.path + } + evaluateResult.operator = this.operator + evaluateResult.value = deepClone(this.value) + evaluateResult.factResult = leftHandSideValue + evaluateResult.valueResult = rightHandSideValue + return evaluateResult + }) + } + + skip () { + const skipResult = super.skip() + skipResult.fact = this.fact + if (this.params) { + skipResult.params = deepClone(this.params) + } + if (this.path) { + skipResult.path = this.path + } + skipResult.operator = this.operator + skipResult.value = deepClone(this.value) + return skipResult + } +} diff --git a/src/condition/condition-constructor.js b/src/condition/condition-constructor.js new file mode 100644 index 0000000..3b909c2 --- /dev/null +++ b/src/condition/condition-constructor.js @@ -0,0 +1,32 @@ +import AllCondition from './all-condition' +import AnyCondition from './any-condition' +import ComparisonCondition from './comparison-condition' +import Condition from './condition' +import ConditionReference from './condition-reference' +import NotCondition from './not-condition' + +export default class ConditionConstructor { + /** + * Construct the correct condition subclass. + * @param {*} options - the options for the condition + * @returns {Condition} a condition subclass + */ + construct (options) { + if (!options) { + throw new Error('Condition: constructor options required') + } + if (options instanceof Condition) { + return options + } + if (Object.prototype.hasOwnProperty.call(options, 'any')) { + return new AnyCondition(options, this) + } else if (Object.prototype.hasOwnProperty.call(options, 'all')) { + return new AllCondition(options, this) + } else if (Object.prototype.hasOwnProperty.call(options, 'not')) { + return new NotCondition(options, this) + } else if (Object.prototype.hasOwnProperty.call(options, 'condition')) { + return new ConditionReference(options) + } + return new ComparisonCondition(options) + } +} diff --git a/src/condition/condition-reference.js b/src/condition/condition-reference.js new file mode 100644 index 0000000..3649799 --- /dev/null +++ b/src/condition/condition-reference.js @@ -0,0 +1,44 @@ +'use strict' + +import Condition from './condition' + +export default class ConditionReference extends Condition { + constructor (properties) { + super(properties) + if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) { throw new Error('ConditionReference: constructor "condition" property required') } + this.condition = properties.condition + } + + /** + * Converts the condition into a json-friendly structure + * @param {Boolean} stringify - whether to return as a json string + * @returns {string,object} json string or json-friendly object + */ + toJSON (stringify = true) { + const props = super.toJSON(false) + props.condition = this.condition + if (stringify) { + return JSON.stringify(props) + } + return props + } + + /** + * Dereferences the condition from the condition map and runs it + * + * @param {Almanac} almanac + * @param {Map} operatorMap - map of available operators, keyed by operator name + * @param {Map} conditionMap - map of available conditions, keyed by condition name + * @returns {object} - evaluation result + */ + evaluate (almanac, operatorMap, conditionMap) { + if (!conditionMap) return Promise.reject(new Error('conditionMap required')) + return conditionMap.get(this.condition).evaluate(almanac, operatorMap, conditionMap) + } + + skip () { + const skipResult = super.skip() + skipResult.condition = this.condition + return skipResult + } +} diff --git a/src/condition/condition.js b/src/condition/condition.js new file mode 100644 index 0000000..7d4a1fe --- /dev/null +++ b/src/condition/condition.js @@ -0,0 +1,74 @@ +export default class Condition { + constructor (properties) { + if (!properties) throw new Error('Condition: constructor options required') + if (Object.prototype.hasOwnProperty.call(properties, 'priority')) { + properties.priority = parseInt(properties.priority, 10) + } + Object.assign(this, properties) + } + + /** + * Converts the condition into a json-friendly structure + * @param {Boolean} stringify - whether to return as a json string + * @returns {string,object} json string or json-friendly object + */ + toJSON (stringify = true) { + const props = {} + if (this.priority) { + props.priority = this.priority + } + if (this.name) { + props.name = this.name + } + if (stringify) { + return JSON.stringify(props) + } + return props + } + + /** + * Returns the priority of the condition. + * @param {Almanac} almanac the almanac use to resolve fact based priority + * @returns {number|undefined} a number or undefined value for the priority + */ + getPriority (almanac) { + return this.priority + } + + /** + * Evaluates the conditions, + * returns the evaluation result including a result boolean. + * @param {Almanac} almanac - the almanac to use for fact lookups + * @param {Map} operatorMap - map of available operators keyed by operator name + * @param {Map} conditionMap - map of available conditions keys by condition name + * @returns {Promise} - evaluation result + */ + evaluate (almanac, operatorMap, conditionMap) { + if (!almanac) return Promise.reject(new Error('almanac required')) + if (!operatorMap) return Promise.reject(new Error('operatorMap required')) + if (!conditionMap) { return Promise.reject(new Error('conditionMap required')) } + const evaluateResult = { + result: false, + priority: this.getPriority(almanac) + } + if (this.name) { + evaluateResult.name = this.name + } + return Promise.resolve(evaluateResult) + } + + /** + * Skips the evaluation and returns an appropriate skip result. + * @returns A Skip result not containing a result property + */ + skip () { + const skipResult = {} + if (this.priority) { + skipResult.priority = this.priority + } + if (this.name) { + skipResult.name = this.name + } + return skipResult + } +} diff --git a/src/condition/index.js b/src/condition/index.js new file mode 100644 index 0000000..1fb4325 --- /dev/null +++ b/src/condition/index.js @@ -0,0 +1,19 @@ +import AllCondition from './all-condition' +import AnyCondition from './any-condition' +import ConditionConstructor from './condition-constructor' +import neverCondition from './never-condition' +import ComparisonCondition from './comparison-condition' +import ConditionReference from './condition-reference' +import NotCondition from './not-condition' + +export default ConditionConstructor + +export { + AllCondition, + AnyCondition, + ConditionConstructor, + neverCondition, + ComparisonCondition, + ConditionReference, + NotCondition +} diff --git a/src/condition/never-condition.js b/src/condition/never-condition.js new file mode 100644 index 0000000..1ec4670 --- /dev/null +++ b/src/condition/never-condition.js @@ -0,0 +1,4 @@ +import Condition from './condition' +const neverCondition = new Condition({}) + +export default neverCondition diff --git a/src/condition/not-condition.js b/src/condition/not-condition.js new file mode 100644 index 0000000..88982ce --- /dev/null +++ b/src/condition/not-condition.js @@ -0,0 +1,58 @@ +'use strict' + +import Condition from './condition' + +export default class NotCondition extends Condition { + constructor (properties, comparisonConstructor) { + super(properties) + if (!Object.prototype.hasOwnProperty.call(properties, 'not')) { throw new Error('NotCondition: constructor "not" property required') } + if (Array.isArray(properties.not)) { + throw new Error('NotCondition: constructor "not" cannot be an array') + } + this.not = comparisonConstructor.construct(properties.not) + this.operator = 'not' + // boolean conditions always have a priority; default 1 + this.priority = this.priority || 1 + } + + /** + * Converts the condition into a json-friendly structure + * @param {Boolean} stringify - whether to return as a json string + * @returns {string,object} json string or json-friendly object + */ + toJSON (stringify = true) { + const props = super.toJSON(false) + props.not = this.not.toJSON(false) + if (stringify) { + return JSON.stringify(props) + } + return props + } + + /** + * Runs the nested condition and returns the inverse of the result + * + * @param {Almanac} almanac + * @param {Map} operatorMap - map of available operators, keyed by operator name + * @param {Map} conditionMap - map of available conditions, keyed by condition name + * @returns {object} - evaluation result + */ + evaluate (almanac, operatorMap, conditionMap) { + return Promise.all([ + this.not.evaluate(almanac, operatorMap, conditionMap), + super.evaluate(almanac, operatorMap, conditionMap) + ]).then(([not, evaluateResult]) => { + evaluateResult.result = not.result !== true + evaluateResult.not = not + evaluateResult.operator = 'not' + return evaluateResult + }) + } + + skip () { + const skipResult = super.skip() + skipResult.not = this.not.skip() + skipResult.operator = 'not' + return skipResult + } +} diff --git a/src/condition/prioritize-and-run.js b/src/condition/prioritize-and-run.js new file mode 100644 index 0000000..fdc9520 --- /dev/null +++ b/src/condition/prioritize-and-run.js @@ -0,0 +1,116 @@ +import debug from '../debug' + +/** + * 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 + * @param {Almanac} almanac + * @return {[number, Condition][][]} prioritized two-dimensional array of conditions and original indexes + * Each outer array element represents a single priority(integer). Inner array is + * all conditions with that priority. + */ +function prioritizeConditions (conditions, almanac) { + const factSets = conditions.reduce((sets, condition, index) => { + // if a priority has been set on this specific condition, honor that first + // otherwise, use the fact's priority + let priority = condition.getPriority(almanac) + if (priority == null) { + priority = 1 + } + if (!sets[priority]) sets[priority] = [] + sets[priority].push([index, condition]) + return sets + }, {}) + return Object.keys(factSets) + .sort((a, b) => { + return Number(a) > Number(b) ? -1 : 1 // order highest priority -> lowest + }) + .map((priority) => factSets[priority]) +} + +/** + * Evaluates a set of conditions based on an 'some' or 'every' 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(any|all)} operator + * @param {Almanac} almanac + * @return {Promise(boolean)} rule evaluation result + */ +export default function prioritizeAndRun ( + conditions, + operator, + almanac, + operatorMap, + conditionMap +) { + if (conditions.length === 0) { + return Promise.resolve({ + result: true, + conditions: [] + }) + } + if (conditions.length === 1) { + // no prioritizing is necessary, just evaluate the single condition + return conditions[0] + .evaluate(almanac, operatorMap, conditionMap) + .then((condition) => ({ + result: condition.result, + conditions: [condition] + })) + } + const orderedSets = prioritizeConditions(conditions, almanac) + let cursor = Promise.resolve() + const conditionsResults = [] + // use for() loop over Array.forEach to support IE8 without polyfill + for (let i = 0; i < orderedSets.length; i++) { + const set = orderedSets[i] + cursor = cursor.then((setResult) => { + let skip = false + // after the first set succeeds, don't fire off the remaining promises + if (operator === 'any' && setResult === true) { + debug( + 'prioritizeAndRun::detected truthy result; skipping remaining conditions' + ) + skip = true + } + + // after the first set fails, don't fire off the remaining promises + if (operator === 'all' && setResult === false) { + debug( + 'prioritizeAndRun::detected falsey result; skipping remaining conditions' + ) + skip = true + } + if (skip) { + for (let j = 0; j < set.length; j++) { + const [index, condition] = set[j] + conditionsResults[index] = condition.skip() + } + return setResult + } + // all conditions passed; proceed with running next set in parallel + return Promise.all( + set.map(([index, condition]) => + condition + .evaluate(almanac, operatorMap, conditionMap) + .then((result) => { + conditionsResults[index] = result + return result.result + }) + ) + ).then((allResults) => { + if (operator === 'all') { + return allResults.every((result) => result) + } + return allResults.some((result) => result) + }) + }) + } + return cursor.then(result => ({ + result, + conditions: conditionsResults + })) +} diff --git a/src/engine.js b/src/engine.js index 70929c9..b941603 100644 --- a/src/engine.js +++ b/src/engine.js @@ -7,7 +7,7 @@ import Almanac from './almanac' import EventEmitter from 'eventemitter2' import defaultOperators from './engine-default-operators' import debug from './debug' -import Condition from './condition' +import ConditionConstructor from './condition' export const READY = 'READY' export const RUNNING = 'RUNNING' @@ -25,6 +25,7 @@ class Engine extends EventEmitter { this.allowUndefinedConditions = options.allowUndefinedConditions || false this.replaceFactsInEventParams = options.replaceFactsInEventParams || false this.pathResolver = options.pathResolver + this.conditionConstructor = new ConditionConstructor() this.operators = new Map() this.facts = new Map() this.conditions = new Map() @@ -108,7 +109,7 @@ class Engine extends EventEmitter { if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) { throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"') } - this.conditions.set(name, new Condition(conditions)) + this.conditions.set(name, this.conditionConstructor.construct(conditions)) return this } diff --git a/src/rule-result.js b/src/rule-result.js index 5e1fcbd..6967400 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -5,15 +5,11 @@ import isObject from 'lodash.isobjectlike' export default class RuleResult { constructor (conditions, event, priority, name) { - this.conditions = deepClone(conditions) + this.conditions = conditions + this.result = conditions.result this.event = deepClone(event) - this.priority = deepClone(priority) - this.name = deepClone(name) - this.result = null - } - - setResult (result) { - this.result = result + this.priority = priority + this.name = name } resolveEventParams (almanac) { @@ -35,7 +31,7 @@ export default class RuleResult { toJSON (stringify = true) { const props = { - conditions: this.conditions.toJSON(false), + conditions: this.conditions, event: this.event, priority: this.priority, name: this.name, diff --git a/src/rule.js b/src/rule.js index c893a14..a43055e 100644 --- a/src/rule.js +++ b/src/rule.js @@ -1,10 +1,8 @@ 'use strict' -import Condition from './condition' import RuleResult from './rule-result' -import debug from './debug' -import deepClone from 'clone' import EventEmitter from 'eventemitter2' +import ConditionConstructor, { neverCondition } from './condition' class Rule extends EventEmitter { /** @@ -23,6 +21,7 @@ class Rule extends EventEmitter { if (typeof options === 'string') { options = JSON.parse(options) } + this.conditionConstructor = new ConditionConstructor() if (options && options.conditions) { this.setConditions(options.conditions) } @@ -81,7 +80,7 @@ class Rule extends EventEmitter { '"conditions" root must contain a single instance of "all", "any", "not", or "condition"' ) } - this.conditions = new Condition(conditions) + this.conditions = this.conditionConstructor.construct(conditions) return this } @@ -160,240 +159,48 @@ class Rule extends EventEmitter { 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. - */ - prioritizeConditions (conditions) { - const factSets = conditions.reduce((sets, condition) => { - // if a priority has been set on this specific condition, honor that first - // otherwise, use the fact's priority - let priority = condition.priority - if (!priority) { - const fact = this.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((a, b) => { - return Number(a) > Number(b) ? -1 : 1 // order highest priority -> lowest - }) - .map((priority) => 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 */ evaluate (almanac) { - const ruleResult = new RuleResult( - this.conditions, - this.ruleEvent, - this.priority, - this.name - ) - - /** - * Evaluates the rule conditions - * @param {Condition} condition - condition to evaluate - * @return {Promise(true|false)} - resolves with the result of the condition evaluation - */ - const evaluateCondition = (condition) => { - if (condition.isConditionReference()) { - return realize(condition) - } else if (condition.isBooleanOperator()) { - const subConditions = condition[condition.operator] - let comparisonPromise - if (condition.operator === 'all') { - comparisonPromise = all(subConditions) - } else if (condition.operator === 'any') { - comparisonPromise = any(subConditions) - } else { - comparisonPromise = not(subConditions) - } - // for booleans, rule passing is determined by the all/any/not result - return comparisonPromise.then((comparisonValue) => { - const passes = comparisonValue === true - condition.result = passes - return passes - }) - } else { - return condition - .evaluate(almanac, this.engine.operators) - .then((evaluationResult) => { - const passes = evaluationResult.result - condition.factResult = evaluationResult.leftHandSideValue - condition.result = passes - return passes - }) - } - } - - /** - * 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 - */ - const evaluateConditions = (conditions, method) => { - if (!Array.isArray(conditions)) conditions = [conditions] - - return Promise.all( - conditions.map((condition) => evaluateCondition(condition)) - ).then((conditionResults) => { - debug('rule::evaluateConditions results', conditionResults) - return method.call(conditionResults, (result) => result === true) - }) - } - - /** - * Evaluates a set of conditions based on an 'all', 'any', or 'not' 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'|'not')} operator - * @return {Promise(boolean)} rule evaluation result - */ - const prioritizeAndRun = (conditions, operator) => { - if (conditions.length === 0) { - return Promise.resolve(true) - } - if (conditions.length === 1) { - // no prioritizing is necessary, just evaluate the single condition - // 'all' and 'any' will give the same results with a single condition so no method is necessary - // this also covers the 'not' case which should only ever have a single condition - return evaluateCondition(conditions[0]) - } - let method = Array.prototype.some - if (operator === 'all') { - method = Array.prototype.every - } - const orderedSets = this.prioritizeConditions(conditions) - let cursor = Promise.resolve() - // use for() loop over Array.forEach to support IE8 without polyfill - for (let i = 0; i < orderedSets.length; i++) { - const set = orderedSets[i] - let stop = false - cursor = cursor.then((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 + return this.conditions + .evaluate(almanac, this.engine.operators, { + get: (conditionName) => { + if (this.engine.conditions.has(conditionName)) { + return this.engine.conditions.get(conditionName) } - - // 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 + if (this.engine.allowUndefinedConditions) { + return neverCondition + } else { + throw new Error(`No condition ${conditionName} exists`) } - // all conditions passed; proceed with running next set in parallel - return evaluateConditions(set, method) - }) - } - return cursor - } - - /** - * Runs an 'any' boolean operator on an array of conditions - * @param {Condition[]} conditions to be evaluated - * @return {Promise(boolean)} condition evaluation result - */ - const any = (conditions) => { - return prioritizeAndRun(conditions, 'any') - } - - /** - * Runs an 'all' boolean operator on an array of conditions - * @param {Condition[]} conditions to be evaluated - * @return {Promise(boolean)} condition evaluation result - */ - const all = (conditions) => { - return prioritizeAndRun(conditions, 'all') - } - - /** - * Runs a 'not' boolean operator on a single condition - * @param {Condition} condition to be evaluated - * @return {Promise(boolean)} condition evaluation result - */ - const not = (condition) => { - return prioritizeAndRun([condition], 'not').then((result) => !result) - } - - /** - * Dereferences the condition reference and then evaluates it. - * @param {Condition} conditionReference - * @returns {Promise(boolean)} condition evaluation result - */ - const realize = (conditionReference) => { - const condition = this.engine.conditions.get(conditionReference.condition) - if (!condition) { - if (this.engine.allowUndefinedConditions) { - // undefined conditions always fail - conditionReference.result = false - return Promise.resolve(false) - } else { - throw new Error( - `No condition ${conditionReference.condition} exists` - ) } - } else { - // project the referenced condition onto reference object and evaluate it. - delete conditionReference.condition - Object.assign(conditionReference, deepClone(condition)) - return evaluateCondition(conditionReference) - } - } - - /** - * Emits based on rule evaluation result, and decorates ruleResult with 'result' property - * @param {RuleResult} ruleResult - */ - const processResult = (result) => { - ruleResult.setResult(result) - let processEvent = Promise.resolve() - if (this.engine.replaceFactsInEventParams) { - processEvent = ruleResult.resolveEventParams(almanac) - } - const event = result ? 'success' : 'failure' - return processEvent.then(() => this.emitAsync(event, ruleResult.event, almanac, ruleResult)).then( - () => ruleResult - ) - } - - if (ruleResult.conditions.any) { - return any(ruleResult.conditions.any).then((result) => - processResult(result) - ) - } else if (ruleResult.conditions.all) { - return all(ruleResult.conditions.all).then((result) => - processResult(result) - ) - } else if (ruleResult.conditions.not) { - return not(ruleResult.conditions.not).then((result) => - processResult(result) - ) - } else { - return realize( - ruleResult.conditions - ).then((result) => processResult(result)) - } + }) + .then((conditionResult) => { + return new RuleResult( + conditionResult, + this.ruleEvent, + this.priority, + this.name + ) + }) + .then((ruleResult) => { + if (this.engine.replaceFactsInEventParams) { + return ruleResult.resolveEventParams(almanac).then(() => ruleResult) + } + return ruleResult + }) + .then((ruleResult) => { + const event = ruleResult.result ? 'success' : 'failure' + return this.emitAsync( + event, + ruleResult.event, + almanac, + ruleResult + ).then(() => ruleResult) + }) } } diff --git a/test/acceptance/acceptance.js b/test/acceptance/acceptance.js index fb68cc7..c345e4c 100644 --- a/test/acceptance/acceptance.js +++ b/test/acceptance/acceptance.js @@ -27,27 +27,33 @@ describe('Acceptance', () => { type: 'event-2' } const expectedFirstRuleResult = { - all: [{ - fact: 'high-priority', - params: { - factParam + all: [ + { + fact: 'high-priority', + params: { + factParam + }, + operator: 'contains', + path: '$.values', + value: 2, + priority: 2, + factResult: [2], + valueResult: 2, + result: true }, - operator: 'contains', - path: '$.values', - value: 2, - factResult: [2], - result: true - }, - { - fact: 'low-priority', - operator: 'in', - value: [2], - factResult: 2, - result: true - } + { + fact: 'low-priority', + operator: 'in', + value: [2], + priority: 1, + factResult: 2, + valueResult: [2], + result: true + } ], operator: 'all', - priority: 1 + priority: 1, + result: true } let successSpy let failureSpy @@ -55,7 +61,7 @@ describe('Acceptance', () => { let lowPrioritySpy function delay (value) { - return new Promise(resolve => setTimeout(() => resolve(value), 5)) + return new Promise((resolve) => setTimeout(() => resolve(value), 5)) } function setup (options = {}) { @@ -67,19 +73,22 @@ describe('Acceptance', () => { name: 'first', priority: 10, conditions: { - all: [{ - fact: 'high-priority', - params: { - factParam + all: [ + { + fact: 'high-priority', + params: { + factParam + }, + operator: 'contains', + path: '$.values', + value: options.highPriorityValue }, - operator: 'contains', - path: '$.values', - value: options.highPriorityValue - }, { - fact: 'low-priority', - operator: 'in', - value: options.lowPriorityValue - }] + { + fact: 'low-priority', + operator: 'in', + value: options.lowPriorityValue + } + ] }, event: event1, onSuccess: async (event, almanac, ruleResults) => { @@ -88,7 +97,11 @@ describe('Acceptance', () => { expect(ruleResults.priority).to.equal(10) expect(ruleResults.conditions).to.deep.equal(expectedFirstRuleResult) - return delay(almanac.addRuntimeFact('rule-created-fact', { array: options.highPriorityValue })) + return delay( + almanac.addRuntimeFact('rule-created-fact', { + array: options.highPriorityValue + }) + ) } }) @@ -96,37 +109,47 @@ describe('Acceptance', () => { name: 'second', priority: 1, conditions: { - all: [{ - fact: 'high-priority', - params: { - factParam - }, - operator: 'containsDivisibleValuesOf', - path: '$.values', - value: { - fact: 'rule-created-fact', - path: '$.array' // set by 'success' of first rule + all: [ + { + fact: 'high-priority', + params: { + factParam + }, + operator: 'containsDivisibleValuesOf', + path: '$.values', + value: { + fact: 'rule-created-fact', + path: '$.array' // set by 'success' of first rule + } } - }] + ] }, event: event2 }) engine.addOperator('containsDivisibleValuesOf', (factValue, jsonValue) => { - return factValue.some(v => v % jsonValue === 0) + return factValue.some((v) => v % jsonValue === 0) }) - engine.addFact('high-priority', async function (params, almanac) { - highPrioritySpy(params) - const idx = await almanac.factValue('sub-fact') - return delay({ values: [idx + params.factParam] }) // { values: [baseIndex + factParam] } - }, { priority: 2 }) + engine.addFact( + 'high-priority', + async function (params, almanac) { + highPrioritySpy(params) + const idx = await almanac.factValue('sub-fact') + return delay({ values: [idx + params.factParam] }) // { values: [baseIndex + factParam] } + }, + { priority: 2 } + ) - engine.addFact('low-priority', async function (params, almanac) { - lowPrioritySpy(params) - const idx = await almanac.factValue('sub-fact') - return delay(idx + 1) // baseIndex + 1 - }, { priority: 1 }) + engine.addFact( + 'low-priority', + async function (params, almanac) { + lowPrioritySpy(params) + const idx = await almanac.factValue('sub-fact') + return delay(idx + 1) // baseIndex + 1 + }, + { priority: 1 } + ) engine.addFact('sub-fact', async function (params, almanac) { const baseIndex = await almanac.factValue('baseIndex') @@ -146,12 +169,9 @@ describe('Acceptance', () => { lowPriorityValue: [2] }) - const { - results, - failureResults, - events, - failureEvents - } = await engine.run({ baseIndex: 1 }) + const { results, failureResults, events, failureEvents } = await engine.run( + { baseIndex: 1 } + ) // results expect(results.length).to.equal(2) @@ -160,29 +180,30 @@ describe('Acceptance', () => { all: [ { fact: 'high-priority', - factResult: [ - 2 - ], + factResult: [2], operator: 'contains', params: { factParam: 1 }, path: '$.values', + priority: 2, result: true, - value: 2 + value: 2, + valueResult: 2 }, { fact: 'low-priority', factResult: 2, operator: 'in', + priority: 1, result: true, - value: [ - 2 - ] + value: [2], + valueResult: [2] } ], operator: 'all', - priority: 1 + priority: 1, + result: true }, event: { params: { @@ -199,23 +220,24 @@ describe('Acceptance', () => { all: [ { fact: 'high-priority', - factResult: [ - 2 - ], + factResult: [2], operator: 'containsDivisibleValuesOf', params: { factParam: 1 }, path: '$.values', + priority: 2, result: true, value: { fact: 'rule-created-fact', path: '$.array' - } + }, + valueResult: 2 } ], operator: 'all', - priority: 1 + priority: 1, + result: true }, event: { type: 'event-2' @@ -246,16 +268,13 @@ describe('Acceptance', () => { lowPriorityValue: [3] // falsey }) - const { - results, - failureResults, - events, - failureEvents - } = await engine.run({ baseIndex: 1, 'rule-created-fact': '' }) + const { results, failureResults, events, failureEvents } = await engine.run( + { baseIndex: 1, 'rule-created-fact': '' } + ) expect(results.length).to.equal(0) expect(failureResults.length).to.equal(2) - expect(failureResults.every(rr => rr.result === false)).to.be.true() + expect(failureResults.every((rr) => rr.result === false)).to.be.true() expect(events.length).to.equal(0) expect(failureEvents.length).to.equal(2) diff --git a/test/condition.test.js b/test/condition.test.js index d4ab046..a4213fc 100644 --- a/test/condition.test.js +++ b/test/condition.test.js @@ -1,12 +1,13 @@ 'use strict' -import Condition from '../src/condition' +import ConditionConstructor from '../src/condition' import defaultOperators from '../src/engine-default-operators' import Almanac from '../src/almanac' import Fact from '../src/fact' const operators = new Map() defaultOperators.forEach(o => operators.set(o.name, o)) +const conditions = new Map() function condition () { return { @@ -21,11 +22,13 @@ function condition () { } } +const conditionConstructor = new ConditionConstructor() + describe('Condition', () => { describe('constructor', () => { it('fact conditions have properties', () => { const properties = condition() - const subject = new Condition(properties.all[0]) + const subject = conditionConstructor.construct(properties.all[0]) expect(subject).to.have.property('fact') expect(subject).to.have.property('operator') expect(subject).to.have.property('value') @@ -35,7 +38,7 @@ describe('Condition', () => { it('boolean conditions have properties', () => { const properties = condition() - const subject = new Condition(properties) + const subject = conditionConstructor.construct(properties) expect(subject).to.have.property('operator') expect(subject).to.have.property('priority') expect(subject.priority).to.equal(1) @@ -54,7 +57,7 @@ describe('Condition', () => { path: '.value' } }) - const condition = new Condition(properties) + const condition = conditionConstructor.construct(properties) const json = condition.toJSON() expect(json).to.equal('{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}') }) @@ -74,7 +77,7 @@ describe('Condition', () => { }) } } - const condition = new Condition(properties) + const condition = conditionConstructor.construct(properties) const json = condition.toJSON() expect(json).to.equal('{"priority":1,"not":{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}}') }) @@ -89,7 +92,7 @@ describe('Condition', () => { let almanac function setup (options, factValue) { const properties = Object.assign({}, conditionBase, options) - condition = new Condition(properties) + condition = conditionConstructor.construct(properties) const fact = new Fact(conditionBase.fact, factValue) almanac = new Almanac(new Map([[fact.id, fact]])) } @@ -102,130 +105,126 @@ describe('Condition', () => { it('throws when missing operators', () => { return expect(condition.evaluate(almanac, undefined)).to.be.rejectedWith('operatorMap required') }) - it('throws when run against a boolean operator', () => { - condition.all = [] - return expect(condition.evaluate(almanac, operators)).to.be.rejectedWith('Cannot evaluate() a boolean condition') - }) }) it('evaluates "equal"', async () => { setup({ operator: 'equal' }, 50) - expect((await condition.evaluate(almanac, operators, 50)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'equal' }, 5) - expect((await condition.evaluate(almanac, operators, 5)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "equal" to check for undefined', async () => { - condition = new Condition({ fact: 'age', operator: 'equal', value: undefined }) + condition = conditionConstructor.construct({ fact: 'age', operator: 'equal', value: undefined }) let fact = new Fact('age', undefined) almanac = new Almanac(new Map([[fact.id, fact]])) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) fact = new Fact('age', 1) almanac = new Almanac(new Map([[fact.id, fact]])) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "notEqual"', async () => { setup({ operator: 'notEqual' }, 50) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'notEqual' }, 5) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) }) it('evaluates "in"', async () => { setup({ operator: 'in', value: [5, 10, 15, 20] }, 15) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'in', value: [5, 10, 15, 20] }, 99) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "contains"', async () => { setup({ operator: 'contains', value: 10 }, [5, 10, 15]) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'contains', value: 10 }, [1, 2, 3]) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "doesNotContain"', async () => { setup({ operator: 'doesNotContain', value: 10 }, [5, 10, 15]) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'doesNotContain', value: 10 }, [1, 2, 3]) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) }) it('evaluates "notIn"', async () => { setup({ operator: 'notIn', value: [5, 10, 15, 20] }, 15) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'notIn', value: [5, 10, 15, 20] }, 99) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) }) it('evaluates "lessThan"', async () => { setup({ operator: 'lessThan' }, 49) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'lessThan' }, 50) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'lessThan' }, 51) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "lessThanInclusive"', async () => { setup({ operator: 'lessThanInclusive' }, 49) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'lessThanInclusive' }, 50) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'lessThanInclusive' }, 51) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "greaterThan"', async () => { setup({ operator: 'greaterThan' }, 51) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'greaterThan' }, 49) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'greaterThan' }, 50) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('evaluates "greaterThanInclusive"', async () => { setup({ operator: 'greaterThanInclusive' }, 51) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'greaterThanInclusive' }, 50) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) setup({ operator: 'greaterThanInclusive' }, 49) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) describe('invalid comparisonValues', () => { it('returns false when using contains or doesNotContain with a non-array', async () => { setup({ operator: 'contains' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'doesNotContain' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('returns false when using comparison operators with null', async () => { setup({ operator: 'lessThan' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'lessThanInclusive' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'greaterThan' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'greaterThanInclusive' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('returns false when using comparison operators with non-numbers', async () => { setup({ operator: 'lessThan' }, 'non-number') - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'lessThan' }, null) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'lessThan' }, []) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) setup({ operator: 'lessThan' }, {}) - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) }) }) @@ -233,14 +232,14 @@ describe('Condition', () => { describe('objects', () => { describe('.path', () => { it('extracts the object property values using its "path" property', async () => { - const condition = new Condition({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 }) + const condition = conditionConstructor.construct({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 }) const ageFact = new Fact('age', [{ id: 50 }, { id: 60 }]) const facts = new Map([[ageFact.id, ageFact]]) const almanac = new Almanac(facts) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) condition.value = 100 // negative case - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) it('ignores "path" when non-objects are returned by the fact', async () => { @@ -248,7 +247,7 @@ describe('Condition', () => { const facts = new Map([[ageFact.id, ageFact]]) const almanac = new Almanac(facts) - const condition = new Condition({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 }) + const condition = conditionConstructor.construct({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 }) expect((await condition.evaluate(almanac, operators, 50)).result).to.equal(true) condition.value = 100 // negative case @@ -258,7 +257,7 @@ describe('Condition', () => { describe('jsonPath', () => { it('allows json path to extract values from complex facts', async () => { - const condition = new Condition({ operator: 'contains', path: '$.phoneNumbers[*].type', fact: 'users', value: 'iPhone' }) + const condition = conditionConstructor.construct({ operator: 'contains', path: '$.phoneNumbers[*].type', fact: 'users', value: 'iPhone' }) const userData = { phoneNumbers: [ { @@ -275,10 +274,10 @@ describe('Condition', () => { const usersFact = new Fact('users', userData) const facts = new Map([[usersFact.id, usersFact]]) const almanac = new Almanac(facts) - expect((await condition.evaluate(almanac, operators)).result).to.equal(true) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(true) condition.value = 'work' // negative case - expect((await condition.evaluate(almanac, operators)).result).to.equal(false) + expect((await condition.evaluate(almanac, operators, conditions)).result).to.equal(false) }) }) }) @@ -287,14 +286,14 @@ describe('Condition', () => { it('throws if not not an array', () => { const conditions = condition() conditions.all = { foo: true } - expect(() => new Condition(conditions)).to.throw(/"all" must be an array/) + expect(() => conditionConstructor.construct(conditions)).to.throw(/"all" must be an array/) }) it('throws if is an array and condition is "not"', () => { const conditions = { not: [{ foo: true }] } - expect(() => new Condition(conditions)).to.throw(/"not" cannot be an array/) + expect(() => conditionConstructor.construct(conditions)).to.throw(/"not" cannot be an array/) }) it('does not throw if is not an array and condition is "not"', () => { @@ -305,31 +304,31 @@ describe('Condition', () => { value: 'bar' } } - expect(() => new Condition(conditions)).to.not.throw() + expect(() => conditionConstructor.construct(conditions)).to.not.throw() }) }) describe('atomic facts', () => { it('throws if no options are provided', () => { - expect(() => new Condition()).to.throw(/Condition: constructor options required/) + expect(() => conditionConstructor.construct()).to.throw(/Condition: constructor options required/) }) it('throws for a missing "operator"', () => { const conditions = condition() delete conditions.all[0].operator - expect(() => new Condition(conditions)).to.throw(/Condition: constructor "operator" property required/) + expect(() => conditionConstructor.construct(conditions)).to.throw(/Condition: constructor "operator" property required/) }) it('throws for a missing "fact"', () => { const conditions = condition() delete conditions.all[0].fact - expect(() => new Condition(conditions)).to.throw(/Condition: constructor "fact" property required/) + expect(() => conditionConstructor.construct(conditions)).to.throw(/Condition: constructor "fact" property required/) }) it('throws for a missing "value"', () => { const conditions = condition() delete conditions.all[0].value - expect(() => new Condition(conditions)).to.throw(/Condition: constructor "value" property required/) + expect(() => conditionConstructor.construct(conditions)).to.throw(/Condition: constructor "value" property required/) }) }) @@ -365,13 +364,13 @@ describe('Condition', () => { } } it('recursively parses nested conditions', () => { - expect(() => new Condition(complexCondition())).to.not.throw() + expect(() => conditionConstructor.construct(complexCondition())).to.not.throw() }) it('throws if a nested condition is invalid', () => { const conditions = complexCondition() delete conditions.all[2].any[0].fact - expect(() => new Condition(conditions)).to.throw(/Condition: constructor "fact" property required/) + expect(() => conditionConstructor.construct(conditions)).to.throw(/Condition: constructor "fact" property required/) }) }) }) diff --git a/test/engine-event.test.js b/test/engine-event.test.js index c929d92..b30e384 100644 --- a/test/engine-event.test.js +++ b/test/engine-event.test.js @@ -341,7 +341,10 @@ describe('Engine: event', () => { it('failure passes the event without resolved facts', async () => { const failureSpy = sandbox.spy() engine.on('failure', failureSpy) - const { failureResults } = await engine.run({ success: false, count: 5 }) + const { failureResults } = await engine.run({ + success: false, + count: 5 + }) expect(failureResults[0].event).to.deep.equal(eventWithFact) expect(failureSpy.firstCall.args[0]).to.deep.equal(eventWithFact) }) @@ -359,7 +362,10 @@ describe('Engine: event', () => { it('failure passes the event with resolved facts', async () => { const failureSpy = sandbox.spy() engine.on('failure', failureSpy) - const { failureResults } = await engine.run({ success: false, count: 5 }) + const { failureResults } = await engine.run({ + success: false, + count: 5 + }) expect(failureResults[0].event).to.deep.equal(expectedEvent) expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent) }) @@ -597,9 +603,38 @@ describe('Engine: event', () => { rule.on('success', successSpy) await engine.run() const ruleResult = successSpy.getCall(0).args[2] - const expected = - '{"conditions":{"priority":1,"any":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}' - expect(JSON.stringify(ruleResult)).to.equal(expected) + const expected = { + conditions: { + priority: 1, + operator: 'any', + any: [ + { + name: 'over 21', + operator: 'greaterThanInclusive', + value: 21, + valueResult: 21, + priority: 1, + fact: 'age', + factResult: 21, + result: true + }, + { + operator: 'equal', + value: true, + valueResult: true, + priority: 1, + fact: 'qualified', + factResult: false, + result: false + } + ], + result: true + }, + event: { type: 'setDrinkingFlag', params: { canOrderDrinks: true } }, + priority: 100, + result: true + } + expect(JSON.parse(JSON.stringify(ruleResult))).to.eql(expected) }) }) }) diff --git a/test/rule.test.js b/test/rule.test.js index fc3357b..4fba54b 100644 --- a/test/rule.test.js +++ b/test/rule.test.js @@ -184,43 +184,6 @@ describe('Rule', () => { }) }) - describe('priotizeConditions()', () => { - const conditions = [{ - fact: 'age', - operator: 'greaterThanInclusive', - value: 18 - }, { - fact: 'segment', - operator: 'equal', - value: 'human' - }, { - fact: 'accountType', - operator: 'equal', - value: 'admin' - }, { - fact: 'state', - operator: 'equal', - value: 'admin' - }] - - it('orders based on priority', async () => { - const engine = new Engine() - engine.addFact('state', async () => {}, { priority: 500 }) - engine.addFact('segment', async () => {}, { priority: 50 }) - engine.addFact('accountType', async () => {}, { priority: 25 }) - engine.addFact('age', async () => {}, { priority: 100 }) - const rule = new Rule() - rule.setEngine(engine) - - const prioritizedConditions = rule.prioritizeConditions(conditions) - expect(prioritizedConditions.length).to.equal(4) - expect(prioritizedConditions[0][0].fact).to.equal('state') - expect(prioritizedConditions[1][0].fact).to.equal('age') - expect(prioritizedConditions[2][0].fact).to.equal('segment') - expect(prioritizedConditions[3][0].fact).to.equal('accountType') - }) - }) - describe('evaluate()', () => { function setup () { const engine = new Engine() From 7bf1a2e2dec3eaa3206f954c5dc404582eb825d4 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Tue, 17 Oct 2023 10:33:33 -0400 Subject: [PATCH 3/4] Support Passing in a condition constructor Support passing a condition constructor into the engine and the rules in order to allow for custom conditions. --- docs/engine.md | 2 ++ docs/rules.md | 2 ++ src/engine.js | 5 +++- src/json-rules-engine.js | 3 +- src/rule.js | 6 +++- test/engine-condition-constructor.test.js | 36 +++++++++++++++++++++++ types/index.d.ts | 18 ++++++++++++ 7 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 test/engine-condition-constructor.test.js diff --git a/docs/engine.md b/docs/engine.md index 9712b5e..4d1af57 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -54,6 +54,8 @@ as failed conditions. (default: false) `pathResolver` - Allows a custom object path resolution library to be used. (default: `json-path` syntax). See [custom path resolver](./rules.md#condition-helpers-custom-path-resolver) docs. +`conditionConstructor` - The condition constructor class to use when given condition options. Allows for custom conditions to be created by specifying a new condition constructor. (default: new ConditionConstructor) + ### engine.addFact(String id, Function [definitionFunc], Object [options]) ```js diff --git a/docs/rules.md b/docs/rules.md index 45b679e..3a03749 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -73,6 +73,8 @@ let rule = new Rule(options) **options.name** : `[Any]` A way of naming your rules, allowing them to be easily identifiable in [Rule Results](#rule-results). This is usually of type `String`, but could also be `Object`, `Array`, or `Number`. Note that the name need not be unique, and that it has no impact on execution of the rule. +**options.conditionConstructor**: [ConditionConstructor] - The condition constructor class to use when given condition options. Allows for custom conditions to be created by specifying a new condition constructor. (default: new ConditionConstructor) + ### setConditions(Array conditions) Helper for setting rule conditions. Alternative to passing the `conditions` option to the rule constructor. diff --git a/src/engine.js b/src/engine.js index b941603..b7b5b66 100644 --- a/src/engine.js +++ b/src/engine.js @@ -25,7 +25,7 @@ class Engine extends EventEmitter { this.allowUndefinedConditions = options.allowUndefinedConditions || false this.replaceFactsInEventParams = options.replaceFactsInEventParams || false this.pathResolver = options.pathResolver - this.conditionConstructor = new ConditionConstructor() + this.conditionConstructor = options.conditionConstructor || new ConditionConstructor() this.operators = new Map() this.facts = new Map() this.conditions = new Map() @@ -52,6 +52,9 @@ class Engine extends EventEmitter { } else { if (!Object.prototype.hasOwnProperty.call(properties, 'event')) throw new Error('Engine: addRule() argument requires "event" property') if (!Object.prototype.hasOwnProperty.call(properties, 'conditions')) throw new Error('Engine: addRule() argument requires "conditions" property') + if (!Object.prototype.hasOwnProperty.call(properties, 'conditionConstructor')) { + properties.conditionConstructor = this.conditionConstructor + } rule = new Rule(properties) } rule.setEngine(this) diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index 339c3c0..40dd9bc 100644 --- a/src/json-rules-engine.js +++ b/src/json-rules-engine.js @@ -2,8 +2,9 @@ import Engine from './engine' import Fact from './fact' import Rule from './rule' import Operator from './operator' +import ConditionConstructor, { Condition } from './condition' -export { Fact, Rule, Operator, Engine } +export { Fact, Rule, Operator, Engine, ConditionConstructor, Condition } export default function (rules, options) { return new Engine(rules, options) } diff --git a/src/rule.js b/src/rule.js index a43055e..be7b3ef 100644 --- a/src/rule.js +++ b/src/rule.js @@ -21,7 +21,11 @@ class Rule extends EventEmitter { if (typeof options === 'string') { options = JSON.parse(options) } - this.conditionConstructor = new ConditionConstructor() + if (options && options.conditionConstructor) { + this.conditionConstructor = options.conditionConstructor + } else { + this.conditionConstructor = new ConditionConstructor() + } if (options && options.conditions) { this.setConditions(options.conditions) } diff --git a/test/engine-condition-constructor.test.js b/test/engine-condition-constructor.test.js new file mode 100644 index 0000000..b50b98b --- /dev/null +++ b/test/engine-condition-constructor.test.js @@ -0,0 +1,36 @@ +'use strict' + +import sinon from 'sinon' +import engineFactory from '../src/index' + +describe('Engine: conditionConstructor', () => { + let engine + let conditionConstructor + let sandbox + before(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) + + beforeEach(() => { + conditionConstructor = { construct: sandbox.spy() } + engine = engineFactory([], { conditionConstructor }) + }) + + it('invokes the condition constructor when adding a condition', () => { + const conditionOpts = { all: [] } + engine.setCondition('test', conditionOpts) + expect(conditionConstructor.construct).to.have.been.calledWith(conditionOpts) + }) + + it('invokes the condition constructor when adding a rule', () => { + const ruleOpts = { + conditions: { all: [] }, + event: { type: 'unknown' } + } + engine.addRule(ruleOpts) + expect(conditionConstructor.construct).to.have.been.calledWith(ruleOpts.conditions) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 35b380a..fc4e4f3 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,6 +3,7 @@ export interface EngineOptions { allowUndefinedConditions?: boolean; replaceFactsInEventParams?: boolean; pathResolver?: PathResolver; + conditionConstructor?: ConditionConstructor; } export interface EngineResult { @@ -118,6 +119,7 @@ export interface RuleProperties { priority?: number; onSuccess?: EventHandler; onFailure?: EventHandler; + conditionConstructor?: ConditionConstructor; } export type RuleSerializable = Pick< Required, @@ -147,6 +149,22 @@ export class Rule implements RuleProperties { ): T extends true ? string : RuleSerializable; } +export class Condition { + constructor(properties: object); + toJSON(stringify: true): {name?: string, priority?: number} + getPriority(almanac: Almanac): number | undefined; + evaluate( + almanac: Almanac, + operatorMap: { get(operatorName: string): Operator }, + conditionMap: { get(conditionName): Condition} + ): Promise<{ result: boolean, priority: number | undefined, name?: string }> + skip(): {name?: string, priority?: number} +} + +export class ConditionConstructor { + construct(options: object): Condition +} + interface ConditionProperties { fact: string; operator: string; From 61ac8d580d3c4aa180c60ec045e850b69f7c6bc4 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Tue, 17 Oct 2023 10:47:01 -0400 Subject: [PATCH 4/4] Move Top Level enforcement to ConditionConstructor Move the enforcement of top-level conditions in the rules and the engine to the top-level condition constructor this also allows for passing Condition instances to the setCondition method by default Ultimately this adds a huge amount of support for new types of conditions that can be plugged into the system. --- docs/engine.md | 2 +- docs/rules.md | 2 +- src/condition/condition-constructor.js | 26 +++++++++++++++++++++++ src/condition/index.js | 5 +++-- src/engine.js | 7 ++---- src/json-rules-engine.js | 4 ++-- src/rule.js | 14 ++---------- test/engine-condition-constructor.test.js | 5 +++++ types/index.d.ts | 10 ++++++--- 9 files changed, 49 insertions(+), 26 deletions(-) diff --git a/docs/engine.md b/docs/engine.md index 4d1af57..35dd7f6 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -54,7 +54,7 @@ as failed conditions. (default: false) `pathResolver` - Allows a custom object path resolution library to be used. (default: `json-path` syntax). See [custom path resolver](./rules.md#condition-helpers-custom-path-resolver) docs. -`conditionConstructor` - The condition constructor class to use when given condition options. Allows for custom conditions to be created by specifying a new condition constructor. (default: new ConditionConstructor) +`conditionConstructor` - The condition constructor class to use when given condition options. Allows for custom conditions to be created by specifying a new condition constructor. (default: new TopLevelConditionConstructor()) ### engine.addFact(String id, Function [definitionFunc], Object [options]) diff --git a/docs/rules.md b/docs/rules.md index 3a03749..2dca776 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -73,7 +73,7 @@ let rule = new Rule(options) **options.name** : `[Any]` A way of naming your rules, allowing them to be easily identifiable in [Rule Results](#rule-results). This is usually of type `String`, but could also be `Object`, `Array`, or `Number`. Note that the name need not be unique, and that it has no impact on execution of the rule. -**options.conditionConstructor**: [ConditionConstructor] - The condition constructor class to use when given condition options. Allows for custom conditions to be created by specifying a new condition constructor. (default: new ConditionConstructor) +**options.conditionConstructor**: [ConditionConstructor] - The condition constructor class to use when given condition options. Allows for custom conditions to be created by specifying a new condition constructor. (default: new TopLevelConditionConstructor()) ### setConditions(Array conditions) diff --git a/src/condition/condition-constructor.js b/src/condition/condition-constructor.js index 3b909c2..13a7419 100644 --- a/src/condition/condition-constructor.js +++ b/src/condition/condition-constructor.js @@ -30,3 +30,29 @@ export default class ConditionConstructor { return new ComparisonCondition(options) } } + +export class TopLevelConditionConstructor extends ConditionConstructor { + constructor (nestedConditionConstructor) { + super() + this.nestedConditionConstructor = nestedConditionConstructor || new ConditionConstructor() + } + + construct (options) { + if (!options) { + throw new Error('Condition: constructor options required') + } + if (options instanceof Condition) { + return options + } + if (Object.prototype.hasOwnProperty.call(options, 'any')) { + return new AnyCondition(options, this.nestedConditionConstructor) + } else if (Object.prototype.hasOwnProperty.call(options, 'all')) { + return new AllCondition(options, this.nestedConditionConstructor) + } else if (Object.prototype.hasOwnProperty.call(options, 'not')) { + return new NotCondition(options, this.nestedConditionConstructor) + } else if (Object.prototype.hasOwnProperty.call(options, 'condition')) { + return new ConditionReference(options) + } + throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"') + } +} diff --git a/src/condition/index.js b/src/condition/index.js index 1fb4325..2571bfd 100644 --- a/src/condition/index.js +++ b/src/condition/index.js @@ -1,6 +1,6 @@ import AllCondition from './all-condition' import AnyCondition from './any-condition' -import ConditionConstructor from './condition-constructor' +import ConditionConstructor, { TopLevelConditionConstructor } from './condition-constructor' import neverCondition from './never-condition' import ComparisonCondition from './comparison-condition' import ConditionReference from './condition-reference' @@ -15,5 +15,6 @@ export { neverCondition, ComparisonCondition, ConditionReference, - NotCondition + NotCondition, + TopLevelConditionConstructor } diff --git a/src/engine.js b/src/engine.js index b7b5b66..4e81fa9 100644 --- a/src/engine.js +++ b/src/engine.js @@ -7,7 +7,7 @@ import Almanac from './almanac' import EventEmitter from 'eventemitter2' import defaultOperators from './engine-default-operators' import debug from './debug' -import ConditionConstructor from './condition' +import { TopLevelConditionConstructor } from './condition' export const READY = 'READY' export const RUNNING = 'RUNNING' @@ -25,7 +25,7 @@ class Engine extends EventEmitter { this.allowUndefinedConditions = options.allowUndefinedConditions || false this.replaceFactsInEventParams = options.replaceFactsInEventParams || false this.pathResolver = options.pathResolver - this.conditionConstructor = options.conditionConstructor || new ConditionConstructor() + this.conditionConstructor = options.conditionConstructor || new TopLevelConditionConstructor() this.operators = new Map() this.facts = new Map() this.conditions = new Map() @@ -109,9 +109,6 @@ class Engine extends EventEmitter { setCondition (name, conditions) { if (!name) throw new Error('Engine: setCondition() requires name') if (!conditions) throw new Error('Engine: setCondition() requires conditions') - if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) { - throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"') - } this.conditions.set(name, this.conditionConstructor.construct(conditions)) return this } diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index 40dd9bc..4ffd9ca 100644 --- a/src/json-rules-engine.js +++ b/src/json-rules-engine.js @@ -2,9 +2,9 @@ import Engine from './engine' import Fact from './fact' import Rule from './rule' import Operator from './operator' -import ConditionConstructor, { Condition } from './condition' +import ConditionConstructor, { Condition, TopLevelConditionConstructor } from './condition' -export { Fact, Rule, Operator, Engine, ConditionConstructor, Condition } +export { Fact, Rule, Operator, Engine, ConditionConstructor, Condition, TopLevelConditionConstructor } export default function (rules, options) { return new Engine(rules, options) } diff --git a/src/rule.js b/src/rule.js index be7b3ef..40dd3be 100644 --- a/src/rule.js +++ b/src/rule.js @@ -2,7 +2,7 @@ import RuleResult from './rule-result' import EventEmitter from 'eventemitter2' -import ConditionConstructor, { neverCondition } from './condition' +import { neverCondition, TopLevelConditionConstructor } from './condition' class Rule extends EventEmitter { /** @@ -24,7 +24,7 @@ class Rule extends EventEmitter { if (options && options.conditionConstructor) { this.conditionConstructor = options.conditionConstructor } else { - this.conditionConstructor = new ConditionConstructor() + this.conditionConstructor = new TopLevelConditionConstructor() } if (options && options.conditions) { this.setConditions(options.conditions) @@ -74,16 +74,6 @@ class Rule extends EventEmitter { * @param {object} conditions - conditions, root element must be a boolean operator */ setConditions (conditions) { - if ( - !Object.prototype.hasOwnProperty.call(conditions, 'all') && - !Object.prototype.hasOwnProperty.call(conditions, 'any') && - !Object.prototype.hasOwnProperty.call(conditions, 'not') && - !Object.prototype.hasOwnProperty.call(conditions, 'condition') - ) { - throw new Error( - '"conditions" root must contain a single instance of "all", "any", "not", or "condition"' - ) - } this.conditions = this.conditionConstructor.construct(conditions) return this } diff --git a/test/engine-condition-constructor.test.js b/test/engine-condition-constructor.test.js index b50b98b..ea9ebcb 100644 --- a/test/engine-condition-constructor.test.js +++ b/test/engine-condition-constructor.test.js @@ -33,4 +33,9 @@ describe('Engine: conditionConstructor', () => { engine.addRule(ruleOpts) expect(conditionConstructor.construct).to.have.been.calledWith(ruleOpts.conditions) }) + + it('allows non-top-level conditions if the constructor will allow them.', () => { + const conditionOpts = { never: true } + expect(engine.setCondition.bind(engine, 'test', conditionOpts)).not.to.throw() + }) }) diff --git a/types/index.d.ts b/types/index.d.ts index fc4e4f3..e7eddda 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -26,7 +26,7 @@ export class Engine { removeRule(ruleOrName: Rule | string): boolean; updateRule(rule: Rule): void; - setCondition(name: string, conditions: TopLevelCondition): this; + setCondition(name: string, conditions: object | Condition): this; removeCondition(name: string): boolean; addOperator(operator: Operator): Map; @@ -137,10 +137,10 @@ export interface RuleResult { export class Rule implements RuleProperties { constructor(ruleProps: RuleProperties | string); name: string; - conditions: TopLevelCondition; + conditions: Condition; event: Event; priority: number; - setConditions(conditions: TopLevelCondition): this; + setConditions(conditions: object | Condition): this; setEvent(event: Event): this; setPriority(priority: number): this; toJSON(): string; @@ -165,6 +165,10 @@ export class ConditionConstructor { construct(options: object): Condition } +export class TopLevelConditionConstructor extends ConditionConstructor { + constructor(nestedConditionConstructor?: ConditionConstructor) +} + interface ConditionProperties { fact: string; operator: string;