From 597daa3882d4a7966f40aeaf883bf2da60171d9f Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Mon, 6 Nov 2023 00:19:27 +0200 Subject: [PATCH 1/3] Add Almanac addFact method The addFact method is stright from the engine class. It's able to either add a fact with a name and value / implementation or add a fact as a fact subclass. --- docs/almanac.md | 21 +++++++++++++++++++++ examples/07-rule-chaining.js | 6 +++--- src/almanac.js | 24 ++++++++++++++++++++++++ test/almanac.test.js | 25 +++++++++++++++++++++++++ types/index.d.ts | 6 ++++++ 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/docs/almanac.md b/docs/almanac.md index 3ceafad6..d0c38264 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) -> 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) -> Events[]](#almanacgeteventsstring-outcome---events) * [almanac.getResults() -> 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/examples/07-rule-chaining.js b/examples/07-rule-chaining.js index eb32df22..e086cbc5 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/src/almanac.js b/src/almanac.js index cc8fdda9..7c38f39b 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -103,8 +103,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/test/almanac.test.js b/test/almanac.test.js index d5381016..d8965b88 100644 --- a/test/almanac.test.js +++ b/test/almanac.test.js @@ -41,6 +41,31 @@ describe('Almanac', () => { }) }) + describe('addFact', () => { + it('supports runtime facts as key => values', () => { + 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() + almanac.addFact(fact) + return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value) + }) + }) + describe('addEvent() / getEvents()', () => { const event = {}; ['success', 'failure'].forEach(outcome => { diff --git a/types/index.d.ts b/types/index.d.ts index 35b380ab..c57423d9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -71,6 +71,12 @@ export class Almanac { params?: Record, path?: string ): Promise; + addFact(fact: Fact): this; + addFact( + id: string, + valueCallback: DynamicFactCallback | T, + options?: FactOptions + ): this; addRuntimeFact(factId: string, value: any): void; } From 25f9f7f0eca6149894bbe6494cb79835a0f67ece Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Mon, 6 Nov 2023 00:22:10 +0200 Subject: [PATCH 2/3] Move Almanac Initialization Move the Almanac Initialization into the engine class. This will allow it to take user supplied Almanacs correctly. --- src/almanac.js | 16 ++-------------- src/engine.js | 16 +++++++++++++++- test/almanac.test.js | 41 ++++++++++++----------------------------- test/condition.test.js | 21 ++++++++++++--------- 4 files changed, 41 insertions(+), 53 deletions(-) diff --git a/src/almanac.js b/src/almanac.js index 7c38f39b..58cc2432 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 } 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}>`) - } } /** diff --git a/src/engine.js b/src/engine.js index 70929c95..9536da6b 100644 --- a/src/engine.js +++ b/src/engine.js @@ -268,7 +268,21 @@ class Engine extends EventEmitter { allowUndefinedFacts: this.allowUndefinedFacts, pathResolver: this.pathResolver } - const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions) + const almanac = new Almanac(almanacOptions) + 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 orderedSets = this.prioritizeRules() let cursor = Promise.resolve() // for each rule set, evaluate in parallel, diff --git a/test/almanac.test.js b/test/almanac.test.js index d8965b88..b9701839 100644 --- a/test/almanac.test.js +++ b/test/almanac.test.js @@ -23,24 +23,12 @@ 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', () => { - it('supports runtime facts as key => values', () => { - almanac = new Almanac(new Map(), { fact1: 3 }) - return expect(almanac.factValue('fact1')).to.eventually.equal(3) - }) - - it('supports runtime fact instances', () => { - const fact = new Fact('fact1', 3) - almanac = new Almanac(new Map(), { fact1: fact }) - return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value) - }) - }) - describe('addFact', () => { it('supports runtime facts as key => values', () => { almanac = new Almanac() @@ -94,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 () => { @@ -131,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) }) }) @@ -149,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 @@ -179,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']) }) @@ -192,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 d4ab046d..cd1d8f54 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 From 3c1975e3bd3d4a754feac03e74227c528bfad632 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Mon, 6 Nov 2023 01:07:09 +0200 Subject: [PATCH 3/3] Support passing a custom Almanac Support for passing a custom almanac to the run options in the engine. --- docs/engine.md | 10 +++ examples/12-using-custom-almanac.js | 94 +++++++++++++++++++++++++++++ src/engine.js | 9 +-- src/json-rules-engine.js | 3 +- test/engine-run.test.js | 20 ++++++ types/index.d.ts | 14 ++++- 6 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 examples/12-using-custom-almanac.js diff --git a/docs/engine.md b/docs/engine.md index 9712b5e1..8546656d 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/12-using-custom-almanac.js b/examples/12-using-custom-almanac.js new file mode 100644 index 00000000..c94c3981 --- /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/engine.js b/src/engine.js index 9536da6b..c67f3a2b 100644 --- a/src/engine.js +++ b/src/engine.js @@ -261,14 +261,15 @@ 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 - } - const almanac = new Almanac(almanacOptions) + }) + this.facts.forEach(fact => { almanac.addFact(fact) }) diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index 339c3c06..6f3b149c 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/test/engine-run.test.js b/test/engine-run.test.js index 18b81559..a96d950a 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') + }) + }) + }) }) diff --git a/types/index.d.ts b/types/index.d.ts index c57423d9..c6ace7fc 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): Promise; + run(facts?: Record, runOptions?: RunOptions): Promise; stop(): this; } @@ -66,6 +73,7 @@ export class Operator { } export class Almanac { + constructor(options?: AlmanacOptions); factValue( factId: string, params?: Record,