Skip to content

Commit 919aba5

Browse files
authored
Merge pull request #275 from andycoopcode/add-not-condition
Add "not" boolean operator
2 parents 9e8c454 + 1b97403 commit 919aba5

9 files changed

+227
-27
lines changed

docs/rules.md

+14-5
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ let rule = new Rule({
134134

135135
See the [hello-world](../examples/01-hello-world.js) example.
136136

137-
### Boolean expressions: `all` and `any`
137+
### Boolean expressions: `all`, `any`, and `not`
138138

139-
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.
139+
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.
140140

141141
```js
142142
// all:
@@ -158,14 +158,23 @@ let rule = new Rule({
158158
{ /* condition 2 */ },
159159
{ /* condition n */ },
160160
{
161-
all: [ /* more conditions */ ]
161+
not: {
162+
all: [ /* more conditions */ ]
163+
}
162164
}
163165
]
164166
}
165167
})
168+
169+
// not:
170+
let rule = new Rule({
171+
conditions: {
172+
not: { /* condition */ }
173+
}
174+
})
166175
```
167176

168-
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.
177+
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.
169178

170179
### Condition helpers: `params`
171180

@@ -318,7 +327,7 @@ engine.on('failure', function(event, almanac, ruleResult) {
318327
319328
## Operators
320329
321-
Each rule condition must begin with a boolean operator(```all``` or ```any```) at its root.
330+
Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root.
322331
323332
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.
324333

examples/02-nested-boolean-logic.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ async function start () {
3838
operator: 'equal',
3939
value: 48
4040
}, {
41-
fact: 'personalFoulCount',
42-
operator: 'greaterThanInclusive',
43-
value: 6
41+
not: {
42+
fact: 'personalFoulCount',
43+
operator: 'lessThan',
44+
value: 6
45+
}
4446
}]
4547
}]
4648
},

src/condition.js

+18-10
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ export default class Condition {
1010
Object.assign(this, properties)
1111
if (booleanOperator) {
1212
const subConditions = properties[booleanOperator]
13-
if (!(Array.isArray(subConditions))) {
14-
throw new Error(`"${booleanOperator}" must be an array`)
15-
}
13+
const subConditionsIsArray = Array.isArray(subConditions)
14+
if (booleanOperator !== 'not' && !subConditionsIsArray) throw new Error(`"${booleanOperator}" must be an array`)
15+
if (booleanOperator === 'not' && subConditionsIsArray) throw new Error(`"${booleanOperator}" cannot be an array`)
1616
this.operator = booleanOperator
1717
// boolean conditions always have a priority; default 1
1818
this.priority = parseInt(properties.priority, 10) || 1
19-
this[booleanOperator] = subConditions.map((c) => {
20-
return new Condition(c)
21-
})
19+
if (subConditionsIsArray) {
20+
this[booleanOperator] = subConditions.map((c) => new Condition(c))
21+
} else {
22+
this[booleanOperator] = new Condition(subConditions)
23+
}
2224
} else {
2325
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) throw new Error('Condition: constructor "fact" property required')
2426
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) throw new Error('Condition: constructor "operator" property required')
@@ -44,7 +46,11 @@ export default class Condition {
4446
}
4547
const oper = Condition.booleanOperator(this)
4648
if (oper) {
47-
props[oper] = this[oper].map((c) => c.toJSON(stringify))
49+
if (Array.isArray(this[oper])) {
50+
props[oper] = this[oper].map((c) => c.toJSON(false))
51+
} else {
52+
props[oper] = this[oper].toJSON(false)
53+
}
4854
} else {
4955
props.operator = this.operator
5056
props.value = this.value
@@ -110,27 +116,29 @@ export default class Condition {
110116
/**
111117
* Returns the boolean operator for the condition
112118
* If the condition is not a boolean condition, the result will be 'undefined'
113-
* @return {string 'all' or 'any'}
119+
* @return {string 'all', 'any', or 'not'}
114120
*/
115121
static booleanOperator (condition) {
116122
if (Object.prototype.hasOwnProperty.call(condition, 'any')) {
117123
return 'any'
118124
} else if (Object.prototype.hasOwnProperty.call(condition, 'all')) {
119125
return 'all'
126+
} else if (Object.prototype.hasOwnProperty.call(condition, 'not')) {
127+
return 'not'
120128
}
121129
}
122130

123131
/**
124132
* Returns the condition's boolean operator
125133
* Instance version of Condition.isBooleanOperator
126-
* @returns {string,undefined} - 'any', 'all', or undefined (if not a boolean condition)
134+
* @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition)
127135
*/
128136
booleanOperator () {
129137
return Condition.booleanOperator(this)
130138
}
131139

132140
/**
133-
* Whether the operator is boolean ('all', 'any')
141+
* Whether the operator is boolean ('all', 'any', 'not')
134142
* @returns {Boolean}
135143
*/
136144
isBooleanOperator () {

src/rule.js

+27-7
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ class Rule extends EventEmitter {
7070
* @param {object} conditions - conditions, root element must be a boolean operator
7171
*/
7272
setConditions (conditions) {
73-
if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any')) {
74-
throw new Error('"conditions" root must contain a single instance of "all" or "any"')
73+
if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not')) {
74+
throw new Error('"conditions" root must contain a single instance of "all", "any", or "not"')
7575
}
7676
this.conditions = new Condition(conditions)
7777
return this
@@ -193,10 +193,12 @@ class Rule extends EventEmitter {
193193
let comparisonPromise
194194
if (condition.operator === 'all') {
195195
comparisonPromise = all(subConditions)
196-
} else {
196+
} else if (condition.operator === 'any') {
197197
comparisonPromise = any(subConditions)
198+
} else {
199+
comparisonPromise = not(subConditions)
198200
}
199-
// for booleans, rule passing is determined by the all/any result
201+
// for booleans, rule passing is determined by the all/any/not result
200202
return comparisonPromise.then(comparisonValue => {
201203
const passes = comparisonValue === true
202204
condition.result = passes
@@ -230,19 +232,25 @@ class Rule extends EventEmitter {
230232
}
231233

232234
/**
233-
* Evaluates a set of conditions based on an 'all' or 'any' operator.
235+
* Evaluates a set of conditions based on an 'all', 'any', or 'not' operator.
234236
* First, orders the top level conditions based on priority
235237
* Iterates over each priority set, evaluating each condition
236238
* If any condition results in the rule to be guaranteed truthy or falsey,
237239
* it will short-circuit and not bother evaluating any additional rules
238240
* @param {Condition[]} conditions - conditions to be evaluated
239-
* @param {string('all'|'any')} operator
241+
* @param {string('all'|'any'|'not')} operator
240242
* @return {Promise(boolean)} rule evaluation result
241243
*/
242244
const prioritizeAndRun = (conditions, operator) => {
243245
if (conditions.length === 0) {
244246
return Promise.resolve(true)
245247
}
248+
if (conditions.length === 1) {
249+
// no prioritizing is necessary, just evaluate the single condition
250+
// 'all' and 'any' will give the same results with a single condition so no method is necessary
251+
// this also covers the 'not' case which should only ever have a single condition
252+
return evaluateCondition(conditions[0])
253+
}
246254
let method = Array.prototype.some
247255
if (operator === 'all') {
248256
method = Array.prototype.every
@@ -292,6 +300,15 @@ class Rule extends EventEmitter {
292300
return prioritizeAndRun(conditions, 'all')
293301
}
294302

303+
/**
304+
* Runs a 'not' boolean operator on a single condition
305+
* @param {Condition} condition to be evaluated
306+
* @return {Promise(boolean)} condition evaluation result
307+
*/
308+
const not = (condition) => {
309+
return prioritizeAndRun([condition], 'not').then(result => !result)
310+
}
311+
295312
/**
296313
* Emits based on rule evaluation result, and decorates ruleResult with 'result' property
297314
* @param {RuleResult} ruleResult
@@ -305,9 +322,12 @@ class Rule extends EventEmitter {
305322
if (ruleResult.conditions.any) {
306323
return any(ruleResult.conditions.any)
307324
.then(result => processResult(result))
308-
} else {
325+
} else if (ruleResult.conditions.all) {
309326
return all(ruleResult.conditions.all)
310327
.then(result => processResult(result))
328+
} else {
329+
return not(ruleResult.conditions.not)
330+
.then(result => processResult(result))
311331
}
312332
}
313333
}

test/condition.test.js

+38
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ describe('Condition', () => {
5656
const json = condition.toJSON()
5757
expect(json).to.equal('{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}')
5858
})
59+
60+
it('converts "not" conditions', () => {
61+
const properties = {
62+
not: {
63+
...factories.condition({
64+
fact: 'age',
65+
value: {
66+
fact: 'weight',
67+
params: {
68+
unit: 'lbs'
69+
},
70+
path: '.value'
71+
}
72+
})
73+
}
74+
}
75+
const condition = new Condition(properties)
76+
const json = condition.toJSON()
77+
expect(json).to.equal('{"priority":1,"not":{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}}')
78+
})
5979
})
6080

6181
describe('evaluate', () => {
@@ -267,6 +287,24 @@ describe('Condition', () => {
267287
conditions.all = { foo: true }
268288
expect(() => new Condition(conditions)).to.throw(/"all" must be an array/)
269289
})
290+
291+
it('throws if is an array and condition is "not"', () => {
292+
const conditions = {
293+
not: [{ foo: true }]
294+
}
295+
expect(() => new Condition(conditions)).to.throw(/"not" cannot be an array/)
296+
})
297+
298+
it('does not throw if is not an array and condition is "not"', () => {
299+
const conditions = {
300+
not: {
301+
fact: 'foo',
302+
operator: 'equal',
303+
value: 'bar'
304+
}
305+
}
306+
expect(() => new Condition(conditions)).to.not.throw()
307+
})
270308
})
271309

272310
describe('atomic facts', () => {

test/engine-not.test.js

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use strict'
2+
3+
import sinon from 'sinon'
4+
import engineFactory from '../src/index'
5+
6+
describe('Engine: "not" conditions', () => {
7+
let engine
8+
let sandbox
9+
before(() => {
10+
sandbox = sinon.createSandbox()
11+
})
12+
afterEach(() => {
13+
sandbox.restore()
14+
})
15+
16+
describe('supports a single "not" condition', () => {
17+
const event = {
18+
type: 'ageTrigger',
19+
params: {
20+
demographic: 'under50'
21+
}
22+
}
23+
const conditions = {
24+
not: {
25+
fact: 'age',
26+
operator: 'greaterThanInclusive',
27+
value: 50
28+
}
29+
}
30+
let eventSpy
31+
let ageSpy
32+
beforeEach(() => {
33+
eventSpy = sandbox.spy()
34+
ageSpy = sandbox.stub()
35+
const rule = factories.rule({ conditions, event })
36+
engine = engineFactory()
37+
engine.addRule(rule)
38+
engine.addFact('age', ageSpy)
39+
engine.on('success', eventSpy)
40+
})
41+
42+
it('emits when the condition is met', async () => {
43+
ageSpy.returns(10)
44+
await engine.run()
45+
expect(eventSpy).to.have.been.calledWith(event)
46+
})
47+
48+
it('does not emit when the condition fails', () => {
49+
ageSpy.returns(75)
50+
engine.run()
51+
expect(eventSpy).to.not.have.been.calledWith(event)
52+
})
53+
})
54+
})

test/engine-recusive-rules.test.js

+68
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,72 @@ describe('Engine: recursive rules', () => {
162162
expect(eventSpy).to.not.have.been.calledOnce()
163163
})
164164
})
165+
166+
const notNotCondition = {
167+
not: {
168+
not: {
169+
fact: 'age',
170+
operator: 'lessThan',
171+
value: 65
172+
}
173+
}
174+
}
175+
176+
describe('"not" nested directly within a "not"', () => {
177+
it('evaluates true when facts pass rules', async () => {
178+
setup(notNotCondition)
179+
engine.addFact('age', 30)
180+
await engine.run()
181+
expect(eventSpy).to.have.been.calledOnce()
182+
})
183+
184+
it('evaluates false when facts do not pass rules', async () => {
185+
setup(notNotCondition)
186+
engine.addFact('age', 65)
187+
await engine.run()
188+
expect(eventSpy).to.not.have.been.calledOnce()
189+
})
190+
})
191+
192+
const nestedNotCondition = {
193+
not: {
194+
all: [
195+
{
196+
fact: 'age',
197+
operator: 'lessThan',
198+
value: 65
199+
},
200+
{
201+
fact: 'age',
202+
operator: 'greaterThan',
203+
value: 21
204+
},
205+
{
206+
not: {
207+
fact: 'income',
208+
operator: 'lessThanInclusive',
209+
value: 100
210+
}
211+
}
212+
]
213+
}
214+
}
215+
216+
describe('outer "not" with nested "all" and nested "not" condition', () => {
217+
it('evaluates true when facts pass rules', async () => {
218+
setup(nestedNotCondition)
219+
engine.addFact('age', 30)
220+
engine.addFact('income', 100)
221+
await engine.run()
222+
expect(eventSpy).to.have.been.calledOnce()
223+
})
224+
225+
it('evaluates false when facts do not pass rules', async () => {
226+
setup(nestedNotCondition)
227+
engine.addFact('age', 30)
228+
engine.addFact('income', 101)
229+
await engine.run()
230+
expect(eventSpy).to.not.have.been.calledOnce()
231+
})
232+
})
165233
})

0 commit comments

Comments
 (0)