Skip to content

Add "not" boolean operator #275

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand 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`

Expand Down Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions examples/02-nested-boolean-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ async function start () {
operator: 'equal',
value: 48
}, {
fact: 'personalFoulCount',
operator: 'greaterThanInclusive',
value: 6
not: {
fact: 'personalFoulCount',
operator: 'lessThan',
value: 6
}
}]
}]
},
Expand Down
28 changes: 18 additions & 10 deletions src/condition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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 () {
Expand Down
34 changes: 27 additions & 7 deletions src/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
}
}
}
Expand Down
38 changes: 38 additions & 0 deletions test/condition.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
54 changes: 54 additions & 0 deletions test/engine-not.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
68 changes: 68 additions & 0 deletions test/engine-recusive-rules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
Loading