Repository: CacheControl/json-rules-engine
Pull request #357 (2023-11-05 23:14:18)

Custom almanac
This is a generic suggestion to resolve #314

Rather than having the Almanac class be completely hidden except in event properties we move to having the Almanac be an option that can be passed into the run call. This allows for extensions of Almanacs that can do things that the standard Almanac can't do.
In order to support this properly a new addFact method was added to the Almanac that replaces the addRuntimeFact method but also allows dynamic facts to be added.


Patch:
diff --git a/docs/almanac.md b/docs/almanac.md
index 3ceafad..d0c3826 100644
--- a/docs/almanac.md
+++ b/docs/almanac.md
@@ -3,6 +3,7 @@
 * [Overview](#overview)
 * [Methods](#methods)
     * [almanac.factValue(Fact fact, Object params, String path) -&gt; Promise](#almanacfactvaluefact-fact-object-params-string-path---promise)
+    * [almanac.addFact(String id, Function [definitionFunc], Object [options])](#almanacaddfactstring-id-function-definitionfunc-object-options)
     * [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value)
     * [almanac.getEvents(String outcome) -&gt; Events[]](#almanacgeteventsstring-outcome---events)
     * [almanac.getResults() -&gt; RuleResults[]](#almanacgetresults---ruleresults)
@@ -33,8 +34,28 @@ almanac
   .then( value => console.log(value))
 ```
 
+### almanac.addFact(String id, Function [definitionFunc], Object [options])
+
+Sets a fact in the almanac. Used in conjunction with rule and engine event emissions.
+
+```js
+// constant facts:
+engine.addFact('speed-of-light', 299792458)
+
+// facts computed via function
+engine.addFact('account-type', function getAccountType(params, almanac) {
+  // ...
+})
+
+// facts with options:
+engine.addFact('account-type', function getAccountType(params, almanac) {
+  // ...
+}, { cache: false, priority: 500 })
+```
+
 ### almanac.addRuntimeFact(String factId, Mixed value)
 
+**Deprecated** Use `almanac.addFact` instead
 Sets a constant fact mid-run.  Often used in conjunction with rule and engine event emissions.
 
 ```js
diff --git a/docs/engine.md b/docs/engine.md
index 9712b5e..8546656 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -269,6 +269,16 @@ const {
 ```
 Link to the [Almanac documentation](./almanac.md)
 
+Optionally, you may specify a specific almanac instance via the almanac property.
+
+```js
+// create a custom Almanac
+const myCustomAlmanac = new CustomAlmanac();
+
+// run the engine with the custom almanac
+await engine.run({}, { almanac: myCustomAlmanac })
+```
+
 ### engine.stop() -> Engine
 
 Stops the rules engine from running the next priority set of Rules.  All remaining rules will be resolved as undefined,
diff --git a/examples/07-rule-chaining.js b/examples/07-rule-chaining.js
index eb32df2..e086cbc 100644
--- a/examples/07-rule-chaining.js
+++ b/examples/07-rule-chaining.js
@@ -39,16 +39,16 @@ async function start () {
     event: { type: 'drinks-screwdrivers' },
     priority: 10, // IMPORTANT!  Set a higher priority for the drinkRule, so it runs first
     onSuccess: async function (event, almanac) {
-      almanac.addRuntimeFact('screwdriverAficionado', true)
+      almanac.addFact('screwdriverAficionado', true)
 
       // asychronous operations can be performed within callbacks
       // engine execution will not proceed until the returned promises is resolved
       const accountId = await almanac.factValue('accountId')
       const accountInfo = await getAccountInformation(accountId)
-      almanac.addRuntimeFact('accountInfo', accountInfo)
+      almanac.addFact('accountInfo', accountInfo)
     },
     onFailure: function (event, almanac) {
-      almanac.addRuntimeFact('screwdriverAficionado', false)
+      almanac.addFact('screwdriverAficionado', false)
     }
   }
   engine.addRule(drinkRule)
diff --git a/examples/12-using-custom-almanac.js b/examples/12-using-custom-almanac.js
new file mode 100644
index 0000000..c94c398
--- /dev/null
+++ b/examples/12-using-custom-almanac.js
@@ -0,0 +1,94 @@
+'use strict'
+
+require('colors')
+const { Almanac, Engine } = require('json-rules-engine')
+
+/**
+ * Almanac that support piping values through named functions
+ */
+class PipedAlmanac extends Almanac {
+  constructor (options) {
+    super(options)
+    this.pipes = new Map()
+  }
+
+  addPipe (name, pipe) {
+    this.pipes.set(name, pipe)
+  }
+
+  factValue (factId, params, path) {
+    let pipes = []
+    if (params && 'pipes' in params && Array.isArray(params.pipes)) {
+      pipes = params.pipes
+      delete params.pipes
+    }
+    return super.factValue(factId, params, path).then(value => {
+      return pipes.reduce((value, pipeName) => {
+        const pipe = this.pipes.get(pipeName)
+        if (pipe) {
+          return pipe(value)
+        }
+        return value
+      }, value)
+    })
+  }
+}
+
+async function start () {
+  const engine = new Engine()
+    .addRule({
+      conditions: {
+        all: [
+          {
+            fact: 'age',
+            params: {
+              // the addOne pipe adds one to the value
+              pipes: ['addOne']
+            },
+            operator: 'greaterThanInclusive',
+            value: 21
+          }
+        ]
+      },
+      event: {
+        type: 'Over 21(ish)'
+      }
+    })
+
+  engine.on('success', async (event, almanac) => {
+    const name = await almanac.factValue('name')
+    const age = await almanac.factValue('age')
+    console.log(`${name} is ${age} years old and ${'is'.green} ${event.type}`)
+  })
+
+  engine.on('failure', async (event, almanac) => {
+    const name = await almanac.factValue('name')
+    const age = await almanac.factValue('age')
+    console.log(`${name} is ${age} years old and ${'is not'.red} ${event.type}`)
+  })
+
+  const createAlmanacWithPipes = () => {
+    const almanac = new PipedAlmanac()
+    almanac.addPipe('addOne', (v) => v + 1)
+    return almanac
+  }
+
+  // first run Bob who is less than 20
+  await engine.run({ name: 'Bob', age: 19 }, { almanac: createAlmanacWithPipes() })
+
+  // second run Alice who is 21
+  await engine.run({ name: 'Alice', age: 21 }, { almanac: createAlmanacWithPipes() })
+
+  // third run Chad who is 20
+  await engine.run({ name: 'Chad', age: 20 }, { almanac: createAlmanacWithPipes() })
+}
+
+start()
+
+/*
+ * OUTPUT:
+ *
+ * Bob is 19 years old and is not Over 21(ish)
+ * Alice is 21 years old and is Over 21(ish)
+ * Chad is 20 years old and is Over 21(ish)
+ */
diff --git a/src/almanac.js b/src/almanac.js
index cc8fdda..58cc243 100644
--- a/src/almanac.js
+++ b/src/almanac.js
@@ -17,25 +17,13 @@ function defaultPathResolver (value, path) {
  * A new almanac is used for every engine run()
  */
 export default class Almanac {
-  constructor (factMap, runtimeFacts = {}, options = {}) {
-    this.factMap = new Map(factMap)
+  constructor (options = {}) {
+    this.factMap = new Map()
     this.factResultsCache = new Map() // { cacheKey:  Promise<factValu> }
     this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts)
     this.pathResolver = options.pathResolver || defaultPathResolver
     this.events = { success: [], failure: [] }
     this.ruleResults = []
-
-    for (const factId in runtimeFacts) {
-      let fact
-      if (runtimeFacts[factId] instanceof Fact) {
-        fact = runtimeFacts[factId]
-      } else {
-        fact = new Fact(factId, runtimeFacts[factId])
-      }
-
-      this._addConstantFact(fact)
-      debug(`almanac::constructor initialized runtime fact:${fact.id} with ${fact.value}<${typeof fact.value}>`)
-    }
   }
 
   /**
@@ -103,8 +91,32 @@ export default class Almanac {
     return factValue
   }
 
+  /**
+   * Add a fact definition to the engine.  Facts are called by rules as they are evaluated.
+   * @param {object|Fact} id - fact identifier or instance of Fact
+   * @param {function} definitionFunc - function to be called when computing the fact value for a given rule
+   * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance
+   */
+  addFact (id, valueOrMethod, options) {
+    let factId = id
+    let fact
+    if (id instanceof Fact) {
+      factId = id.id
+      fact = id
+    } else {
+      fact = new Fact(id, valueOrMethod, options)
+    }
+    debug(`almanac::addFact id:${factId}`)
+    this.factMap.set(factId, fact)
+    if (fact.isConstant()) {
+      this._setFactValue(fact, {}, fact.value)
+    }
+    return this
+  }
+
   /**
    * Adds a constant fact during runtime.  Can be used mid-run() to add additional information
+   * @deprecated use addFact
    * @param {String} fact - fact identifier
    * @param {Mixed} value - constant value of the fact
    */
diff --git a/src/engine.js b/src/engine.js
index 70929c9..c67f3a2 100644
--- a/src/engine.js
+++ b/src/engine.js
@@ -261,14 +261,29 @@ class Engine extends EventEmitter {
    * @param  {Object} runOptions - run options
    * @return {Promise} resolves when the engine has completed running
    */
-  run (runtimeFacts = {}) {
+  run (runtimeFacts = {}, runOptions = {}) {
     debug('engine::run started')
     this.status = RUNNING
-    const almanacOptions = {
+
+    const almanac = runOptions.almanac || new Almanac({
       allowUndefinedFacts: this.allowUndefinedFacts,
       pathResolver: this.pathResolver
+    })
+
+    this.facts.forEach(fact => {
+      almanac.addFact(fact)
+    })
+    for (const factId in runtimeFacts) {
+      let fact
+      if (runtimeFacts[factId] instanceof Fact) {
+        fact = runtimeFacts[factId]
+      } else {
+        fact = new Fact(factId, runtimeFacts[factId])
+      }
+
+      almanac.addFact(fact)
+      debug(`engine::run initialized runtime fact:${fact.id} with ${fact.value}<${typeof fact.value}>`)
     }
-    const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions)
     const orderedSets = this.prioritizeRules()
     let cursor = Promise.resolve()
     // for each rule set, evaluate in parallel,
diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js
index 339c3c0..6f3b149 100644
--- a/src/json-rules-engine.js
+++ b/src/json-rules-engine.js
@@ -2,8 +2,9 @@ import Engine from './engine'
 import Fact from './fact'
 import Rule from './rule'
 import Operator from './operator'
+import Almanac from './almanac'
 
-export { Fact, Rule, Operator, Engine }
+export { Fact, Rule, Operator, Engine, Almanac }
 export default function (rules, options) {
   return new Engine(rules, options)
 }
diff --git a/types/index.d.ts b/types/index.d.ts
index 35b380a..c6ace7f 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,8 +1,15 @@
-export interface EngineOptions {
+export interface AlmanacOptions {
   allowUndefinedFacts?: boolean;
+  pathResolver?: PathResolver;
+}
+
+export interface EngineOptions extends AlmanacOptions {
   allowUndefinedConditions?: boolean;
   replaceFactsInEventParams?: boolean;
-  pathResolver?: PathResolver;
+}
+
+export interface RunOptions {
+  almanac?: Almanac;
 }
 
 export interface EngineResult {
@@ -48,7 +55,7 @@ export class Engine {
   on(eventName: "failure", handler: EventHandler): this;
   on(eventName: string, handler: EventHandler): this;
 
-  run(facts?: Record<string, any>): Promise<EngineResult>;
+  run(facts?: Record<string, any>, runOptions?: RunOptions): Promise<EngineResult>;
   stop(): this;
 }
 
@@ -66,11 +73,18 @@ export class Operator<A = unknown, B = unknown> {
 }
 
 export class Almanac {
+  constructor(options?: AlmanacOptions);
   factValue<T>(
     factId: string,
     params?: Record<string, any>,
     path?: string
   ): Promise<T>;
+  addFact<T>(fact: Fact<T>): this;
+  addFact<T>(
+    id: string,
+    valueCallback: DynamicFactCallback<T> | T,
+    options?: FactOptions
+  ): this;
   addRuntimeFact(factId: string, value: any): void;
 }
 


Test patch:
diff --git a/test/almanac.test.js b/test/almanac.test.js
index d538101..b970183 100644
--- a/test/almanac.test.js
+++ b/test/almanac.test.js
@@ -23,20 +23,33 @@ describe('Almanac', () => {
     })
 
     it('adds runtime facts', () => {
-      almanac = new Almanac(new Map(), { modelId: 'XYZ' })
+      almanac = new Almanac()
+      almanac.addFact('modelId', 'XYZ')
       expect(almanac.factMap.get('modelId').value).to.equal('XYZ')
     })
   })
 
-  describe('constructor', () => {
+  describe('addFact', () => {
     it('supports runtime facts as key => values', () => {
-      almanac = new Almanac(new Map(), { fact1: 3 })
+      almanac = new Almanac()
+      almanac.addFact('fact1', 3)
       return expect(almanac.factValue('fact1')).to.eventually.equal(3)
     })
 
+    it('supporrts runtime facts as dynamic callbacks', async () => {
+      almanac = new Almanac()
+      almanac.addFact('fact1', () => {
+        factSpy()
+        return Promise.resolve(3)
+      })
+      await expect(almanac.factValue('fact1')).to.eventually.equal(3)
+      await expect(factSpy).to.have.been.calledOnce()
+    })
+
     it('supports runtime fact instances', () => {
       const fact = new Fact('fact1', 3)
-      almanac = new Almanac(new Map(), { fact1: fact })
+      almanac = new Almanac()
+      almanac.addFact(fact)
       return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value)
     })
   })
@@ -69,9 +82,8 @@ describe('Almanac', () => {
         if (params.userId) return params.userId
         return 'unknown'
       })
-      const factMap = new Map()
-      factMap.set(fact.id, fact)
-      almanac = new Almanac(factMap)
+      almanac = new Almanac()
+      almanac.addFact(fact)
     })
 
     it('allows parameters to be passed to the fact', async () => {
@@ -106,10 +118,9 @@ describe('Almanac', () => {
 
   describe('_getFact', _ => {
     it('retrieves the fact object', () => {
-      const facts = new Map()
       const fact = new Fact('id', 1)
-      facts.set(fact.id, fact)
-      almanac = new Almanac(facts)
+      almanac = new Almanac()
+      almanac.addFact(fact)
       expect(almanac._getFact('id')).to.equal(fact)
     })
   })
@@ -124,9 +135,8 @@ describe('Almanac', () => {
 
     function setup (f = new Fact('id', 1)) {
       fact = f
-      const facts = new Map()
-      facts.set(fact.id, fact)
-      almanac = new Almanac(facts)
+      almanac = new Almanac()
+      almanac.addFact(fact)
     }
     let fact
     const FACT_VALUE = 2
@@ -154,9 +164,8 @@ describe('Almanac', () => {
           name: 'Thomas'
         }]
       })
-      const factMap = new Map()
-      factMap.set(fact.id, fact)
-      almanac = new Almanac(factMap)
+      almanac = new Almanac()
+      almanac.addFact(fact)
       const result = await almanac.factValue('foo', null, '$..name')
       expect(result).to.deep.equal(['George', 'Thomas'])
     })
@@ -167,9 +176,8 @@ describe('Almanac', () => {
           factSpy()
           return 'unknown'
         }, factOptions)
-        const factMap = new Map()
-        factMap.set(fact.id, fact)
-        almanac = new Almanac(factMap)
+        almanac = new Almanac()
+        almanac.addFact(fact)
       }
 
       it('evaluates the fact every time when fact caching is off', () => {
diff --git a/test/condition.test.js b/test/condition.test.js
index d4ab046..cd1d8f5 100644
--- a/test/condition.test.js
+++ b/test/condition.test.js
@@ -91,7 +91,8 @@ describe('Condition', () => {
       const properties = Object.assign({}, conditionBase, options)
       condition = new Condition(properties)
       const fact = new Fact(conditionBase.fact, factValue)
-      almanac = new Almanac(new Map([[fact.id, fact]]))
+      almanac = new Almanac()
+      almanac.addFact(fact)
     }
 
     context('validations', () => {
@@ -118,12 +119,14 @@ describe('Condition', () => {
     it('evaluates "equal" to check for undefined', async () => {
       condition = new Condition({ fact: 'age', operator: 'equal', value: undefined })
       let fact = new Fact('age', undefined)
-      almanac = new Almanac(new Map([[fact.id, fact]]))
+      almanac = new Almanac()
+      almanac.addFact(fact)
 
       expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
 
       fact = new Fact('age', 1)
-      almanac = new Almanac(new Map([[fact.id, fact]]))
+      almanac = new Almanac()
+      almanac.addFact(fact)
       expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
     })
 
@@ -235,8 +238,8 @@ describe('Condition', () => {
       it('extracts the object property values using its "path" property', async () => {
         const condition = new Condition({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 })
         const ageFact = new Fact('age', [{ id: 50 }, { id: 60 }])
-        const facts = new Map([[ageFact.id, ageFact]])
-        const almanac = new Almanac(facts)
+        const almanac = new Almanac()
+        almanac.addFact(ageFact)
         expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
 
         condition.value = 100 // negative case
@@ -245,8 +248,8 @@ describe('Condition', () => {
 
       it('ignores "path" when non-objects are returned by the fact', async () => {
         const ageFact = new Fact('age', 50)
-        const facts = new Map([[ageFact.id, ageFact]])
-        const almanac = new Almanac(facts)
+        const almanac = new Almanac()
+        almanac.addFact(ageFact)
 
         const condition = new Condition({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 })
         expect((await condition.evaluate(almanac, operators, 50)).result).to.equal(true)
@@ -273,8 +276,8 @@ describe('Condition', () => {
         }
 
         const usersFact = new Fact('users', userData)
-        const facts = new Map([[usersFact.id, usersFact]])
-        const almanac = new Almanac(facts)
+        const almanac = new Almanac()
+        almanac.addFact(usersFact)
         expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
 
         condition.value = 'work' // negative case
diff --git a/test/engine-run.test.js b/test/engine-run.test.js
index 18b8155..a96d950 100644
--- a/test/engine-run.test.js
+++ b/test/engine-run.test.js
@@ -113,4 +113,24 @@ describe('Engine: run', () => {
       })
     })
   })
+
+  describe('custom alamanc', () => {
+    class CapitalAlmanac extends Almanac {
+      factValue (factId, params, path) {
+        return super.factValue(factId, params, path).then(value => {
+          if (typeof value === 'string') {
+            return value.toUpperCase()
+          }
+          return value
+        })
+      }
+    }
+
+    it('returns the capitalized value when using the CapitalAlamanc', () => {
+      return engine.run({ greeting: 'hello', age: 30 }, { almanac: new CapitalAlmanac() }).then((results) => {
+        const fact = results.almanac.factValue('greeting')
+        return expect(fact).to.eventually.equal('HELLO')
+      })
+    })
+  })
 })