diff --git a/docs/rules.md b/docs/rules.md index 20a3f55..187c230 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -134,9 +134,9 @@ let rule = new Rule({ See the [hello-world](../examples/01-hello-world.js) example. -### Boolean expressions: `all` and `any` +### Boolean expressions: `all`, `any`, and `not` -Each rule's conditions *must* have either an `all` or an `any` operator at its root, containing an array of conditions. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. +Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root or a `not` operator containing a single condition. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains. ```js // all: @@ -158,14 +158,23 @@ let rule = new Rule({ { /* condition 2 */ }, { /* condition n */ }, { - all: [ /* more conditions */ ] + not: { + all: [ /* more conditions */ ] + } } ] } }) + +// not: +let rule = new Rule({ + conditions: { + not: { /* condition */ } + } +}) ``` -Notice in the second example how `all` and `any` can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example. +Notice in the second example how `all`, `any`, and 'not' can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example. ### Condition helpers: `params` @@ -318,7 +327,7 @@ engine.on('failure', function(event, almanac, ruleResult) { ## Operators -Each rule condition must begin with a boolean operator(```all``` or ```any```) at its root. +Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root. The ```operator``` compares the value returned by the ```fact``` to what is stored in the ```value``` property. If the result is truthy, the condition passes. diff --git a/examples/02-nested-boolean-logic.js b/examples/02-nested-boolean-logic.js index 0dab6df..d5d3d67 100644 --- a/examples/02-nested-boolean-logic.js +++ b/examples/02-nested-boolean-logic.js @@ -38,9 +38,11 @@ async function start () { operator: 'equal', value: 48 }, { - fact: 'personalFoulCount', - operator: 'greaterThanInclusive', - value: 6 + not: { + fact: 'personalFoulCount', + operator: 'lessThan', + value: 6 + } }] }] }, diff --git a/src/condition.js b/src/condition.js index 98a7690..9163ce1 100644 --- a/src/condition.js +++ b/src/condition.js @@ -10,15 +10,17 @@ export default class Condition { Object.assign(this, properties) if (booleanOperator) { const subConditions = properties[booleanOperator] - if (!(Array.isArray(subConditions))) { - throw new Error(`"${booleanOperator}" must be an array`) - } + 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 - this[booleanOperator] = subConditions.map((c) => { - return new Condition(c) - }) + if (subConditionsIsArray) { + this[booleanOperator] = subConditions.map((c) => new Condition(c)) + } else { + this[booleanOperator] = new Condition(subConditions) + } } else { 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') @@ -44,7 +46,11 @@ export default class Condition { } const oper = Condition.booleanOperator(this) if (oper) { - props[oper] = this[oper].map((c) => c.toJSON(stringify)) + if (Array.isArray(this[oper])) { + props[oper] = this[oper].map((c) => c.toJSON(false)) + } else { + props[oper] = this[oper].toJSON(false) + } } else { props.operator = this.operator props.value = this.value @@ -110,27 +116,29 @@ export default class Condition { /** * Returns the boolean operator for the condition * If the condition is not a boolean condition, the result will be 'undefined' - * @return {string 'all' or 'any'} + * @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', or undefined (if not a boolean condition) + * @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') + * Whether the operator is boolean ('all', 'any', 'not') * @returns {Boolean} */ isBooleanOperator () { diff --git a/src/rule.js b/src/rule.js index 5db0191..c4f488b 100644 --- a/src/rule.js +++ b/src/rule.js @@ -70,8 +70,8 @@ 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')) { - throw new Error('"conditions" root must contain a single instance of "all" or "any"') + if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not')) { + throw new Error('"conditions" root must contain a single instance of "all", "any", or "not"') } this.conditions = new Condition(conditions) return this @@ -193,10 +193,12 @@ class Rule extends EventEmitter { let comparisonPromise if (condition.operator === 'all') { comparisonPromise = all(subConditions) - } else { + } else if (condition.operator === 'any') { comparisonPromise = any(subConditions) + } else { + comparisonPromise = not(subConditions) } - // for booleans, rule passing is determined by the all/any result + // for booleans, rule passing is determined by the all/any/not result return comparisonPromise.then(comparisonValue => { const passes = comparisonValue === true condition.result = passes @@ -230,19 +232,25 @@ class Rule extends EventEmitter { } /** - * Evaluates a set of conditions based on an 'all' or 'any' operator. + * 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')} operator + * @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 @@ -292,6 +300,15 @@ class Rule extends EventEmitter { 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) + } + /** * Emits based on rule evaluation result, and decorates ruleResult with 'result' property * @param {RuleResult} ruleResult @@ -305,9 +322,12 @@ class Rule extends EventEmitter { if (ruleResult.conditions.any) { return any(ruleResult.conditions.any) .then(result => processResult(result)) - } else { + } else if (ruleResult.conditions.all) { return all(ruleResult.conditions.all) .then(result => processResult(result)) + } else { + return not(ruleResult.conditions.not) + .then(result => processResult(result)) } } } diff --git a/test/condition.test.js b/test/condition.test.js index 248c6ed..7d3f314 100644 --- a/test/condition.test.js +++ b/test/condition.test.js @@ -56,6 +56,26 @@ describe('Condition', () => { const json = condition.toJSON() expect(json).to.equal('{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}') }) + + it('converts "not" conditions', () => { + const properties = { + not: { + ...factories.condition({ + fact: 'age', + value: { + fact: 'weight', + params: { + unit: 'lbs' + }, + path: '.value' + } + }) + } + } + const condition = new Condition(properties) + const json = condition.toJSON() + expect(json).to.equal('{"priority":1,"not":{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}}') + }) }) describe('evaluate', () => { @@ -267,6 +287,24 @@ describe('Condition', () => { conditions.all = { foo: true } expect(() => new Condition(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/) + }) + + it('does not throw if is not an array and condition is "not"', () => { + const conditions = { + not: { + fact: 'foo', + operator: 'equal', + value: 'bar' + } + } + expect(() => new Condition(conditions)).to.not.throw() + }) }) describe('atomic facts', () => { diff --git a/test/engine-not.test.js b/test/engine-not.test.js new file mode 100644 index 0000000..f83bed8 --- /dev/null +++ b/test/engine-not.test.js @@ -0,0 +1,54 @@ +'use strict' + +import sinon from 'sinon' +import engineFactory from '../src/index' + +describe('Engine: "not" conditions', () => { + let engine + let sandbox + before(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) + + describe('supports a single "not" condition', () => { + const event = { + type: 'ageTrigger', + params: { + demographic: 'under50' + } + } + const conditions = { + not: { + fact: 'age', + operator: 'greaterThanInclusive', + value: 50 + } + } + let eventSpy + let ageSpy + beforeEach(() => { + eventSpy = sandbox.spy() + ageSpy = sandbox.stub() + const rule = factories.rule({ conditions, event }) + engine = engineFactory() + engine.addRule(rule) + engine.addFact('age', ageSpy) + engine.on('success', eventSpy) + }) + + it('emits when the condition is met', async () => { + ageSpy.returns(10) + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('does not emit when the condition fails', () => { + ageSpy.returns(75) + engine.run() + expect(eventSpy).to.not.have.been.calledWith(event) + }) + }) +}) diff --git a/test/engine-recusive-rules.test.js b/test/engine-recusive-rules.test.js index 6816798..4893599 100644 --- a/test/engine-recusive-rules.test.js +++ b/test/engine-recusive-rules.test.js @@ -162,4 +162,72 @@ describe('Engine: recursive rules', () => { expect(eventSpy).to.not.have.been.calledOnce() }) }) + + const notNotCondition = { + not: { + not: { + fact: 'age', + operator: 'lessThan', + value: 65 + } + } + } + + describe('"not" nested directly within a "not"', () => { + it('evaluates true when facts pass rules', async () => { + setup(notNotCondition) + engine.addFact('age', 30) + await engine.run() + expect(eventSpy).to.have.been.calledOnce() + }) + + it('evaluates false when facts do not pass rules', async () => { + setup(notNotCondition) + engine.addFact('age', 65) + await engine.run() + expect(eventSpy).to.not.have.been.calledOnce() + }) + }) + + const nestedNotCondition = { + not: { + all: [ + { + fact: 'age', + operator: 'lessThan', + value: 65 + }, + { + fact: 'age', + operator: 'greaterThan', + value: 21 + }, + { + not: { + fact: 'income', + operator: 'lessThanInclusive', + value: 100 + } + } + ] + } + } + + describe('outer "not" with nested "all" and nested "not" condition', () => { + it('evaluates true when facts pass rules', async () => { + setup(nestedNotCondition) + engine.addFact('age', 30) + engine.addFact('income', 100) + await engine.run() + expect(eventSpy).to.have.been.calledOnce() + }) + + it('evaluates false when facts do not pass rules', async () => { + setup(nestedNotCondition) + engine.addFact('age', 30) + engine.addFact('income', 101) + await engine.run() + expect(eventSpy).to.not.have.been.calledOnce() + }) + }) }) diff --git a/test/rule.test.js b/test/rule.test.js index bc3fb6a..8ee08fe 100644 --- a/test/rule.test.js +++ b/test/rule.test.js @@ -109,7 +109,7 @@ describe('Rule', () => { describe('setConditions()', () => { describe('validations', () => { it('throws an exception for invalid root conditions', () => { - expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all" or "any"/) + expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", or "not"/) }) }) }) diff --git a/types/index.d.ts b/types/index.d.ts index b3d39e2..4128af6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -157,4 +157,5 @@ interface ConditionProperties { type NestedCondition = ConditionProperties | TopLevelCondition; type AllConditions = { all: NestedCondition[] }; type AnyConditions = { any: NestedCondition[] }; -export type TopLevelCondition = AllConditions | AnyConditions; +type NotConditions = { not: NestedCondition }; +export type TopLevelCondition = AllConditions | AnyConditions | NotConditions;