Repository: CacheControl/json-rules-engine
Pull request #275 (2021-07-21 17:37:53)

Add "not" boolean operator
I've had difficulty expressing certain rules in a readable way without the ability to negate conditions.

For instance if I wanted to create a rule saying "trigger an event when there is not any condition which is true," I would instead have to express this as "trigger an event when all conditions are false." This means that I would have to write each condition as its logical negation, which potentially affects the readability of the condition.

These changes add in a new `not` boolean operator alongside `all` and `any` which negates the result of whatever single condition is nested inside it.

I believe this addresses issue https://github.com/CacheControl/json-rules-engine/issues/269.


Patch:
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/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;


Test patch:
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"/)
       })
     })
   })