From a3c380723f9ada00509d76ca7d417b56acbbf28e Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 21 Aug 2023 14:12:06 -0400 Subject: [PATCH 1/2] Support Facts In Events Support event parameters to include facts this allows the events to reference values that are being stored in a persistent storage mechanism or otherwise unknown when the rules are written. --- docs/engine.md | 2 + docs/rules.md | 23 +++ examples/11-using-facts-in-events.js | 148 +++++++++++++++++++ src/almanac.js | 10 ++ src/condition.js | 52 ++++--- src/engine.js | 1 + src/rule-result.js | 18 +++ src/rule.js | 6 +- test/engine-event.test.js | 205 ++++++++++++++++++++++++++- 9 files changed, 435 insertions(+), 30 deletions(-) create mode 100644 examples/11-using-facts-in-events.js 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/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 ) } 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 () => { From 9a2beac2718a323f8fcd74ea558e23f576d33e66 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Wed, 23 Aug 2023 14:04:42 -0400 Subject: [PATCH 2/2] Upgrade Package Version to deploy minor change --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",