Repository: CacheControl/json-rules-engine
Pull request #345 (2023-08-21 18:22:18)

Support Facts In Events
This adds support via an engine option flag to allow event params to include reference to facts like values do.
The structure of this is that for event parameters the top-level keys may have the value: `{ "fact": "<fact id>" }` at which point they'll be converted into the actual fact value when the rule is evaluated. You can use path and param helpers the same as any other reference to a fact.

The primary use case for this is as a way to externaize values for the events into facts so they can be shared, or so they can change without the rule needing to change. Additionally moving away from needing event / rule specific handlers allows the rules to more completely configure the system for instance:
```js
const maxBidRule = {
   conditions: {
      all: [
         { condition: "desireable" },
         {
            fact: "remainingCash",
            operator: "greaterThanInclusive",
            value: { fact: "maxBid" }
         }
     ]
  },
  event: {
      type: "bid",
      params: {
          amount: { fact: "maxBid" }
      }
  }
}

const allInRule = {
      conditions: {
         all: [
            { condition: "desireable"  },
            {
                fact: "remainingCash",
                operator: "lessThan",
                value: { fact: "maxBid" }
            }
        ]
    },
   event: {
       type: "bid",
       params: { 
         amount: { fact: "remainingCash" }
       }
   }
 }
```

CacheControl: Will look at this today!

Patch:
diff --git a/docs/engine.md b/docs/engine.md
index 7665fa0..9712b5e 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -50,6 +50,8 @@ condition reference that cannot be resolved an exception is thrown. Turning
 this option on will cause the engine to treat unresolvable condition references
 as failed conditions. (default: false)
 
+`replaceFactsInEventParams` - By default when rules succeed or fail the events emitted are clones of the event in the rule declaration. When setting this option to true the parameters on the events will be have any fact references resolved. (default: false)
+
 `pathResolver` - Allows a custom object path resolution library to be used. (default: `json-path` syntax). See [custom path resolver](./rules.md#condition-helpers-custom-path-resolver) docs.
 
 ### engine.addFact(String id, Function [definitionFunc], Object [options])
diff --git a/docs/rules.md b/docs/rules.md
index 518989e..45b679e 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -349,6 +349,29 @@ engine.on('failure', function(event, almanac, ruleResult) {
 })
 ```
 
+### Referencing Facts In Events
+
+With the engine option [`replaceFactsInEventParams`](./engine.md#options) the parameters of the event may include references to facts in the same form as [Comparing Facts](#comparing-facts). These references will be replaced with the value of the fact before the event is emitted.
+
+```js
+const engine = new Engine([], { replaceFactsInEventParams: true });
+engine.addRule({
+    conditions: { /* ... */ },
+    event: {
+      type: "gameover",
+      params: {
+        initials: {
+          fact: "currentHighScore",
+          path: "$.initials",
+          params: { foo: 'bar' }
+        }
+      }
+    }
+  })
+```
+
+See [11-using-facts-in-events.js](../examples/11-using-facts-in-events.js) for a complete example.
+
 ## Operators
 
 Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root.
diff --git a/examples/11-using-facts-in-events.js b/examples/11-using-facts-in-events.js
new file mode 100644
index 0000000..1004e7e
--- /dev/null
+++ b/examples/11-using-facts-in-events.js
@@ -0,0 +1,148 @@
+'use strict'
+/*
+ * This is an advanced example demonstrating an event that emits the value
+ * of a fact in it's parameters.
+ *
+ * Usage:
+ *   node ./examples/11-using-facts-in-events.js
+ *
+ * For detailed output:
+ *   DEBUG=json-rules-engine node ./examples/11-using-facts-in-events.js
+ */
+
+require('colors')
+const { Engine, Fact } = require('json-rules-engine')
+
+async function start () {
+  /**
+   * Setup a new engine
+   */
+  const engine = new Engine([], { replaceFactsInEventParams: true })
+
+  // in-memory "database"
+  let currentHighScore = null
+  const currentHighScoreFact = new Fact('currentHighScore', () => currentHighScore)
+
+  /**
+   * Rule for when you've gotten the high score
+   * event will include your score and initials.
+   */
+  const highScoreRule = {
+    conditions: {
+      any: [
+        {
+          fact: 'currentHighScore',
+          operator: 'equal',
+          value: null
+        },
+        {
+          fact: 'score',
+          operator: 'greaterThan',
+          value: {
+            fact: 'currentHighScore',
+            path: '$.score'
+          }
+        }
+      ]
+    },
+    event: {
+      type: 'highscore',
+      params: {
+        initials: { fact: 'initials' },
+        score: { fact: 'score' }
+      }
+    }
+  }
+
+  /**
+   * Rule for when the game is over and you don't have the high score
+   * event will include the previous high score
+   */
+  const gameOverRule = {
+    conditions: {
+      all: [
+        {
+          fact: 'score',
+          operator: 'lessThanInclusive',
+          value: {
+            fact: 'currentHighScore',
+            path: '$.score'
+          }
+        }
+      ]
+    },
+    event: {
+      type: 'gameover',
+      params: {
+        initials: {
+          fact: 'currentHighScore',
+          path: '$.initials'
+        },
+        score: {
+          fact: 'currentHighScore',
+          path: '$.score'
+        }
+      }
+    }
+  }
+  engine.addRule(highScoreRule)
+  engine.addRule(gameOverRule)
+  engine.addFact(currentHighScoreFact)
+
+  /**
+   * Register listeners with the engine for rule success
+   */
+  engine
+    .on('success', async ({ params: { initials, score } }) => {
+      console.log(`HIGH SCORE\n${initials} - ${score}`)
+    })
+    .on('success', ({ type, params }) => {
+      if (type === 'highscore') {
+        currentHighScore = params
+      }
+    })
+
+  let facts = {
+    initials: 'DOG',
+    score: 968
+  }
+
+  // first run, without a high score
+  await engine.run(facts)
+
+  console.log('\n')
+
+  // new player
+  facts = {
+    initials: 'AAA',
+    score: 500
+  }
+
+  // new player hasn't gotten the high score yet
+  await engine.run(facts)
+
+  console.log('\n')
+
+  facts = {
+    initials: 'AAA',
+    score: 1000
+  }
+
+  // second run, with a high score
+  await engine.run(facts)
+}
+
+start()
+
+/*
+ * OUTPUT:
+ *
+ * NEW SCORE:
+ * DOG - 968
+ *
+ * HIGH SCORE:
+ * DOG - 968
+ *
+ * HIGH SCORE:
+ * AAA - 1000
+ */
diff --git a/package.json b/package.json
index 80c1ebe..3afb25a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "json-rules-engine",
-  "version": "6.3.1",
+  "version": "6.4.0",
   "description": "Rules Engine expressed in simple json",
   "main": "dist/index.js",
   "types": "types/index.d.ts",
diff --git a/src/almanac.js b/src/almanac.js
index 7a9a8e0..cc8fdda 100644
--- a/src/almanac.js
+++ b/src/almanac.js
@@ -162,4 +162,14 @@ export default class Almanac {
 
     return factValuePromise
   }
+
+  /**
+   * Interprets value as either a primitive, or if a fact, retrieves the fact value
+   */
+  getValue (value) {
+    if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' }
+      return this.factValue(value.fact, value.params, value.path)
+    }
+    return Promise.resolve(value)
+  }
 }
diff --git a/src/condition.js b/src/condition.js
index b5f8102..5f5775d 100644
--- a/src/condition.js
+++ b/src/condition.js
@@ -1,7 +1,6 @@
 'use strict'
 
 import debug from './debug'
-import isObjectLike from 'lodash.isobjectlike'
 
 export default class Condition {
   constructor (properties) {
@@ -11,8 +10,8 @@ export default class Condition {
     if (booleanOperator) {
       const subConditions = properties[booleanOperator]
       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`)
+      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
@@ -22,9 +21,9 @@ export default class Condition {
         this[booleanOperator] = new Condition(subConditions)
       }
     } else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
-      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')
-      if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required')
+      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') }
+      if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') }
 
       // a non-boolean condition does not have a priority by default. this allows
       // priority to be dictated by the fact definition
@@ -79,17 +78,6 @@ export default class Condition {
     return props
   }
 
-  /**
-   * Interprets .value as either a primitive, or if a fact, retrieves the fact value
-   */
-  _getValue (almanac) {
-    const value = this.value
-    if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value: { fact: 'xyz' }
-      return almanac.factValue(value.fact, value.params, value.path)
-    }
-    return Promise.resolve(value)
-  }
-
   /**
    * Takes the fact result and compares it to the condition 'value', using the operator
    *   LHS                      OPER       RHS
@@ -102,20 +90,28 @@ export default class Condition {
   evaluate (almanac, operatorMap) {
     if (!almanac) return Promise.reject(new Error('almanac required'))
     if (!operatorMap) return Promise.reject(new Error('operatorMap required'))
-    if (this.isBooleanOperator()) return Promise.reject(new Error('Cannot evaluate() a boolean condition'))
+    if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) }
 
     const op = operatorMap.get(this.operator)
-    if (!op) return Promise.reject(new Error(`Unknown operator: ${this.operator}`))
+    if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) }
 
-    return this._getValue(almanac) // todo - parallelize
-      .then(rightHandSideValue => {
-        return almanac.factValue(this.fact, this.params, this.path)
-          .then(leftHandSideValue => {
-            const result = op.evaluate(leftHandSideValue, rightHandSideValue)
-            debug(`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${this.operator} ${JSON.stringify(rightHandSideValue)}?> (${result})`)
-            return { result, leftHandSideValue, rightHandSideValue, operator: this.operator }
-          })
-      })
+    return Promise.all([
+      almanac.getValue(this.value),
+      almanac.factValue(this.fact, this.params, this.path)
+    ]).then(([rightHandSideValue, leftHandSideValue]) => {
+      const result = op.evaluate(leftHandSideValue, rightHandSideValue)
+      debug(
+        `condition::evaluate <${JSON.stringify(leftHandSideValue)} ${
+          this.operator
+        } ${JSON.stringify(rightHandSideValue)}?> (${result})`
+      )
+      return {
+        result,
+        leftHandSideValue,
+        rightHandSideValue,
+        operator: this.operator
+      }
+    })
   }
 
   /**
diff --git a/src/engine.js b/src/engine.js
index 6434d53..70929c9 100644
--- a/src/engine.js
+++ b/src/engine.js
@@ -23,6 +23,7 @@ class Engine extends EventEmitter {
     this.rules = []
     this.allowUndefinedFacts = options.allowUndefinedFacts || false
     this.allowUndefinedConditions = options.allowUndefinedConditions || false
+    this.replaceFactsInEventParams = options.replaceFactsInEventParams || false
     this.pathResolver = options.pathResolver
     this.operators = new Map()
     this.facts = new Map()
diff --git a/src/rule-result.js b/src/rule-result.js
index de16317..9512443 100644
--- a/src/rule-result.js
+++ b/src/rule-result.js
@@ -1,6 +1,7 @@
 'use strict'
 
 import deepClone from 'clone'
+import { isObject } from 'lodash'
 
 export default class RuleResult {
   constructor (conditions, event, priority, name) {
@@ -15,6 +16,23 @@ export default class RuleResult {
     this.result = result
   }
 
+  resolveEventParams (almanac) {
+    if (isObject(this.event.params)) {
+      const updates = []
+      for (const key in this.event.params) {
+        if (Object.prototype.hasOwnProperty.call(this.event.params, key)) {
+          updates.push(
+            almanac
+              .getValue(this.event.params[key])
+              .then((val) => (this.event.params[key] = val))
+          )
+        }
+      }
+      return Promise.all(updates)
+    }
+    return Promise.resolve()
+  }
+
   toJSON (stringify = true) {
     const props = {
       conditions: this.conditions.toJSON(false),
diff --git a/src/rule.js b/src/rule.js
index cf117c2..c893a14 100644
--- a/src/rule.js
+++ b/src/rule.js
@@ -367,8 +367,12 @@ class Rule extends EventEmitter {
      */
     const processResult = (result) => {
       ruleResult.setResult(result)
+      let processEvent = Promise.resolve()
+      if (this.engine.replaceFactsInEventParams) {
+        processEvent = ruleResult.resolveEventParams(almanac)
+      }
       const event = result ? 'success' : 'failure'
-      return this.emitAsync(event, ruleResult.event, almanac, ruleResult).then(
+      return processEvent.then(() => this.emitAsync(event, ruleResult.event, almanac, ruleResult)).then(
         () => ruleResult
       )
     }


Test patch:
diff --git a/test/engine-event.test.js b/test/engine-event.test.js
index 51b77de..c929d92 100644
--- a/test/engine-event.test.js
+++ b/test/engine-event.test.js
@@ -1,6 +1,6 @@
 'use strict'
 
-import engineFactory from '../src/index'
+import engineFactory, { Fact } from '../src/index'
 import Almanac from '../src/almanac'
 import sinon from 'sinon'
 
@@ -301,6 +301,110 @@ describe('Engine: event', () => {
     })
   })
 
+  context('engine events: with facts', () => {
+    const eventWithFact = {
+      type: 'countedEnough',
+      params: {
+        count: { fact: 'count' }
+      }
+    }
+
+    const expectedEvent = { type: 'countedEnough', params: { count: 5 } }
+
+    function setup (replaceFactsInEventParams, event = eventWithFact) {
+      const conditions = {
+        any: [
+          {
+            fact: 'success',
+            operator: 'equal',
+            value: true
+          }
+        ]
+      }
+
+      const ruleOptions = { conditions, event, priority: 100 }
+      const countedEnoughRule = factories.rule(ruleOptions)
+      engine = engineFactory([countedEnoughRule], {
+        replaceFactsInEventParams
+      })
+    }
+    context('without flag', () => {
+      beforeEach(() => setup(false))
+      it('"success" passes the event without resolved facts', async () => {
+        const successSpy = sandbox.spy()
+        engine.on('success', successSpy)
+        const { results } = await engine.run({ success: true, count: 5 })
+        expect(results[0].event).to.deep.equal(eventWithFact)
+        expect(successSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
+      })
+
+      it('failure passes the event without resolved facts', async () => {
+        const failureSpy = sandbox.spy()
+        engine.on('failure', failureSpy)
+        const { failureResults } = await engine.run({ success: false, count: 5 })
+        expect(failureResults[0].event).to.deep.equal(eventWithFact)
+        expect(failureSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
+      })
+    })
+    context('with flag', () => {
+      beforeEach(() => setup(true))
+      it('"success" passes the event with resolved facts', async () => {
+        const successSpy = sandbox.spy()
+        engine.on('success', successSpy)
+        const { results } = await engine.run({ success: true, count: 5 })
+        expect(results[0].event).to.deep.equal(expectedEvent)
+        expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+      })
+
+      it('failure passes the event with resolved facts', async () => {
+        const failureSpy = sandbox.spy()
+        engine.on('failure', failureSpy)
+        const { failureResults } = await engine.run({ success: false, count: 5 })
+        expect(failureResults[0].event).to.deep.equal(expectedEvent)
+        expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+      })
+      context('using fact params and path', () => {
+        const eventWithFactWithParamsAndPath = {
+          type: 'countedEnough',
+          params: {
+            count: {
+              fact: 'count',
+              params: { incrementBy: 5 },
+              path: '$.next'
+            }
+          }
+        }
+
+        beforeEach(() => {
+          setup(true, eventWithFactWithParamsAndPath)
+          engine.addFact(
+            new Fact('count', async ({ incrementBy }) => {
+              return {
+                previous: 0,
+                next: incrementBy
+              }
+            })
+          )
+        })
+        it('"success" passes the event with resolved facts', async () => {
+          const successSpy = sandbox.spy()
+          engine.on('success', successSpy)
+          const { results } = await engine.run({ success: true })
+          expect(results[0].event).to.deep.equal(expectedEvent)
+          expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+        })
+
+        it('failure passes the event with resolved facts', async () => {
+          const failureSpy = sandbox.spy()
+          engine.on('failure', failureSpy)
+          const { failureResults } = await engine.run({ success: false })
+          expect(failureResults[0].event).to.deep.equal(expectedEvent)
+          expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+        })
+      })
+    })
+  })
+
   context('rule events: simple', () => {
     beforeEach(() => simpleSetup())
 
@@ -386,6 +490,105 @@ describe('Engine: event', () => {
     })
   })
 
+  context('rule events: with facts', () => {
+    const expectedEvent = { type: 'countedEnough', params: { count: 5 } }
+    const eventWithFact = {
+      type: 'countedEnough',
+      params: {
+        count: { fact: 'count' }
+      }
+    }
+
+    function setup (replaceFactsInEventParams, event = eventWithFact) {
+      const conditions = {
+        any: [
+          {
+            fact: 'success',
+            operator: 'equal',
+            value: true
+          }
+        ]
+      }
+
+      const ruleOptions = { conditions, event, priority: 100 }
+      const countedEnoughRule = factories.rule(ruleOptions)
+      engine = engineFactory([countedEnoughRule], {
+        replaceFactsInEventParams
+      })
+    }
+    context('without flag', () => {
+      beforeEach(() => setup(false))
+      it('"success" passes the event without resolved facts', async () => {
+        const successSpy = sandbox.spy()
+        engine.rules[0].on('success', successSpy)
+        await engine.run({ success: true, count: 5 })
+        expect(successSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
+      })
+
+      it('failure passes the event without resolved facts', async () => {
+        const failureSpy = sandbox.spy()
+        engine.rules[0].on('failure', failureSpy)
+        await engine.run({ success: false, count: 5 })
+        expect(failureSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
+      })
+    })
+    context('with flag', () => {
+      beforeEach(() => setup(true))
+      it('"success" passes the event with resolved facts', async () => {
+        const successSpy = sandbox.spy()
+        engine.rules[0].on('success', successSpy)
+        await engine.run({ success: true, count: 5 })
+        expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+      })
+
+      it('failure passes the event with resolved facts', async () => {
+        const failureSpy = sandbox.spy()
+        engine.rules[0].on('failure', failureSpy)
+        await engine.run({ success: false, count: 5 })
+        expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+      })
+      context('using fact params and path', () => {
+        const eventWithFactWithParamsAndPath = {
+          type: 'countedEnough',
+          params: {
+            count: {
+              fact: 'count',
+              params: { incrementBy: 5 },
+              path: '$.next'
+            }
+          }
+        }
+
+        beforeEach(() => {
+          setup(true, eventWithFactWithParamsAndPath)
+          engine.addFact(
+            new Fact('count', async ({ incrementBy }) => {
+              return {
+                previous: 0,
+                next: incrementBy
+              }
+            })
+          )
+        })
+        it('"success" passes the event with resolved facts', async () => {
+          const successSpy = sandbox.spy()
+          engine.on('success', successSpy)
+          const { results } = await engine.run({ success: true })
+          expect(results[0].event).to.deep.equal(expectedEvent)
+          expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+        })
+
+        it('failure passes the event with resolved facts', async () => {
+          const failureSpy = sandbox.spy()
+          engine.on('failure', failureSpy)
+          const { failureResults } = await engine.run({ success: false })
+          expect(failureResults[0].event).to.deep.equal(expectedEvent)
+          expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
+        })
+      })
+    })
+  })
+
   context('rule events: json serializing', () => {
     beforeEach(() => simpleSetup())
     it('serializes properties', async () => {