Repository: CacheControl/json-rules-engine
Pull request #24 (2017-01-21 22:32:57)

Allow undefined facts


Patch:
diff --git a/docs/engine.md b/docs/engine.md
index 98c033b..016abea 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -4,7 +4,7 @@ The Engine stores and executes rules, emits events, and maintains state.
 
 ## Methods
 
-### constructor([Array rules])
+### constructor([Array rules], Object [options])
 
 ```js
 let Engine = require('json-rules-engine').Engine
@@ -13,8 +13,20 @@ let engine = new Engine()
 
 // initialize with rules
 let engine = new Engine([Array rules])
+
+// initialize with options
+let options = {
+  allowUndefinedFacts: false
+};
+let engine = new Engine([Array rules], options)
 ```
 
+#### Options
+
+`allowUndefinedFacts` - By default, when a running engine encounters an undefined fact,
+an exception is thrown.  Turning this option on will cause the engine to treat
+undefined facts as falsey conditions.  (default: false)
+
 ### engine.addFact(String id, Function [definitionFunc], Object [options])
 
 ```js
diff --git a/docs/walkthrough.md b/docs/walkthrough.md
index 71fdc19..d0bf049 100644
--- a/docs/walkthrough.md
+++ b/docs/walkthrough.md
@@ -61,7 +61,9 @@ More on rules can be found [here](./docs/rules.md)
 
 ### Step 3: Define Facts
 
-Facts are constant values or pure functions.  Using the current example, if the engine were to be run, it would throw an error: "Undefined fact: 'age'".  So let's define some facts!
+Facts are constant values or pure functions.  Using the current example, if the engine were to be run, it would throw an exception: `Undefined fact:'age'` (note: this behavior can be disable via [engine options](./engine.md#Options)).
+
+Let's define some facts:
 
 ```js
 
diff --git a/package.json b/package.json
index 20a8378..28c09b6 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,8 @@
       "/dist"
     ],
     "globals": [
+      "context",
+      "xcontext",
       "describe",
       "xdescribe",
       "it",
diff --git a/src/almanac.js b/src/almanac.js
index 49c34d6..7fc91b4 100644
--- a/src/almanac.js
+++ b/src/almanac.js
@@ -4,6 +4,7 @@ let debug = require('debug')('json-rules-engine')
 let verbose = require('debug')('json-rules-engine-verbose')
 
 import Fact from './fact'
+import { UndefinedFactError } from './errors'
 
 /**
  * Fact results lookup
@@ -36,7 +37,7 @@ export default class Almanac {
   _getFact (factId) {
     let fact = this.factMap.get(factId)
     if (fact === undefined) {
-      throw new Error(`Undefined fact: ${factId}`)
+      throw new UndefinedFactError(`Undefined fact: ${factId}`)
     }
     return fact
   }
diff --git a/src/engine.js b/src/engine.js
index da6b909..b345381 100644
--- a/src/engine.js
+++ b/src/engine.js
@@ -20,9 +20,10 @@ class Engine extends EventEmitter {
    * Returns a new Engine instance
    * @param  {Rule[]} rules - array of rules to initialize with
    */
-  constructor (rules = []) {
+  constructor (rules = [], options = {}) {
     super()
     this.rules = []
+    this.allowUndefinedFacts = options.allowUndefinedFacts || false
     this.operators = new Map()
     this.facts = new Map()
     this.status = READY
diff --git a/src/errors.js b/src/errors.js
new file mode 100644
index 0000000..5f44df3
--- /dev/null
+++ b/src/errors.js
@@ -0,0 +1,8 @@
+'use strict'
+
+export class UndefinedFactError extends Error {
+  constructor (...props) {
+    super(...props)
+    this.code = 'UNDEFINED_FACT'
+  }
+}
diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js
index c0b8652..339c3c0 100644
--- a/src/json-rules-engine.js
+++ b/src/json-rules-engine.js
@@ -4,6 +4,6 @@ import Rule from './rule'
 import Operator from './operator'
 
 export { Fact, Rule, Operator, Engine }
-export default function (rules) {
-  return new Engine(rules)
+export default function (rules, options) {
+  return new Engine(rules, options)
 }
diff --git a/src/rule.js b/src/rule.js
index 6637e9e..0af1017 100644
--- a/src/rule.js
+++ b/src/rule.js
@@ -134,6 +134,7 @@ class Rule extends EventEmitter {
      */
     let evaluateCondition = async (condition) => {
       let comparisonValue
+      let passes
       if (condition.isBooleanOperator()) {
         let subConditions = condition[condition.operator]
         if (condition.operator === 'all') {
@@ -142,9 +143,16 @@ class Rule extends EventEmitter {
           comparisonValue = await any(subConditions)
         }
       } else {
-        comparisonValue = await almanac.factValue(condition.fact, condition.params)
+        try {
+          comparisonValue = await almanac.factValue(condition.fact, condition.params)
+        } catch (err) {
+          if (this.engine.allowUndefinedFacts && err.code === 'UNDEFINED_FACT') passes = false
+          else throw err
+        }
+      }
+      if (passes === undefined) {
+        passes = await condition.evaluate(comparisonValue, this.engine.operators)
       }
-      let passes = await condition.evaluate(comparisonValue, this.engine.operators)
       if (passes) {
         this.emit('success', this.event, almanac)
       } else {


Test patch:
diff --git a/test/engine-fact.test.js b/test/engine-fact.test.js
index 8630257..d701766 100644
--- a/test/engine-fact.test.js
+++ b/test/engine-fact.test.js
@@ -42,41 +42,90 @@ describe('Engine: fact evaluation', () => {
       demographic: 'under50'
     }
   }
-  let baseConditions = {
-    any: [{
-      fact: 'eligibilityField',
-      operator: 'lessThan',
-      params: {
-        eligibilityId: 1,
-        field: 'age'
-      },
-      value: 50
-    }]
+  function baseConditions () {
+    return {
+      any: [{
+        fact: 'eligibilityField',
+        operator: 'lessThan',
+        params: {
+          eligibilityId: 1,
+          field: 'age'
+        },
+        value: 50
+      }]
+    }
   }
-  let eventSpy = sinon.spy()
-  function setup (conditions = baseConditions) {
-    eventSpy.reset()
-    engine = engineFactory()
+  let successSpy = sinon.spy()
+  let failureSpy = sinon.spy()
+  function setup (conditions = baseConditions(), engineOptions = {}) {
+    successSpy.reset()
+    failureSpy.reset()
+    engine = engineFactory([], engineOptions)
     let rule = factories.rule({ conditions, event })
     engine.addRule(rule)
     engine.addFact('eligibilityField', eligibilityField)
     engine.addFact('eligibilityData', eligibilityData)
-    engine.on('success', eventSpy)
+    engine.on('success', successSpy)
+    engine.on('failure', failureSpy)
   }
 
+  describe('options', () => {
+    describe('options.allowUndefinedFacts', () => {
+      it('throws when fact is undefined by default', async () => {
+        let conditions = Object.assign({}, baseConditions())
+        conditions.any.push({
+          fact: 'undefined-fact',
+          operator: 'equal',
+          value: true
+        })
+        setup(conditions)
+        return expect(engine.run()).to.be.rejectedWith(/Undefined fact: undefined-fact/)
+      })
+
+      context('treats undefined facts as falsey when allowUndefinedFacts is set', () => {
+        it('emits "success" when the condition succeeds', async () => {
+          let conditions = Object.assign({}, baseConditions())
+          conditions.any.push({
+            fact: 'undefined-fact',
+            operator: 'equal',
+            value: true
+          })
+          setup(conditions, { allowUndefinedFacts: true })
+          await engine.run()
+          expect(successSpy).to.have.been.called
+          expect(failureSpy).to.not.have.been.called
+        })
+
+        it('emits "failure" when the condition fails', async () => {
+          let conditions = Object.assign({}, baseConditions())
+          conditions.any.push({
+            fact: 'undefined-fact',
+            operator: 'equal',
+            value: true
+          })
+          conditions.any[0].params.eligibilityId = 2
+          setup(conditions, { allowUndefinedFacts: true })
+          await engine.run()
+          expect(successSpy).to.not.have.been.called
+          expect(failureSpy).to.have.been.called
+        })
+      })
+    })
+  })
+
   describe('params', () => {
     it('emits when the condition is met', async () => {
       setup()
       await engine.run()
-      expect(eventSpy).to.have.been.calledWith(event)
+      expect(successSpy).to.have.been.calledWith(event)
     })
 
     it('does not emit when the condition fails', async () => {
-      let conditions = Object.assign({}, baseConditions)
+      let conditions = Object.assign({}, baseConditions())
       conditions.any[0].params.eligibilityId = 2
       setup(conditions)
       await engine.run()
-      expect(eventSpy).to.not.have.been.called
+      expect(successSpy).to.not.have.been.called
     })
   })
 
@@ -97,7 +146,7 @@ describe('Engine: fact evaluation', () => {
     it('emits when the condition is met', async () => {
       setup(conditions())
       await engine.run()
-      expect(eventSpy).to.have.been.calledWith(event)
+      expect(successSpy).to.have.been.calledWith(event)
     })
 
     it('does not emit when the condition fails', async () => {
@@ -105,7 +154,7 @@ describe('Engine: fact evaluation', () => {
       failureCondition.any[0].params.eligibilityId = 2
       setup(failureCondition)
       await engine.run()
-      expect(eventSpy).to.not.have.been.called
+      expect(successSpy).to.not.have.been.called
     })
 
     it('emits when complex object paths meet the conditions', async () => {
@@ -115,7 +164,7 @@ describe('Engine: fact evaluation', () => {
       complexCondition.any[0].operator = 'equal'
       setup(complexCondition)
       await engine.run()
-      expect(eventSpy).to.have.been.calledWith(event)
+      expect(successSpy).to.have.been.calledWith(event)
     })
 
     it('does not emit when complex object paths fail the condition', async () => {
@@ -125,7 +174,7 @@ describe('Engine: fact evaluation', () => {
       complexCondition.any[0].operator = 'equal'
       setup(complexCondition)
       await engine.run()
-      expect(eventSpy).to.not.have.been.calledWith(event)
+      expect(successSpy).to.not.have.been.calledWith(event)
     })
 
     it('treats invalid object paths as undefined', async () => {
@@ -135,7 +184,7 @@ describe('Engine: fact evaluation', () => {
       complexCondition.any[0].operator = 'equal'
       setup(complexCondition)
       await engine.run()
-      expect(eventSpy).to.have.been.calledWith(event)
+      expect(successSpy).to.have.been.calledWith(event)
     })
 
     it('ignores "path" when facts return non-objects', async () => {
@@ -145,7 +194,7 @@ describe('Engine: fact evaluation', () => {
       }
       engine.addFact('eligibilityData', eligibilityData)
       await engine.run()
-      expect(eventSpy).to.have.been.calledWith(event)
+      expect(successSpy).to.have.been.calledWith(event)
     })
   })
 
@@ -161,7 +210,7 @@ describe('Engine: fact evaluation', () => {
       }
       engine.addFact('eligibilityField', eligibilityField)
       await engine.run()
-      expect(eventSpy).to.have.been.called
+      expect(successSpy).to.have.been.called
     })
   })
 
@@ -173,7 +222,7 @@ describe('Engine: fact evaluation', () => {
       }
       engine.addFact('eligibilityField', eligibilityField)
       await engine.run()
-      expect(eventSpy).to.have.been.called
+      expect(successSpy).to.have.been.called
     })
 
     it('works with synchronous, non-promise evaluations that are falsey', async () => {
@@ -183,7 +232,7 @@ describe('Engine: fact evaluation', () => {
       }
       engine.addFact('eligibilityField', eligibilityField)
       await engine.run()
-      expect(eventSpy).to.not.have.been.called
+      expect(successSpy).to.not.have.been.called
     })
   })
 })