From 2a804117841add347d428912e5cc7dba911816fd Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sat, 5 Jun 2021 13:09:30 -0600 Subject: [PATCH 01/65] 6.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0c0e10d2..fedfdccc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.1.0", + "version": "6.1.1", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From beb656df2502c8716ffab9dc37dc134271b56506 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sat, 5 Jun 2021 13:14:08 -0600 Subject: [PATCH 02/65] 6.1.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bf12e1d8..e76dde79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.1.1", + "version": "6.1.2", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From 8485a040b5cd4ef82f0b89d498545b9fa6656c63 Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 10:48:07 -0400 Subject: [PATCH 03/65] Add tests for not condition --- test/engine-not.test.js | 54 ++++++++++++++++++++++++ test/engine-recusive-rules.test.js | 68 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 test/engine-not.test.js diff --git a/test/engine-not.test.js b/test/engine-not.test.js new file mode 100644 index 00000000..f83bed8a --- /dev/null +++ b/test/engine-not.test.js @@ -0,0 +1,54 @@ +'use strict' + +import sinon from 'sinon' +import engineFactory from '../src/index' + +describe('Engine: "not" conditions', () => { + let engine + let sandbox + before(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) + + describe('supports a single "not" condition', () => { + const event = { + type: 'ageTrigger', + params: { + demographic: 'under50' + } + } + const conditions = { + not: { + fact: 'age', + operator: 'greaterThanInclusive', + value: 50 + } + } + let eventSpy + let ageSpy + beforeEach(() => { + eventSpy = sandbox.spy() + ageSpy = sandbox.stub() + const rule = factories.rule({ conditions, event }) + engine = engineFactory() + engine.addRule(rule) + engine.addFact('age', ageSpy) + engine.on('success', eventSpy) + }) + + it('emits when the condition is met', async () => { + ageSpy.returns(10) + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('does not emit when the condition fails', () => { + ageSpy.returns(75) + engine.run() + expect(eventSpy).to.not.have.been.calledWith(event) + }) + }) +}) diff --git a/test/engine-recusive-rules.test.js b/test/engine-recusive-rules.test.js index 68167981..48935998 100644 --- a/test/engine-recusive-rules.test.js +++ b/test/engine-recusive-rules.test.js @@ -162,4 +162,72 @@ describe('Engine: recursive rules', () => { expect(eventSpy).to.not.have.been.calledOnce() }) }) + + const notNotCondition = { + not: { + not: { + fact: 'age', + operator: 'lessThan', + value: 65 + } + } + } + + describe('"not" nested directly within a "not"', () => { + it('evaluates true when facts pass rules', async () => { + setup(notNotCondition) + engine.addFact('age', 30) + await engine.run() + expect(eventSpy).to.have.been.calledOnce() + }) + + it('evaluates false when facts do not pass rules', async () => { + setup(notNotCondition) + engine.addFact('age', 65) + await engine.run() + expect(eventSpy).to.not.have.been.calledOnce() + }) + }) + + const nestedNotCondition = { + not: { + all: [ + { + fact: 'age', + operator: 'lessThan', + value: 65 + }, + { + fact: 'age', + operator: 'greaterThan', + value: 21 + }, + { + not: { + fact: 'income', + operator: 'lessThanInclusive', + value: 100 + } + } + ] + } + } + + describe('outer "not" with nested "all" and nested "not" condition', () => { + it('evaluates true when facts pass rules', async () => { + setup(nestedNotCondition) + engine.addFact('age', 30) + engine.addFact('income', 100) + await engine.run() + expect(eventSpy).to.have.been.calledOnce() + }) + + it('evaluates false when facts do not pass rules', async () => { + setup(nestedNotCondition) + engine.addFact('age', 30) + engine.addFact('income', 101) + await engine.run() + expect(eventSpy).to.not.have.been.calledOnce() + }) + }) }) From b6a8976c18c1b1a8998be3d7c0c9f30cc4799374 Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 10:48:44 -0400 Subject: [PATCH 04/65] Implement logic for evaluating not condition --- src/rule.js | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/rule.js b/src/rule.js index 5db01916..c4f488b3 100644 --- a/src/rule.js +++ b/src/rule.js @@ -70,8 +70,8 @@ class Rule extends EventEmitter { * @param {object} conditions - conditions, root element must be a boolean operator */ setConditions (conditions) { - if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any')) { - throw new Error('"conditions" root must contain a single instance of "all" or "any"') + if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not')) { + throw new Error('"conditions" root must contain a single instance of "all", "any", or "not"') } this.conditions = new Condition(conditions) return this @@ -193,10 +193,12 @@ class Rule extends EventEmitter { let comparisonPromise if (condition.operator === 'all') { comparisonPromise = all(subConditions) - } else { + } else if (condition.operator === 'any') { comparisonPromise = any(subConditions) + } else { + comparisonPromise = not(subConditions) } - // for booleans, rule passing is determined by the all/any result + // for booleans, rule passing is determined by the all/any/not result return comparisonPromise.then(comparisonValue => { const passes = comparisonValue === true condition.result = passes @@ -230,19 +232,25 @@ class Rule extends EventEmitter { } /** - * Evaluates a set of conditions based on an 'all' or 'any' operator. + * Evaluates a set of conditions based on an 'all', 'any', or 'not' operator. * First, orders the top level conditions based on priority * Iterates over each priority set, evaluating each condition * If any condition results in the rule to be guaranteed truthy or falsey, * it will short-circuit and not bother evaluating any additional rules * @param {Condition[]} conditions - conditions to be evaluated - * @param {string('all'|'any')} operator + * @param {string('all'|'any'|'not')} operator * @return {Promise(boolean)} rule evaluation result */ const prioritizeAndRun = (conditions, operator) => { if (conditions.length === 0) { return Promise.resolve(true) } + if (conditions.length === 1) { + // no prioritizing is necessary, just evaluate the single condition + // 'all' and 'any' will give the same results with a single condition so no method is necessary + // this also covers the 'not' case which should only ever have a single condition + return evaluateCondition(conditions[0]) + } let method = Array.prototype.some if (operator === 'all') { method = Array.prototype.every @@ -292,6 +300,15 @@ class Rule extends EventEmitter { return prioritizeAndRun(conditions, 'all') } + /** + * Runs a 'not' boolean operator on a single condition + * @param {Condition} condition to be evaluated + * @return {Promise(boolean)} condition evaluation result + */ + const not = (condition) => { + return prioritizeAndRun([condition], 'not').then(result => !result) + } + /** * Emits based on rule evaluation result, and decorates ruleResult with 'result' property * @param {RuleResult} ruleResult @@ -305,9 +322,12 @@ class Rule extends EventEmitter { if (ruleResult.conditions.any) { return any(ruleResult.conditions.any) .then(result => processResult(result)) - } else { + } else if (ruleResult.conditions.all) { return all(ruleResult.conditions.all) .then(result => processResult(result)) + } else { + return not(ruleResult.conditions.not) + .then(result => processResult(result)) } } } From 813281b89c51d54c88b4ebcd2aad22afe72c91de Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 10:49:09 -0400 Subject: [PATCH 05/65] Update test for invalid root conditions --- test/rule.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/rule.test.js b/test/rule.test.js index bc3fb6a6..8ee08fe2 100644 --- a/test/rule.test.js +++ b/test/rule.test.js @@ -109,7 +109,7 @@ describe('Rule', () => { describe('setConditions()', () => { describe('validations', () => { it('throws an exception for invalid root conditions', () => { - expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all" or "any"/) + expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", or "not"/) }) }) }) From e08bd3af17a300d26761525cb93f167cb6c104d7 Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 10:49:30 -0400 Subject: [PATCH 06/65] Account for 'not' condition in Conditions class --- src/condition.js | 28 ++++++++++++++++++---------- test/condition.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/condition.js b/src/condition.js index 98a76909..9163ce17 100644 --- a/src/condition.js +++ b/src/condition.js @@ -10,15 +10,17 @@ export default class Condition { Object.assign(this, properties) if (booleanOperator) { const subConditions = properties[booleanOperator] - if (!(Array.isArray(subConditions))) { - throw new Error(`"${booleanOperator}" must be an array`) - } + 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`) this.operator = booleanOperator // boolean conditions always have a priority; default 1 this.priority = parseInt(properties.priority, 10) || 1 - this[booleanOperator] = subConditions.map((c) => { - return new Condition(c) - }) + if (subConditionsIsArray) { + this[booleanOperator] = subConditions.map((c) => new Condition(c)) + } else { + this[booleanOperator] = new Condition(subConditions) + } } else { 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') @@ -44,7 +46,11 @@ export default class Condition { } const oper = Condition.booleanOperator(this) if (oper) { - props[oper] = this[oper].map((c) => c.toJSON(stringify)) + if (Array.isArray(this[oper])) { + props[oper] = this[oper].map((c) => c.toJSON(false)) + } else { + props[oper] = this[oper].toJSON(false) + } } else { props.operator = this.operator props.value = this.value @@ -110,27 +116,29 @@ export default class Condition { /** * Returns the boolean operator for the condition * If the condition is not a boolean condition, the result will be 'undefined' - * @return {string 'all' or 'any'} + * @return {string 'all', 'any', or 'not'} */ static booleanOperator (condition) { if (Object.prototype.hasOwnProperty.call(condition, 'any')) { return 'any' } else if (Object.prototype.hasOwnProperty.call(condition, 'all')) { return 'all' + } else if (Object.prototype.hasOwnProperty.call(condition, 'not')) { + return 'not' } } /** * Returns the condition's boolean operator * Instance version of Condition.isBooleanOperator - * @returns {string,undefined} - 'any', 'all', or undefined (if not a boolean condition) + * @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition) */ booleanOperator () { return Condition.booleanOperator(this) } /** - * Whether the operator is boolean ('all', 'any') + * Whether the operator is boolean ('all', 'any', 'not') * @returns {Boolean} */ isBooleanOperator () { diff --git a/test/condition.test.js b/test/condition.test.js index 248c6ed3..7d3f3141 100644 --- a/test/condition.test.js +++ b/test/condition.test.js @@ -56,6 +56,26 @@ describe('Condition', () => { const json = condition.toJSON() expect(json).to.equal('{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}') }) + + it('converts "not" conditions', () => { + const properties = { + not: { + ...factories.condition({ + fact: 'age', + value: { + fact: 'weight', + params: { + unit: 'lbs' + }, + path: '.value' + } + }) + } + } + const condition = new Condition(properties) + const json = condition.toJSON() + expect(json).to.equal('{"priority":1,"not":{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}}') + }) }) describe('evaluate', () => { @@ -267,6 +287,24 @@ describe('Condition', () => { conditions.all = { foo: true } expect(() => new Condition(conditions)).to.throw(/"all" must be an array/) }) + + it('throws if is an array and condition is "not"', () => { + const conditions = { + not: [{ foo: true }] + } + expect(() => new Condition(conditions)).to.throw(/"not" cannot be an array/) + }) + + it('does not throw if is not an array and condition is "not"', () => { + const conditions = { + not: { + fact: 'foo', + operator: 'equal', + value: 'bar' + } + } + expect(() => new Condition(conditions)).to.not.throw() + }) }) describe('atomic facts', () => { From 721410d36db8c3c99acbe3683bd86ef7ec77c549 Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 10:49:48 -0400 Subject: [PATCH 07/65] Add NotConditions type --- types/index.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index b3d39e2a..4128af69 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -157,4 +157,5 @@ interface ConditionProperties { type NestedCondition = ConditionProperties | TopLevelCondition; type AllConditions = { all: NestedCondition[] }; type AnyConditions = { any: NestedCondition[] }; -export type TopLevelCondition = AllConditions | AnyConditions; +type NotConditions = { not: NestedCondition }; +export type TopLevelCondition = AllConditions | AnyConditions | NotConditions; From 0132eb26d29fbe173e25ec2046ced4177685f1d6 Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 11:09:25 -0400 Subject: [PATCH 08/65] Update rules documentation with new condition --- docs/rules.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index 20a3f551..187c2301 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -134,9 +134,9 @@ let rule = new Rule({ See the [hello-world](../examples/01-hello-world.js) example. -### Boolean expressions: `all` and `any` +### Boolean expressions: `all`, `any`, and `not` -Each rule's conditions *must* have either an `all` or an `any` operator at its root, containing an array of conditions. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. +Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root or a `not` operator containing a single condition. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains. ```js // all: @@ -158,14 +158,23 @@ let rule = new Rule({ { /* condition 2 */ }, { /* condition n */ }, { - all: [ /* more conditions */ ] + not: { + all: [ /* more conditions */ ] + } } ] } }) + +// not: +let rule = new Rule({ + conditions: { + not: { /* condition */ } + } +}) ``` -Notice in the second example how `all` and `any` can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example. +Notice in the second example how `all`, `any`, and 'not' can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example. ### Condition helpers: `params` @@ -318,7 +327,7 @@ engine.on('failure', function(event, almanac, ruleResult) { ## Operators -Each rule condition must begin with a boolean operator(```all``` or ```any```) at its root. +Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root. The ```operator``` compares the value returned by the ```fact``` to what is stored in the ```value``` property. If the result is truthy, the condition passes. From 1b97403b00db3657264930aad4330752cb6881dc Mon Sep 17 00:00:00 2001 From: Andy Coop Date: Wed, 21 Jul 2021 11:09:46 -0400 Subject: [PATCH 09/65] Use 'not' condition in example 02 --- examples/02-nested-boolean-logic.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/02-nested-boolean-logic.js b/examples/02-nested-boolean-logic.js index 0dab6dfc..d5d3d671 100644 --- a/examples/02-nested-boolean-logic.js +++ b/examples/02-nested-boolean-logic.js @@ -38,9 +38,11 @@ async function start () { operator: 'equal', value: 48 }, { - fact: 'personalFoulCount', - operator: 'greaterThanInclusive', - value: 6 + not: { + fact: 'personalFoulCount', + operator: 'lessThan', + value: 6 + } }] }] }, From 30e270b19f6665fdce5af0663f7a7f72bf2d92d2 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Mon, 18 Oct 2021 01:05:34 -0500 Subject: [PATCH 10/65] Update almanac.md Minor style edits --- docs/almanac.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/almanac.md b/docs/almanac.md index a5d05a11..3ceafad6 100644 --- a/docs/almanac.md +++ b/docs/almanac.md @@ -14,8 +14,8 @@ ## Overview An almanac collects facts through an engine run cycle. As the engine computes fact values, -the results are stored in the almanac and cache'd. If the engine detects a fact computation has -been previously computed, it re-uses the cache'd result from the almanac. Every time ```engine.run()``` is invoked, +the results are stored in the almanac and cached. If the engine detects a fact computation has +been previously computed, it reuses the cached result from the almanac. Every time ```engine.run()``` is invoked, a new almanac is instantiated. The almanac for the current engine run is available as arguments passed to the fact evaluation methods and From b31b902f6a8f61781637bccfa940c11578591825 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Wed, 14 Jun 2023 12:25:32 -0400 Subject: [PATCH 11/65] Allow Named Conditions Allow Conditions to include names that are emitted as part of events and can be serialized to / from JSON Names are useful to provide human readable tags on the different conditions, especially complex ones made up of nested boolean conditions. --- examples/02-nested-boolean-logic.js | 6 +- src/condition.js | 3 + test/condition.test.js | 2 + test/engine-event.test.js | 92 ++++++++++++++++++----------- types/index.d.ts | 7 ++- 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/examples/02-nested-boolean-logic.js b/examples/02-nested-boolean-logic.js index d5d3d671..ce473abc 100644 --- a/examples/02-nested-boolean-logic.js +++ b/examples/02-nested-boolean-logic.js @@ -31,7 +31,8 @@ async function start () { fact: 'personalFoulCount', operator: 'greaterThanInclusive', value: 5 - }] + }], + name: "short foul limit" }, { all: [{ fact: 'gameDuration', @@ -43,7 +44,8 @@ async function start () { operator: 'lessThan', value: 6 } - }] + }], + name: "long foul limit" }] }, event: { // define the event to fire when the conditions evaluate truthy diff --git a/src/condition.js b/src/condition.js index 9163ce17..e7a37707 100644 --- a/src/condition.js +++ b/src/condition.js @@ -44,6 +44,9 @@ export default class Condition { if (this.priority) { props.priority = this.priority } + if (this.name) { + props.name = this.name + } const oper = Condition.booleanOperator(this) if (oper) { if (Array.isArray(this[oper])) { diff --git a/test/condition.test.js b/test/condition.test.js index 7d3f3141..f7499212 100644 --- a/test/condition.test.js +++ b/test/condition.test.js @@ -12,6 +12,7 @@ function condition () { return { all: [{ id: '6ed20017-375f-40c9-a1d2-6d7e0f4733c5', + name: "team participation in form", fact: 'team_participation', operator: 'equal', value: 50, @@ -29,6 +30,7 @@ describe('Condition', () => { expect(subject).to.have.property('operator') expect(subject).to.have.property('value') expect(subject).to.have.property('path') + expect(subject).to.have.property('name'); }) it('boolean conditions have properties', () => { diff --git a/test/engine-event.test.js b/test/engine-event.test.js index 3b1ce8ff..51b77de3 100644 --- a/test/engine-event.test.js +++ b/test/engine-event.test.js @@ -25,15 +25,19 @@ describe('Engine: event', () => { */ function simpleSetup () { const conditions = { - any: [{ - fact: 'age', - operator: 'greaterThanInclusive', - value: 21 - }, { - fact: 'qualified', - operator: 'equal', - value: true - }] + any: [ + { + name: 'over 21', + fact: 'age', + operator: 'greaterThanInclusive', + value: 21 + }, + { + fact: 'qualified', + operator: 'equal', + value: true + } + ] } engine = engineFactory() const ruleOptions = { conditions, event, priority: 100 } @@ -50,25 +54,32 @@ describe('Engine: event', () => { */ function advancedSetup () { const conditions = { - any: [{ - fact: 'age', - operator: 'greaterThanInclusive', - value: 21 - }, { - fact: 'qualified', - operator: 'equal', - value: true - }, { - all: [{ - fact: 'zipCode', - operator: 'in', - value: [80211, 80403] - }, { - fact: 'gender', - operator: 'notEqual', - value: 'female' - }] - }] + any: [ + { + fact: 'age', + operator: 'greaterThanInclusive', + value: 21 + }, + { + fact: 'qualified', + operator: 'equal', + value: true + }, + { + all: [ + { + fact: 'zipCode', + operator: 'in', + value: [80211, 80403] + }, + { + fact: 'gender', + operator: 'notEqual', + value: 'female' + } + ] + } + ] } engine = engineFactory() const ruleOptions = { conditions, event, priority: 100 } @@ -91,6 +102,7 @@ describe('Engine: event', () => { expect(ruleResult.result).to.be.true() expect(ruleResult.conditions.any[0].result).to.be.true() expect(ruleResult.conditions.any[0].factResult).to.equal(21) + expect(ruleResult.conditions.any[0].name).to.equal('over 21') expect(ruleResult.conditions.any[1].result).to.be.false() expect(ruleResult.conditions.any[1].factResult).to.equal(false) } @@ -177,11 +189,13 @@ describe('Engine: event', () => { params: drinkOrderParams } const drinkOrderConditions = { - any: [{ - fact: 'canOrderDrinks', - operator: 'equal', - value: true - }] + any: [ + { + fact: 'canOrderDrinks', + operator: 'equal', + value: true + } + ] } const drinkOrderRule = factories.rule({ conditions: drinkOrderConditions, @@ -193,7 +207,10 @@ describe('Engine: event', () => { engine.on('success', function (event, almanac, ruleResult) { switch (event.type) { case 'setDrinkingFlag': - almanac.addRuntimeFact('canOrderDrinks', event.params.canOrderDrinks) + almanac.addRuntimeFact( + 'canOrderDrinks', + event.params.canOrderDrinks + ) break case 'offerDrink': expect(event.params).to.eql(drinkOrderParams) @@ -257,7 +274,9 @@ describe('Engine: event', () => { expect(ruleResult.conditions.any[1].factResult).to.equal(false) expect(ruleResult.conditions.any[2].result).to.be.false() expect(ruleResult.conditions.any[2].all[0].result).to.be.false() - expect(ruleResult.conditions.any[2].all[0].factResult).to.equal(ZIP_CODE) + expect(ruleResult.conditions.any[2].all[0].factResult).to.equal( + ZIP_CODE + ) expect(ruleResult.conditions.any[2].all[1].result).to.be.false() expect(ruleResult.conditions.any[2].all[1].factResult).to.equal(GENDER) } @@ -375,7 +394,8 @@ describe('Engine: event', () => { rule.on('success', successSpy) await engine.run() const ruleResult = successSpy.getCall(0).args[2] - const expected = '{"conditions":{"priority":1,"any":[{"operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}' + const expected = + '{"conditions":{"priority":1,"any":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}' expect(JSON.stringify(ruleResult)).to.equal(expected) }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 4128af69..3cf93c0b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -152,10 +152,11 @@ interface ConditionProperties { path?: string; priority?: number; params?: Record; + name?: string; } type NestedCondition = ConditionProperties | TopLevelCondition; -type AllConditions = { all: NestedCondition[] }; -type AnyConditions = { any: NestedCondition[] }; -type NotConditions = { not: NestedCondition }; +type AllConditions = { all: NestedCondition[]; name?: string; priority?: number; }; +type AnyConditions = { any: NestedCondition[]; name?: string; priority?: number; }; +type NotConditions = { not: NestedCondition; name?: string; priority?: number; }; export type TopLevelCondition = AllConditions | AnyConditions | NotConditions; From 3b1a68c993e3a10441088de0d5e6fdaa5a22385b Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Fri, 30 Jun 2023 11:07:09 -0400 Subject: [PATCH 12/65] Upgrades to Dependencies Upgrades to HashIt and JSON Path Plus. These libraries are major versions out-of date. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e76dde79..334aefda 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,8 @@ "dependencies": { "clone": "^2.1.2", "eventemitter2": "^6.4.4", - "hash-it": "^5.0.0", - "jsonpath-plus": "^5.0.7", + "hash-it": "^6.0.0", + "jsonpath-plus": "^7.2.0", "lodash.isobjectlike": "^4.0.0" } } From 8f7c4facadb8a23697646cc601d1772a6e55b0ff Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Fri, 30 Jun 2023 17:57:36 -0400 Subject: [PATCH 13/65] Bump Package Version to 6.2.0 --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 334aefda..343a45a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.1.2", + "version": "6.2.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -55,6 +55,9 @@ ] }, "author": "Cache Hamm ", + "contributors": [ + "Chris Pardy " + ], "license": "ISC", "bugs": { "url": "https://github.com/cachecontrol/json-rules-engine/issues" From 159c0808c641f0fb44df1dbf524e28eb2bd22032 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Mon, 3 Jul 2023 09:17:20 -0600 Subject: [PATCH 14/65] Decrease lower bound on performance tests --- test/performance.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/performance.test.js b/test/performance.test.js index 6af67ac1..7a0ce6d4 100644 --- a/test/performance.test.js +++ b/test/performance.test.js @@ -47,7 +47,7 @@ describe('Performance', () => { perfy.start('any') await engine.run() const result = perfy.end('any') - expect(result.time).to.be.greaterThan(0.02) + expect(result.time).to.be.greaterThan(0.001) expect(result.time).to.be.lessThan(0.5) }) @@ -59,7 +59,7 @@ describe('Performance', () => { perfy.start('all') await engine.run() const result = perfy.end('all') - expect(result.time).to.be.greaterThan(0.01) // assert lower value + expect(result.time).to.be.greaterThan(0.001) // assert lower value expect(result.time).to.be.lessThan(0.5) }) }) From 5e3d0ed77083062809130da7bf4f3f937418bd98 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Mon, 3 Jul 2023 09:18:24 -0600 Subject: [PATCH 15/65] lint --- examples/02-nested-boolean-logic.js | 4 ++-- test/condition.test.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/02-nested-boolean-logic.js b/examples/02-nested-boolean-logic.js index ce473abc..8fcc0446 100644 --- a/examples/02-nested-boolean-logic.js +++ b/examples/02-nested-boolean-logic.js @@ -32,7 +32,7 @@ async function start () { operator: 'greaterThanInclusive', value: 5 }], - name: "short foul limit" + name: 'short foul limit' }, { all: [{ fact: 'gameDuration', @@ -45,7 +45,7 @@ async function start () { value: 6 } }], - name: "long foul limit" + name: 'long foul limit' }] }, event: { // define the event to fire when the conditions evaluate truthy diff --git a/test/condition.test.js b/test/condition.test.js index f7499212..d4ab046d 100644 --- a/test/condition.test.js +++ b/test/condition.test.js @@ -12,7 +12,7 @@ function condition () { return { all: [{ id: '6ed20017-375f-40c9-a1d2-6d7e0f4733c5', - name: "team participation in form", + name: 'team participation in form', fact: 'team_participation', operator: 'equal', value: 50, @@ -30,7 +30,7 @@ describe('Condition', () => { expect(subject).to.have.property('operator') expect(subject).to.have.property('value') expect(subject).to.have.property('path') - expect(subject).to.have.property('name'); + expect(subject).to.have.property('name') }) it('boolean conditions have properties', () => { From e11171a23de39da7c892c9bbfa78cf093139c763 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Mon, 3 Jul 2023 09:18:42 -0600 Subject: [PATCH 16/65] npm publishing actions workflow --- .github/workflows/node.js.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index d842635b..37e653e7 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -27,3 +27,7 @@ jobs: - run: npm install - run: npm run build --if-present - run: npm test + # https://github.com/marketplace/actions/npm-publish + - uses: JS-DevTools/npm-publish@v2 + with: + token: ${{ secrets.NPM_TOKEN }} From 8e1cf471877dd61a56334a53136dd927068a9b6a Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Mon, 3 Jul 2023 09:30:53 -0600 Subject: [PATCH 17/65] run actions on latest lts version of node --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 37e653e7..0db88d40 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x] + node-version: [18.x] steps: - uses: actions/checkout@v2 From 560802b789e0cd26cf7ab8ac2d04dce52420eed9 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 3 Jul 2023 14:21:27 -0400 Subject: [PATCH 18/65] Add Linting to CI, split deployment Add Linting to the CI and move deployment out of the on pull request bit so it doesn't trigger on pull requests. --- .github/workflows/deploy.yml | 42 +++++++++++++++++++++++++++++++++++ .github/workflows/node.js.yml | 9 ++------ 2 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..646a2d96 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,42 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Deploy Package + +on: + push: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x, 18.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm run build --if-present + - run: npm run lint + - run: npm test + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 18.x + - run: npm install + - run: npm run build --if-present + # https://github.com/marketplace/actions/npm-publish + - uses: JS-DevTools/npm-publish@v2 + with: + token: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0db88d40..1ad05a66 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -4,8 +4,6 @@ name: Node.js CI on: - push: - branches: [ master, next-major ] pull_request: branches: [ master, next-major ] @@ -16,7 +14,7 @@ jobs: strategy: matrix: - node-version: [18.x] + node-version: [14.x, 16.x, 18.x] steps: - uses: actions/checkout@v2 @@ -26,8 +24,5 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install - run: npm run build --if-present + - run: npm run lint - run: npm test - # https://github.com/marketplace/actions/npm-publish - - uses: JS-DevTools/npm-publish@v2 - with: - token: ${{ secrets.NPM_TOKEN }} From 487fb7df20d9ca756c86ab17b8eebb11f62fe7ba Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 10 Jul 2023 12:58:01 -0400 Subject: [PATCH 19/65] Fix link to Boolean Expressions fix the link to the boolean expressions that broke after not was introduced. --- docs/rules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules.md b/docs/rules.md index 187c2301..d17dc416 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -14,7 +14,7 @@ Rules contain a set of _conditions_ and a single _event_. When the engine is ru * [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true) * [Conditions](#conditions) * [Basic conditions](#basic-conditions) - * [Boolean expressions: all and any](#boolean-expressions-all-and-any) + * [Boolean expressions: all, any, and not](#boolean-expressions-all-any-and-not) * [Condition helpers: params](#condition-helpers-params) * [Condition helpers: path](#condition-helpers-path) * [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver) From 1d5d3045299cd70762620cbf6acccd075718e4d6 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 10 Jul 2023 12:58:44 -0400 Subject: [PATCH 20/65] Point package.json for examples as the parent This will allow you to npm install examples and actually run them. --- examples/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/package.json b/examples/package.json index 2af5d119..1758ee5e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,6 @@ "author": "Cache Hamm ", "license": "ISC", "dependencies": { - "json-rules-engine": "6.0.0-alpha-3" + "json-rules-engine": "../" } } From 96f5c17e185adfc078aaa5395eac46aeed0e5881 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 10 Jul 2023 13:01:39 -0400 Subject: [PATCH 21/65] Add Condition / ConditionReference Support Add Support for Conditions which are added to the engine and then referenced in various rules. Condition references can be used as top-level conditions in a rule and can be used recursively. --- src/condition.js | 12 +- src/engine.js | 28 ++++ src/rule.js | 26 +++- test/engine-condition.test.js | 280 ++++++++++++++++++++++++++++++++++ test/rule.test.js | 2 +- types/index.d.ts | 34 ++++- 6 files changed, 369 insertions(+), 13 deletions(-) create mode 100644 test/engine-condition.test.js diff --git a/src/condition.js b/src/condition.js index e7a37707..b5f81024 100644 --- a/src/condition.js +++ b/src/condition.js @@ -21,7 +21,7 @@ export default class Condition { } else { this[booleanOperator] = new Condition(subConditions) } - } else { + } 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') @@ -54,6 +54,8 @@ export default class Condition { } else { props[oper] = this[oper].toJSON(false) } + } else if (this.isConditionReference()) { + props.condition = this.condition } else { props.operator = this.operator props.value = this.value @@ -147,4 +149,12 @@ export default class Condition { isBooleanOperator () { return Condition.booleanOperator(this) !== undefined } + + /** + * Whether the condition represents a reference to a condition + * @returns {Boolean} + */ + isConditionReference () { + return Object.prototype.hasOwnProperty.call(this, 'condition') + } } diff --git a/src/engine.js b/src/engine.js index 3bd958e1..6434d53b 100644 --- a/src/engine.js +++ b/src/engine.js @@ -7,6 +7,7 @@ import Almanac from './almanac' import EventEmitter from 'eventemitter2' import defaultOperators from './engine-default-operators' import debug from './debug' +import Condition from './condition' export const READY = 'READY' export const RUNNING = 'RUNNING' @@ -21,9 +22,11 @@ class Engine extends EventEmitter { super() this.rules = [] this.allowUndefinedFacts = options.allowUndefinedFacts || false + this.allowUndefinedConditions = options.allowUndefinedConditions || false this.pathResolver = options.pathResolver this.operators = new Map() this.facts = new Map() + this.conditions = new Map() this.status = READY rules.map(r => this.addRule(r)) defaultOperators.map(o => this.addOperator(o)) @@ -92,6 +95,31 @@ class Engine extends EventEmitter { return ruleRemoved } + /** + * sets a condition that can be referenced by the given name. + * If a condition with the given name has already been set this will replace it. + * @param {string} name - the name of the condition to be referenced by rules. + * @param {object} conditions - the conditions to use when the condition is referenced. + */ + setCondition (name, conditions) { + if (!name) throw new Error('Engine: setCondition() requires name') + if (!conditions) throw new Error('Engine: setCondition() requires conditions') + if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) { + throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"') + } + this.conditions.set(name, new Condition(conditions)) + return this + } + + /** + * Removes a condition that has previously been added to this engine + * @param {string} name - the name of the condition to remove. + * @returns true if the condition existed, otherwise false + */ + removeCondition (name) { + return this.conditions.delete(name) + } + /** * Add a custom operator definition * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc diff --git a/src/rule.js b/src/rule.js index c4f488b3..a26e0350 100644 --- a/src/rule.js +++ b/src/rule.js @@ -3,6 +3,7 @@ import Condition from './condition' import RuleResult from './rule-result' import debug from './debug' +import deepClone from 'clone' import EventEmitter from 'eventemitter2' class Rule extends EventEmitter { @@ -70,8 +71,8 @@ class Rule extends EventEmitter { * @param {object} conditions - conditions, root element must be a boolean operator */ setConditions (conditions) { - if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not')) { - throw new Error('"conditions" root must contain a single instance of "all", "any", or "not"') + if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) { + throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"') } this.conditions = new Condition(conditions) return this @@ -188,7 +189,9 @@ class Rule extends EventEmitter { * @return {Promise(true|false)} - resolves with the result of the condition evaluation */ const evaluateCondition = (condition) => { - if (condition.isBooleanOperator()) { + if (condition.isConditionReference()) { + return realize(this.engine.conditions.get(condition.condition), condition) + } else if (condition.isBooleanOperator()) { const subConditions = condition[condition.operator] let comparisonPromise if (condition.operator === 'all') { @@ -309,6 +312,23 @@ class Rule extends EventEmitter { return prioritizeAndRun([condition], 'not').then(result => !result) } + const realize = (condition, conditionReference) => { + if (!condition) { + if (this.engine.allowUndefinedConditions) { + // undefined conditions always fail + conditionReference.result = false + return Promise.resolve(false) + } else { + throw new Error(`No condition ${conditionReference.condition} exists`) + } + } else { + // project the referenced condition onto reference object and evaluate it. + delete conditionReference.condition + Object.assign(conditionReference, deepClone(condition)) + return evaluateCondition(conditionReference) + } + } + /** * Emits based on rule evaluation result, and decorates ruleResult with 'result' property * @param {RuleResult} ruleResult diff --git a/test/engine-condition.test.js b/test/engine-condition.test.js new file mode 100644 index 00000000..d4867b99 --- /dev/null +++ b/test/engine-condition.test.js @@ -0,0 +1,280 @@ +'use strict' + +import sinon from 'sinon' +import engineFactory from '../src/index' + +describe('Engine: condition', () => { + let engine + let sandbox + before(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) + + describe('setCondition()', () => { + describe('validations', () => { + beforeEach(() => { + engine = engineFactory() + }) + it('throws an exception for invalid root conditions', () => { + expect(engine.setCondition.bind(engine, 'test', { foo: true })).to.throw( + /"conditions" root must contain a single instance of "all", "any", "not", or "condition"/ + ) + }) + }) + }) + + describe('undefined condition', () => { + const sendEvent = { + type: 'checkSending', + params: { + sendRetirementPayment: true + } + } + + const sendConditions = { + all: [ + { condition: 'over60' }, + { + fact: 'isRetired', + operator: 'equal', + value: true + } + ] + } + + describe('allowUndefinedConditions: true', () => { + let eventSpy + beforeEach(() => { + eventSpy = sandbox.spy() + const sendRule = factories.rule({ + conditions: sendConditions, + event: sendEvent + }) + engine = engineFactory([sendRule], { allowUndefinedConditions: true }) + + engine.addFact('isRetired', true) + engine.on('failure', eventSpy) + }) + + it('evaluates undefined conditions as false', async () => { + await engine.run() + expect(eventSpy).to.have.been.called() + }) + }) + + describe('allowUndefinedConditions: false', () => { + beforeEach(() => { + const sendRule = factories.rule({ + conditions: sendConditions, + event: sendEvent + }) + engine = engineFactory([sendRule], { allowUndefinedConditions: false }) + + engine.addFact('isRetired', true) + }) + + it('throws error during run', async () => { + try { + await engine.run() + } catch (error) { + expect(error.message).to.equal('No condition over60 exists') + } + }) + }) + }) + + describe('supports condition shared across multiple rules', () => { + const name = 'over60' + const condition = { + all: [ + { + fact: 'age', + operator: 'greaterThanInclusive', + value: 60 + } + ] + } + + const sendEvent = { + type: 'checkSending', + params: { + sendRetirementPayment: true + } + } + + const sendConditions = { + all: [ + { condition: name }, + { + fact: 'isRetired', + operator: 'equal', + value: true + } + ] + } + + const outreachEvent = { + type: 'triggerOutreach' + } + + const outreachConditions = { + all: [ + { condition: name }, + { + fact: 'requestedOutreach', + operator: 'equal', + value: true + } + ] + } + + let eventSpy + let ageSpy + let isRetiredSpy + let requestedOutreachSpy + beforeEach(() => { + eventSpy = sandbox.spy() + ageSpy = sandbox.stub() + isRetiredSpy = sandbox.stub() + requestedOutreachSpy = sandbox.stub() + engine = engineFactory() + + const sendRule = factories.rule({ + conditions: sendConditions, + event: sendEvent + }) + engine.addRule(sendRule) + + const outreachRule = factories.rule({ + conditions: outreachConditions, + event: outreachEvent + }) + engine.addRule(outreachRule) + + engine.setCondition(name, condition) + + engine.addFact('age', ageSpy) + engine.addFact('isRetired', isRetiredSpy) + engine.addFact('requestedOutreach', requestedOutreachSpy) + engine.on('success', eventSpy) + }) + + it('emits all events when all conditions are met', async () => { + ageSpy.returns(65) + isRetiredSpy.returns(true) + requestedOutreachSpy.returns(true) + await engine.run() + expect(eventSpy) + .to.have.been.calledWith(sendEvent) + .and.to.have.been.calledWith(outreachEvent) + }) + + it('expands condition in rule results', async () => { + ageSpy.returns(65) + isRetiredSpy.returns(true) + requestedOutreachSpy.returns(true) + const { results } = await engine.run() + const nestedCondition = { + 'conditions.all[0].all[0].fact': 'age', + 'conditions.all[0].all[0].operator': 'greaterThanInclusive', + 'conditions.all[0].all[0].value': 60 + } + expect(results[0]).to.nested.include(nestedCondition) + expect(results[1]).to.nested.include(nestedCondition) + }) + }) + + describe('nested condition', () => { + const name1 = 'over60' + const condition1 = { + all: [ + { + fact: 'age', + operator: 'greaterThanInclusive', + value: 60 + } + ] + } + + const name2 = 'earlyRetirement' + const condition2 = { + all: [ + { not: { condition: name1 } }, + { + fact: 'isRetired', + operator: 'equal', + value: true + } + ] + } + + const outreachEvent = { + type: 'triggerOutreach' + } + + const outreachConditions = { + all: [ + { condition: name2 }, + { + fact: 'requestedOutreach', + operator: 'equal', + value: true + } + ] + } + + let eventSpy + let ageSpy + let isRetiredSpy + let requestedOutreachSpy + beforeEach(() => { + eventSpy = sandbox.spy() + ageSpy = sandbox.stub() + isRetiredSpy = sandbox.stub() + requestedOutreachSpy = sandbox.stub() + engine = engineFactory() + + const outreachRule = factories.rule({ + conditions: outreachConditions, + event: outreachEvent + }) + engine.addRule(outreachRule) + + engine.setCondition(name1, condition1) + + engine.setCondition(name2, condition2) + + engine.addFact('age', ageSpy) + engine.addFact('isRetired', isRetiredSpy) + engine.addFact('requestedOutreach', requestedOutreachSpy) + engine.on('success', eventSpy) + }) + + it('emits all events when all conditions are met', async () => { + ageSpy.returns(55) + isRetiredSpy.returns(true) + requestedOutreachSpy.returns(true) + await engine.run() + expect(eventSpy).to.have.been.calledWith(outreachEvent) + }) + + it('expands condition in rule results', async () => { + ageSpy.returns(55) + isRetiredSpy.returns(true) + requestedOutreachSpy.returns(true) + const { results } = await engine.run() + const nestedCondition = { + 'conditions.all[0].all[0].not.all[0].fact': 'age', + 'conditions.all[0].all[0].not.all[0].operator': 'greaterThanInclusive', + 'conditions.all[0].all[0].not.all[0].value': 60, + 'conditions.all[0].all[1].fact': 'isRetired', + 'conditions.all[0].all[1].operator': 'equal', + 'conditions.all[0].all[1].value': true + } + expect(results[0]).to.nested.include(nestedCondition) + }) + }) +}) diff --git a/test/rule.test.js b/test/rule.test.js index 8ee08fe2..fc3357b8 100644 --- a/test/rule.test.js +++ b/test/rule.test.js @@ -109,7 +109,7 @@ describe('Rule', () => { describe('setConditions()', () => { describe('validations', () => { it('throws an exception for invalid root conditions', () => { - expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", or "not"/) + expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", "not", or "condition"/) }) }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 3cf93c0b..ec28eed1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,5 +1,6 @@ export interface EngineOptions { allowUndefinedFacts?: boolean; + allowUndefinedConditions?: boolean; pathResolver?: PathResolver; } @@ -23,6 +24,9 @@ export class Engine { removeRule(ruleOrName: Rule | string): boolean; updateRule(rule: Rule): void; + setCondition(name: string, conditions: TopLevelCondition): this; + removeCondition(name: string): boolean; + addOperator(operator: Operator): Map; addOperator( operatorName: string, @@ -98,10 +102,7 @@ export interface Event { params?: Record; } -export type PathResolver = ( - value: object, - path: string, -) => any; +export type PathResolver = (value: object, path: string) => any; export type EventHandler = ( event: Event, @@ -156,7 +157,24 @@ interface ConditionProperties { } type NestedCondition = ConditionProperties | TopLevelCondition; -type AllConditions = { all: NestedCondition[]; name?: string; priority?: number; }; -type AnyConditions = { any: NestedCondition[]; name?: string; priority?: number; }; -type NotConditions = { not: NestedCondition; name?: string; priority?: number; }; -export type TopLevelCondition = AllConditions | AnyConditions | NotConditions; +type AllConditions = { + all: NestedCondition[]; + name?: string; + priority?: number; +}; +type AnyConditions = { + any: NestedCondition[]; + name?: string; + priority?: number; +}; +type NotConditions = { not: NestedCondition; name?: string; priority?: number }; +type ConditionReference = { + condition: string; + name?: string; + priority?: number; +}; +export type TopLevelCondition = + | AllConditions + | AnyConditions + | NotConditions + | ConditionReference; From 371243ea1ba2267fc8135bd52bb65bc96e030b90 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 10 Jul 2023 13:02:31 -0400 Subject: [PATCH 22/65] Add documentation around conditions Add documentation covering the use and advantages of condition sharing. Specifically on the ability to re-use the same condition across multiple rules. --- docs/engine.md | 72 ++++++++++++++++ docs/rules.md | 28 ++++++- examples/10-condition-sharing.js | 139 +++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 examples/10-condition-sharing.js diff --git a/docs/engine.md b/docs/engine.md index 46d0a1bf..7665fa09 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -12,6 +12,8 @@ The Engine stores and executes rules, emits events, and maintains state. * [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance) * [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue) * [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname) + * [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions) + * [engine.removeCondition(String name)](#engineremovecondtionstring-name) * [engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-) * [engine.stop() -> Engine](#enginestop---engine) * [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult) @@ -43,6 +45,11 @@ let engine = new Engine([Array rules], options) an exception is thrown. Turning this option on will cause the engine to treat undefined facts as `undefined`. (default: false) +`allowUndefinedConditions` - By default, when a running engine encounters a +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) + `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]) @@ -172,6 +179,71 @@ engine.addOperator('startsWithLetter', (factValue, jsonValue) => { engine.removeOperator('startsWithLetter'); ``` +### engine.setCondition(String name, Object conditions) + +Adds or updates a condition to the engine. Rules may include references to this condition. Conditions must start with `all`, `any`, `not`, or reference a condition. + +```javascript +engine.setCondition('validLogin', { + all: [ + { + operator: 'notEqual', + fact: 'loginToken', + value: null + }, + { + operator: 'greaterThan', + fact: 'loginToken', + path: '$.expirationTime', + value: { fact: 'now' } + } + ] +}); + +engine.addRule({ + condtions: { + all: [ + { + condition: 'validLogin' + }, + { + operator: 'contains', + fact: 'loginToken', + path: '$.role', + value: 'admin' + } + ] + }, + event: { + type: 'AdminAccessAllowed' + } +}) + +``` + +### engine.removeCondition(String name) + +Removes the condition that was previously added. + +```javascript +engine.setCondition('validLogin', { + all: [ + { + operator: 'notEqual', + fact: 'loginToken', + value: null + }, + { + operator: 'greaterThan', + fact: 'loginToken', + path: '$.expirationTime', + value: { fact: 'now' } + } + ] +}); + +engine.removeCondition('validLogin'); +``` ### engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []}) diff --git a/docs/rules.md b/docs/rules.md index d17dc416..518989e0 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -15,6 +15,7 @@ Rules contain a set of _conditions_ and a single _event_. When the engine is ru * [Conditions](#conditions) * [Basic conditions](#basic-conditions) * [Boolean expressions: all, any, and not](#boolean-expressions-all-any-and-not) + * [Condition Reference](#condition-reference) * [Condition helpers: params](#condition-helpers-params) * [Condition helpers: path](#condition-helpers-path) * [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver) @@ -136,7 +137,7 @@ See the [hello-world](../examples/01-hello-world.js) example. ### Boolean expressions: `all`, `any`, and `not` -Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root or a `not` operator containing a single condition. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains. +Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root, a `not` operator containing a single condition, or a condition reference. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains. ```js // all: @@ -174,7 +175,30 @@ let rule = new Rule({ }) ``` -Notice in the second example how `all`, `any`, and 'not' can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example. +Notice in the second example how `all`, `any`, and `not` can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example. + +### Condition Reference + +Rules may reference conditions based on their name. + +```js +let rule = new Rule({ + conditions: { + all: [ + { condition: 'conditionName' }, + { /* additional condition */ } + ] + } +}) +``` + +Before running the rule the condition should be added to the engine. + +```js +engine.setCondition('conditionName', { /* conditions */ }); +``` + +Conditions must start with `all`, `any`, `not`, or reference a condition. ### Condition helpers: `params` diff --git a/examples/10-condition-sharing.js b/examples/10-condition-sharing.js new file mode 100644 index 00000000..29fe2e37 --- /dev/null +++ b/examples/10-condition-sharing.js @@ -0,0 +1,139 @@ +'use strict' +/* + * This is an advanced example demonstrating rules that re-use a condition defined + * in the engine. + * + * Usage: + * node ./examples/10-condition-sharing.js + * + * For detailed output: + * DEBUG=json-rules-engine node ./examples/10-condition-sharing.js + */ + +require('colors') +const { Engine } = require('json-rules-engine') + +async function start () { + /** + * Setup a new engine + */ + const engine = new Engine() + + /** + * Condition that will be used to determine if a user likes screwdrivers + */ + engine.setCondition('screwdriverAficionado', { + all: [ + { + fact: 'drinksOrangeJuice', + operator: 'equal', + value: true + }, + { + fact: 'enjoysVodka', + operator: 'equal', + value: true + } + ] + }) + + /** + * Rule for identifying people who should be invited to a screwdriver social + * - Only invite people who enjoy screw drivers + * - Only invite people who are sociable + */ + const inviteRule = { + conditions: { + all: [ + { + condition: 'screwdriverAficionado' + }, + { + fact: 'isSociable', + operator: 'equal', + value: true + } + ] + }, + event: { type: 'invite-to-screwdriver-social' } + } + engine.addRule(inviteRule) + + /** + * Rule for identifying people who should be invited to the other social + * - Only invite people who don't enjoy screw drivers + * - Only invite people who are sociable + */ + const otherInviteRule = { + conditions: { + all: [ + { + not: { + condition: 'screwdriverAficionado' + } + }, + { + fact: 'isSociable', + operator: 'equal', + value: true + } + ] + }, + event: { type: 'invite-to-other-social' } + } + engine.addRule(otherInviteRule) + + /** + * Register listeners with the engine for rule success and failure + */ + engine + .on('success', async (event, almanac) => { + const accountId = await almanac.factValue('accountId') + console.log( + `${accountId}` + + 'DID'.green + + ` meet conditions for the ${event.type.underline} rule.` + ) + }) + .on('failure', async (event, almanac) => { + const accountId = await almanac.factValue('accountId') + console.log( + `${accountId} did ` + + 'NOT'.red + + ` meet conditions for the ${event.type.underline} rule.` + ) + }) + + // define fact(s) known at runtime + let facts = { + accountId: 'washington', + drinksOrangeJuice: true, + enjoysVodka: true, + isSociable: true + } + + // first run, using washington's facts + await engine.run(facts) + + facts = { + accountId: 'jefferson', + drinksOrangeJuice: true, + enjoysVodka: false, + isSociable: true, + accountInfo: {} + } + + // second run, using jefferson's facts; facts & evaluation are independent of the first run + await engine.run(facts) +} + +start() + +/* + * OUTPUT: + * + * washington DID meet conditions for the invite-to-screwdriver-social rule. + * washington did NOT meet conditions for the invite-to-other-social rule. + * jefferson did NOT meet conditions for the invite-to-screwdriver-social rule. + * jefferson DID meet conditions for the invite-to-other-social rule. + */ From 8747b65763bd89ed46c8a9a4b03b60e7efb80582 Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Wed, 12 Jul 2023 19:52:10 -0400 Subject: [PATCH 23/65] Update Version to 6.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 343a45a9..4f1a7787 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.2.0", + "version": "6.3.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From 48e50e94257e3320a1cc7ae32a117dcc031817de Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Thu, 13 Jul 2023 14:31:31 -0400 Subject: [PATCH 24/65] Fix Top Level Condition Reference Failure There was no test for the top-level condition reference and this was failing because it was being treated as a `not` add a test and fix the bug. --- package.json | 2 +- src/rule.js | 99 +++++++++++++++++++++++++---------- test/engine-condition.test.js | 41 +++++++++++++++ 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 4f1a7787..80c1ebef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.3.0", + "version": "6.3.1", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", diff --git a/src/rule.js b/src/rule.js index a26e0350..cf117c20 100644 --- a/src/rule.js +++ b/src/rule.js @@ -71,8 +71,15 @@ class Rule extends EventEmitter { * @param {object} conditions - conditions, root element must be a boolean operator */ setConditions (conditions) { - if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) { - throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"') + if ( + !Object.prototype.hasOwnProperty.call(conditions, 'all') && + !Object.prototype.hasOwnProperty.call(conditions, 'any') && + !Object.prototype.hasOwnProperty.call(conditions, 'not') && + !Object.prototype.hasOwnProperty.call(conditions, 'condition') + ) { + throw new Error( + '"conditions" root must contain a single instance of "all", "any", "not", or "condition"' + ) } this.conditions = new Condition(conditions) return this @@ -86,7 +93,11 @@ class Rule extends EventEmitter { */ setEvent (event) { if (!event) throw new Error('Rule: setEvent() requires event object') - if (!Object.prototype.hasOwnProperty.call(event, 'type')) throw new Error('Rule: setEvent() requires event object with "type" property') + if (!Object.prototype.hasOwnProperty.call(event, 'type')) { + throw new Error( + 'Rule: setEvent() requires event object with "type" property' + ) + } this.ruleEvent = { type: event.type } @@ -170,9 +181,11 @@ class Rule extends EventEmitter { sets[priority].push(condition) return sets }, {}) - return Object.keys(factSets).sort((a, b) => { - return Number(a) > Number(b) ? -1 : 1 // order highest priority -> lowest - }).map((priority) => factSets[priority]) + return Object.keys(factSets) + .sort((a, b) => { + return Number(a) > Number(b) ? -1 : 1 // order highest priority -> lowest + }) + .map((priority) => factSets[priority]) } /** @@ -181,7 +194,12 @@ class Rule extends EventEmitter { * @return {Promise(RuleResult)} rule evaluation result */ evaluate (almanac) { - const ruleResult = new RuleResult(this.conditions, this.ruleEvent, this.priority, this.name) + const ruleResult = new RuleResult( + this.conditions, + this.ruleEvent, + this.priority, + this.name + ) /** * Evaluates the rule conditions @@ -190,7 +208,7 @@ class Rule extends EventEmitter { */ const evaluateCondition = (condition) => { if (condition.isConditionReference()) { - return realize(this.engine.conditions.get(condition.condition), condition) + return realize(condition) } else if (condition.isBooleanOperator()) { const subConditions = condition[condition.operator] let comparisonPromise @@ -202,14 +220,15 @@ class Rule extends EventEmitter { comparisonPromise = not(subConditions) } // for booleans, rule passing is determined by the all/any/not result - return comparisonPromise.then(comparisonValue => { + return comparisonPromise.then((comparisonValue) => { const passes = comparisonValue === true condition.result = passes return passes }) } else { - return condition.evaluate(almanac, this.engine.operators) - .then(evaluationResult => { + return condition + .evaluate(almanac, this.engine.operators) + .then((evaluationResult) => { const passes = evaluationResult.result condition.factResult = evaluationResult.leftHandSideValue condition.result = passes @@ -225,13 +244,14 @@ class Rule extends EventEmitter { * @return {Promise(boolean)} whether conditions evaluated truthy or falsey based on condition evaluation + method */ const evaluateConditions = (conditions, method) => { - if (!(Array.isArray(conditions))) conditions = [conditions] + if (!Array.isArray(conditions)) conditions = [conditions] - return Promise.all(conditions.map((condition) => evaluateCondition(condition))) - .then(conditionResults => { - debug('rule::evaluateConditions results', conditionResults) - return method.call(conditionResults, (result) => result === true) - }) + return Promise.all( + conditions.map((condition) => evaluateCondition(condition)) + ).then((conditionResults) => { + debug('rule::evaluateConditions results', conditionResults) + return method.call(conditionResults, (result) => result === true) + }) } /** @@ -267,14 +287,18 @@ class Rule extends EventEmitter { cursor = cursor.then((setResult) => { // after the first set succeeds, don't fire off the remaining promises if ((operator === 'any' && setResult === true) || stop) { - debug('prioritizeAndRun::detected truthy result; skipping remaining conditions') + debug( + 'prioritizeAndRun::detected truthy result; skipping remaining conditions' + ) stop = true return true } // after the first set fails, don't fire off the remaining promises if ((operator === 'all' && setResult === false) || stop) { - debug('prioritizeAndRun::detected falsey result; skipping remaining conditions') + debug( + 'prioritizeAndRun::detected falsey result; skipping remaining conditions' + ) stop = true return false } @@ -309,17 +333,25 @@ class Rule extends EventEmitter { * @return {Promise(boolean)} condition evaluation result */ const not = (condition) => { - return prioritizeAndRun([condition], 'not').then(result => !result) + return prioritizeAndRun([condition], 'not').then((result) => !result) } - const realize = (condition, conditionReference) => { + /** + * Dereferences the condition reference and then evaluates it. + * @param {Condition} conditionReference + * @returns {Promise(boolean)} condition evaluation result + */ + const realize = (conditionReference) => { + const condition = this.engine.conditions.get(conditionReference.condition) if (!condition) { if (this.engine.allowUndefinedConditions) { // undefined conditions always fail conditionReference.result = false return Promise.resolve(false) } else { - throw new Error(`No condition ${conditionReference.condition} exists`) + throw new Error( + `No condition ${conditionReference.condition} exists` + ) } } else { // project the referenced condition onto reference object and evaluate it. @@ -336,18 +368,27 @@ class Rule extends EventEmitter { const processResult = (result) => { ruleResult.setResult(result) const event = result ? 'success' : 'failure' - return this.emitAsync(event, ruleResult.event, almanac, ruleResult).then(() => ruleResult) + return this.emitAsync(event, ruleResult.event, almanac, ruleResult).then( + () => ruleResult + ) } if (ruleResult.conditions.any) { - return any(ruleResult.conditions.any) - .then(result => processResult(result)) + return any(ruleResult.conditions.any).then((result) => + processResult(result) + ) } else if (ruleResult.conditions.all) { - return all(ruleResult.conditions.all) - .then(result => processResult(result)) + return all(ruleResult.conditions.all).then((result) => + processResult(result) + ) + } else if (ruleResult.conditions.not) { + return not(ruleResult.conditions.not).then((result) => + processResult(result) + ) } else { - return not(ruleResult.conditions.not) - .then(result => processResult(result)) + return realize( + ruleResult.conditions + ).then((result) => processResult(result)) } } } diff --git a/test/engine-condition.test.js b/test/engine-condition.test.js index d4867b99..028a856e 100644 --- a/test/engine-condition.test.js +++ b/test/engine-condition.test.js @@ -277,4 +277,45 @@ describe('Engine: condition', () => { expect(results[0]).to.nested.include(nestedCondition) }) }) + + describe('top-level condition reference', () => { + const sendEvent = { + type: 'checkSending', + params: { + sendRetirementPayment: true + } + } + + const retiredName = 'retired' + const retiredCondition = { + all: [ + { fact: 'isRetired', operator: 'equal', value: true } + ] + } + + const sendConditions = { + condition: retiredName + } + + let eventSpy + beforeEach(() => { + eventSpy = sandbox.spy() + const sendRule = factories.rule({ + conditions: sendConditions, + event: sendEvent + }) + engine = engineFactory() + + engine.addRule(sendRule) + engine.setCondition(retiredName, retiredCondition) + + engine.addFact('isRetired', true) + engine.on('success', eventSpy) + }) + + it('evaluates top level conditions correctly', async () => { + await engine.run() + expect(eventSpy).to.have.been.called() + }) + }) }) From a3c380723f9ada00509d76ca7d417b56acbbf28e Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Mon, 21 Aug 2023 14:12:06 -0400 Subject: [PATCH 25/65] 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 7665fa09..9712b5e1 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 518989e0..45b679eb 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 00000000..1004e7ee --- /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 7a9a8e07..cc8fdda9 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 b5f81024..5f5775dc 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 6434d53b..70929c95 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 de163176..95124435 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 cf117c20..c893a143 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 51b77de3..c929d92e 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 26/65] 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 80c1ebef..3afb25a0 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", From 37df47d90b6240687a092597885778e3cf2ae2d8 Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Sat, 26 Aug 2023 18:52:36 -0400 Subject: [PATCH 27/65] Fix usage of lodash We didn't have lodash as a dependency instead having lodash.isObject builds were working because lodash was a dev dependency. Resolves #346 --- package.json | 2 +- src/rule-result.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3afb25a0..4d7a59b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.4.0", + "version": "6.4.1", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", diff --git a/src/rule-result.js b/src/rule-result.js index 95124435..5e1fcbd5 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -1,7 +1,7 @@ 'use strict' import deepClone from 'clone' -import { isObject } from 'lodash' +import isObject from 'lodash.isobjectlike' export default class RuleResult { constructor (conditions, event, priority, name) { From 3c18b698dee7ec72d778da32468d287db0446fce Mon Sep 17 00:00:00 2001 From: Christopher Pardy Date: Fri, 1 Sep 2023 15:03:03 -0400 Subject: [PATCH 28/65] Bump patch version and add missed typing --- package.json | 2 +- types/index.d.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4d7a59b6..ac34dcd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.4.1", + "version": "6.4.2", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", diff --git a/types/index.d.ts b/types/index.d.ts index ec28eed1..35b380ab 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,7 @@ export interface EngineOptions { allowUndefinedFacts?: boolean; allowUndefinedConditions?: boolean; + replaceFactsInEventParams?: boolean; pathResolver?: PathResolver; } From 597daa3882d4a7966f40aeaf883bf2da60171d9f Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Mon, 6 Nov 2023 00:19:27 +0200 Subject: [PATCH 29/65] 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 30/65] 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 31/65] 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, From 9655c812f3e3ab69e2de2e15bd112c1d3b5d41eb Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Fri, 10 Nov 2023 09:50:05 +0100 Subject: [PATCH 32/65] Update package.json Bump Package Version to release new custom Almanac Feature --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac34dcd1..0322ee70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.4.2", + "version": "6.5.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From 7629c342aebe58b3d743446bd6d3e3f2208ca04a Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Wed, 9 Oct 2024 13:26:53 -0400 Subject: [PATCH 33/65] Fix typing of addOperator Methods These methods currently return void so don't mark their return type as map --- types/index.d.ts | 4 ++-- types/index.test-d.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index c6ace7fc..15b61fb8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -35,11 +35,11 @@ export class Engine { setCondition(name: string, conditions: TopLevelCondition): this; removeCondition(name: string): boolean; - addOperator(operator: Operator): Map; + addOperator(operator: Operator): void; addOperator( operatorName: string, callback: OperatorEvaluator - ): Map; + ): void; removeOperator(operator: Operator | string): boolean; addFact(fact: Fact): this; diff --git a/types/index.test-d.ts b/types/index.test-d.ts index dcf55419..f4ae3c15 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -72,7 +72,7 @@ const operatorEvaluator: OperatorEvaluator = ( a: number, b: number ) => a === b; -expectType>( +expectType( engine.addOperator("test", operatorEvaluator) ); const operator: Operator = new Operator( @@ -80,7 +80,7 @@ const operator: Operator = new Operator( operatorEvaluator, (num: number) => num > 0 ); -expectType>(engine.addOperator(operator)); +expectType(engine.addOperator(operator)); expectType(engine.removeOperator(operator)); // Fact tests From 6dc102263d077c3754c804752c85df33d213b4d2 Mon Sep 17 00:00:00 2001 From: Amer Trkic Date: Thu, 10 Oct 2024 16:26:08 +0200 Subject: [PATCH 34/65] Remove lodash.isobjectlike package --- package.json | 3 +-- src/almanac.js | 2 +- src/rule-result.js | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 0322ee70..69297949 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "clone": "^2.1.2", "eventemitter2": "^6.4.4", "hash-it": "^6.0.0", - "jsonpath-plus": "^7.2.0", - "lodash.isobjectlike": "^4.0.0" + "jsonpath-plus": "^7.2.0" } } diff --git a/src/almanac.js b/src/almanac.js index 58cc2432..572336a6 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -5,7 +5,7 @@ import { UndefinedFactError } from './errors' import debug from './debug' import { JSONPath } from 'jsonpath-plus' -import isObjectLike from 'lodash.isobjectlike' +import isObjectLike from 'lodash/isObjectLike' function defaultPathResolver (value, path) { return JSONPath({ path, json: value, wrap: false }) diff --git a/src/rule-result.js b/src/rule-result.js index 5e1fcbd5..79a6c75f 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -1,7 +1,7 @@ 'use strict' import deepClone from 'clone' -import isObject from 'lodash.isobjectlike' +import isObject from 'lodash/isObjectLike' export default class RuleResult { constructor (conditions, event, priority, name) { From 87afb2f751a894e45adc4fc4e6c9e3bbd9ee06c5 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Wed, 9 Oct 2024 13:29:45 -0400 Subject: [PATCH 35/65] Add Support for Operator Decorators Operator Decorators "decorate" an operator and return a new operator. Create defaults: - someFact -> for fact values that are arrays return if one of the values matches the decorated operator - someValue -> same but for json values that are arrays - everyFact - everyValue - not - swap --- docs/engine.md | 58 +++++++++ docs/rules.md | 34 ++++++ examples/13-using-operator-decorators.js | 98 ++++++++++++++++ examples/package-lock.json | 82 ++++++------- src/engine-default-operator-decorators.js | 14 +++ src/engine.js | 40 ++++--- src/json-rules-engine.js | 3 +- src/operator-decorator.js | 37 ++++++ src/operator-map.js | 137 ++++++++++++++++++++++ test/engine-operator-map.test.js | 86 ++++++++++++++ test/engine.test.js | 16 +-- test/operator-decorator.test.js | 40 +++++++ types/index.d.ts | 17 +++ types/index.test-d.ts | 19 +++ 14 files changed, 610 insertions(+), 71 deletions(-) create mode 100644 examples/13-using-operator-decorators.js create mode 100644 src/engine-default-operator-decorators.js create mode 100644 src/operator-decorator.js create mode 100644 src/operator-map.js create mode 100644 test/engine-operator-map.test.js create mode 100644 test/operator-decorator.test.js diff --git a/docs/engine.md b/docs/engine.md index 8546656d..b944adc6 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -12,6 +12,8 @@ The Engine stores and executes rules, emits events, and maintains state. * [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance) * [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue) * [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname) + * [engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next))](#engineaddoperatordecoratorstring-decoratorname-function-evaluatefuncfactvalue-jsonvalue-next) + * [engine.removeOperatorDecorator(String decoratorName)](#engineremoveoperatordecoratorstring-decoratorname) * [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions) * [engine.removeCondition(String name)](#engineremovecondtionstring-name) * [engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-) @@ -181,6 +183,62 @@ engine.addOperator('startsWithLetter', (factValue, jsonValue) => { engine.removeOperator('startsWithLetter'); ``` +### engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next)) + +Adds a custom operator decorator to the engine. + +```js +/* + * decoratorName - operator decorator identifier used in the rule condition + * evaluateFunc(factValue, jsonValue, next) - uses the decorated operator to compare the fact result to the condition 'value' + * factValue - the value returned from the fact + * jsonValue - the "value" property stored in the condition itself + * next - the evaluateFunc of the decorated operator + */ +engine.addOperatorDecorator('first', (factValue, jsonValue, next) => { + if (!factValue.length) return false + return next(factValue[0], jsonValue) +}) + +engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => { + return next(factValue.toLowerCase(), jsonValue.toLowerCase()) +}) + +// and to use the decorator... +let rule = new Rule( + conditions: { + all: [ + { + fact: 'username', + operator: 'first:caseInsensitive:equal', // reference the decorator:operator in the rule + value: 'a' + } + ] + } +) +``` + +See the [operator decorator example](../examples/13-using-operator-decorators.js) + + + +### engine.removeOperatorDecorator(String decoratorName) + +Removes a operator decorator from the engine + +```javascript +engine.addOperatorDecorator('first', (factValue, jsonValue, next) => { + if (!factValue.length) return false + return next(factValue[0], jsonValue) +}) + +engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => { + return next(factValue.toLowerCase(), jsonValue.toLowerCase()) +}) + +engine.removeOperator('first'); +``` + ### engine.setCondition(String name, Object conditions) Adds or updates a condition to the engine. Rules may include references to this condition. Conditions must start with `all`, `any`, `not`, or reference a condition. diff --git a/docs/rules.md b/docs/rules.md index 45b679eb..7f6d4238 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -27,6 +27,11 @@ Rules contain a set of _conditions_ and a single _event_. When the engine is ru * [String and Numeric operators:](#string-and-numeric-operators) * [Numeric operators:](#numeric-operators) * [Array operators:](#array-operators) +* [Operator Decorators](#operator-decorators) + * [Array decorators:](#array-decorators) + * [Logical decorators:](#logical-decorators) + * [Utility decorators:](#utility-decorators) + * [Decorator composition:](#decorator-composition) * [Rule Results](#rule-results) * [Persisting](#persisting) @@ -406,6 +411,35 @@ The ```operator``` compares the value returned by the ```fact``` to what is stor ```doesNotContain``` - _fact_ (an array) must not include _value_ +## Operator Decorators + +Operator Decorators modify the behavior of an operator either by changing the input or the output. To specify one or more decorators prefix the name of the operator with them in the ```operator``` field and use the colon (```:```) symbol to separate decorators and the operator. For instance ```everyFact:greaterThan``` will produce an operator that checks that every element of the _fact_ is greater than the value. + +See [12-using-operator-decorators.js](../examples/13-using-operator-decorators.js) for an example. + +### Array Decorators: + + ```everyFact``` - _fact_ (an array) must have every element pass the decorated operator for _value_ + + ```everyValue``` - _fact_ must pass the decorated operator for every element of _value_ (an array) + + ```someFact``` - _fact_ (an array) must have at-least one element pass the decorated operator for _value_ + + ```someValue``` - _fact_ must pass the decorated operator for at-least one element of _value_ (an array) + +### Logical Decorators + + ```not``` - negate the result of the decorated operator + +### Utility Decorators + ```swap``` - Swap _fact_ and _value_ for the decorated operator + +### Decorator Composition + +Operator Decorators can be composed by chaining them together with the colon to separate them. For example if you wanted to ensure that every number in an array was less than every number in another array you could use ```everyFact:everyValue:lessThan```. + +```swap``` and ```not``` are useful when there are not symmetric or negated versions of custom operators, for instance you could check if a _value_ does not start with a letter contained in a _fact_ using the decorated custom operator ```swap:not:startsWithLetter```. This allows a single custom operator to have 4 permutations. + ## Rule Results After a rule is evaluated, a `rule result` object is provided to the `success` and `failure` events. This argument is similar to a regular rule, and contains additional metadata about how the rule was evaluated. Rule results can be used to extract the results of individual conditions, computed fact values, and boolean logic results. `name` can be used to easily identify a given rule. diff --git a/examples/13-using-operator-decorators.js b/examples/13-using-operator-decorators.js new file mode 100644 index 00000000..fbfeaa53 --- /dev/null +++ b/examples/13-using-operator-decorators.js @@ -0,0 +1,98 @@ +'use strict' +/* + * This example demonstrates using operator decorators. + * + * In this example, a fact contains a list of strings and we want to check if any of these are valid. + * + * Usage: + * node ./examples/12-using-operator-decorators.js + * + * For detailed output: + * DEBUG=json-rules-engine node ./examples/12-using-operator-decorators.js + */ + +require('colors') +const { Engine } = require('json-rules-engine') + +async function start () { + /** + * Setup a new engine + */ + const engine = new Engine() + + /** + * Add a rule for validating a tag (fact) + * against a set of tags that are valid (also a fact) + */ + const validTags = { + conditions: { + all: [{ + fact: 'tags', + operator: 'everyFact:in', + value: { fact: 'validTags' } + }] + }, + event: { + type: 'valid tags' + } + } + + engine.addRule(validTags) + + engine.addFact('validTags', ['dev', 'staging', 'load', 'prod']) + + let facts + + engine + .on('success', event => { + console.log(facts.tags.join(', ') + ' WERE'.green + ' all ' + event.type) + }) + .on('failure', event => { + console.log(facts.tags.join(', ') + ' WERE NOT'.red + ' all ' + event.type) + }) + + // first run with valid tags + facts = { tags: ['dev', 'prod'] } + await engine.run(facts) + + // second run with an invalid tag + facts = { tags: ['dev', 'deleted'] } + await engine.run(facts) + + // add a new decorator to allow for a case-insensitive match + engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => { + return next(factValue.toLowerCase(), jsonValue.toLowerCase()) + }) + + // new rule for case-insensitive validation + const caseInsensitiveValidTags = { + conditions: { + all: [{ + fact: 'tags', + // everyFact has someValue that caseInsensitive is equal + operator: 'everyFact:someValue:caseInsensitive:equal', + value: { fact: 'validTags' } + }] + }, + event: { + type: 'valid tags (case insensitive)' + } + } + + engine.addRule(caseInsensitiveValidTags); + + // third run with a tag that is valid if case insensitive + facts = { tags: ['dev', 'PROD'] } + await engine.run(facts); + +} +start() + +/* + * OUTPUT: + * + * dev, prod WERE all valid tags + * dev, deleted WERE NOT all valid tags + * dev, PROD WERE NOT all valid tags + * dev, PROD WERE all valid tags (case insensitive) + */ diff --git a/examples/package-lock.json b/examples/package-lock.json index abbb622a..da2712e1 100644 --- a/examples/package-lock.json +++ b/examples/package-lock.json @@ -1,53 +1,53 @@ { - "name": "examples", + "name": "json-rules-engine-examples", "version": "1.0.0", - "lockfileVersion": 1, + "lockfileVersion": 3, "requires": true, - "dependencies": { - "clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" - }, - "curriable": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/curriable/-/curriable-1.3.0.tgz", - "integrity": "sha512-7kfjDPRSF+pguU0TlfSFBMCd8XlmF29ZAiXcq/zaN4LhZvWdvV0Y72AvaWFqInXZG9Yg1kA1UMkpE9lFBKMpQA==" - }, - "eventemitter2": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz", - "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==" - }, - "hash-it": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/hash-it/-/hash-it-4.0.5.tgz", - "integrity": "sha512-bVZPdJn9GqaAkmGXcBoWG0MKn99VJYYC1X17UWQUPKFxsUSTYMhzz+RdBzCgtG61iT5IwfunE3NKVFZWkAc/OQ==", - "requires": { - "curriable": "^1.1.0" + "packages": { + "": { + "name": "json-rules-engine-examples", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "json-rules-engine": "../" } }, - "json-rules-engine": { - "version": "6.0.0-alpha-3", - "resolved": "https://registry.npmjs.org/json-rules-engine/-/json-rules-engine-6.0.0-alpha-3.tgz", - "integrity": "sha512-P575ORK6cKlzAH7UXlRZ7TxWVdBm+eBVhW3MxkehbP+k/mwB+npobFJZp8R5ZHwROhSp+QwgltOne0H4jJQOow==", - "requires": { + "..": { + "version": "6.4.2", + "license": "ISC", + "dependencies": { "clone": "^2.1.2", - "eventemitter2": "^6.4.3", - "hash-it": "^4.0.5", - "jsonpath-plus": "^4.0.0", + "eventemitter2": "^6.4.4", + "hash-it": "^6.0.0", + "jsonpath-plus": "^7.2.0", "lodash.isobjectlike": "^4.0.0" + }, + "devDependencies": { + "babel-cli": "6.26.0", + "babel-core": "6.26.3", + "babel-eslint": "10.1.0", + "babel-loader": "8.2.2", + "babel-polyfill": "6.26.0", + "babel-preset-es2015": "~6.24.1", + "babel-preset-stage-0": "~6.24.1", + "babel-register": "6.26.0", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "colors": "~1.4.0", + "dirty-chai": "2.0.1", + "lodash": "4.17.21", + "mocha": "^8.4.0", + "perfy": "^1.1.5", + "sinon": "^11.1.1", + "sinon-chai": "^3.7.0", + "snazzy": "^9.0.0", + "standard": "^16.0.3", + "tsd": "^0.17.0" } }, - "jsonpath-plus": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-4.0.0.tgz", - "integrity": "sha512-e0Jtg4KAzDJKKwzbLaUtinCn0RZseWBVRTRGihSpvFlM3wTR7ExSp+PTdeTsDrLNJUe7L7JYJe8mblHX5SCT6A==" - }, - "lodash.isobjectlike": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/lodash.isobjectlike/-/lodash.isobjectlike-4.0.0.tgz", - "integrity": "sha1-dCxfxlrdJ5JNPSQZFoGqmheytg0=" + "node_modules/json-rules-engine": { + "resolved": "..", + "link": true } } } diff --git a/src/engine-default-operator-decorators.js b/src/engine-default-operator-decorators.js new file mode 100644 index 00000000..4bf83312 --- /dev/null +++ b/src/engine-default-operator-decorators.js @@ -0,0 +1,14 @@ +'use strict' + +import OperatorDecorator from './operator-decorator' + +const OperatorDecorators = [] + +OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray)) +OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv)))) +OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray)) +OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv)))) +OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue))) +OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue))) + +export default OperatorDecorators diff --git a/src/engine.js b/src/engine.js index c67f3a2b..4bd77cab 100644 --- a/src/engine.js +++ b/src/engine.js @@ -2,12 +2,13 @@ import Fact from './fact' import Rule from './rule' -import Operator from './operator' import Almanac from './almanac' import EventEmitter from 'eventemitter2' import defaultOperators from './engine-default-operators' +import defaultDecorators from './engine-default-operator-decorators' import debug from './debug' import Condition from './condition' +import OperatorMap from './operator-map' export const READY = 'READY' export const RUNNING = 'RUNNING' @@ -25,12 +26,13 @@ class Engine extends EventEmitter { this.allowUndefinedConditions = options.allowUndefinedConditions || false this.replaceFactsInEventParams = options.replaceFactsInEventParams || false this.pathResolver = options.pathResolver - this.operators = new Map() + this.operators = new OperatorMap() this.facts = new Map() this.conditions = new Map() this.status = READY rules.map(r => this.addRule(r)) defaultOperators.map(o => this.addOperator(o)) + defaultDecorators.map(d => this.addOperatorDecorator(d)) } /** @@ -127,30 +129,32 @@ class Engine extends EventEmitter { * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. */ addOperator (operatorOrName, cb) { - let operator - if (operatorOrName instanceof Operator) { - operator = operatorOrName - } else { - operator = new Operator(operatorOrName, cb) - } - debug(`engine::addOperator name:${operator.name}`) - this.operators.set(operator.name, operator) + this.operators.addOperator(operatorOrName, cb) } /** * Remove a custom operator definition * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc - * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. */ removeOperator (operatorOrName) { - let operatorName - if (operatorOrName instanceof Operator) { - operatorName = operatorOrName.name - } else { - operatorName = operatorOrName - } + return this.operators.removeOperator(operatorOrName) + } - return this.operators.delete(operatorName) + /** + * Add a custom operator decorator + * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc + * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered. + */ + addOperatorDecorator (decoratorOrName, cb) { + this.operators.addOperatorDecorator(decoratorOrName, cb) + } + + /** + * Remove a custom operator decorator + * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc + */ + removeOperatorDecorator (decoratorOrName) { + return this.operators.removeOperatorDecorator(decoratorOrName) } /** diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index 6f3b149c..bed371d5 100644 --- a/src/json-rules-engine.js +++ b/src/json-rules-engine.js @@ -3,8 +3,9 @@ import Fact from './fact' import Rule from './rule' import Operator from './operator' import Almanac from './almanac' +import OperatorDecorator from './operator-decorator' -export { Fact, Rule, Operator, Engine, Almanac } +export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator } export default function (rules, options) { return new Engine(rules, options) } diff --git a/src/operator-decorator.js b/src/operator-decorator.js new file mode 100644 index 00000000..b9196222 --- /dev/null +++ b/src/operator-decorator.js @@ -0,0 +1,37 @@ +'use strict' + +import Operator from './operator' + +export default class OperatorDecorator { + /** + * Constructor + * @param {string} name - decorator identifier + * @param {function(factValue, jsonValue, next)} callback - callback that takes the next operator as a parameter + * @param {function} [factValueValidator] - optional validator for asserting the data type of the fact + * @returns {OperatorDecorator} - instance + */ + constructor (name, cb, factValueValidator) { + this.name = String(name) + if (!name) throw new Error('Missing decorator name') + if (typeof cb !== 'function') throw new Error('Missing decorator callback') + this.cb = cb + this.factValueValidator = factValueValidator + if (!this.factValueValidator) this.factValueValidator = () => true + } + + /** + * Takes the fact result and compares it to the condition 'value', using the callback + * @param {Operator} operator - fact result + * @returns {Operator} - whether the values pass the operator test + */ + decorate (operator) { + const next = operator.evaluate.bind(operator) + return new Operator( + `${this.name}:${operator.name}`, + (factValue, jsonValue) => { + return this.cb(factValue, jsonValue, next) + }, + this.factValueValidator + ) + } +} diff --git a/src/operator-map.js b/src/operator-map.js new file mode 100644 index 00000000..9e00c0e1 --- /dev/null +++ b/src/operator-map.js @@ -0,0 +1,137 @@ +'use strict' + +import Operator from './operator' +import OperatorDecorator from './operator-decorator' +import debug from './debug' + +export default class OperatorMap { + constructor () { + this.operators = new Map() + this.decorators = new Map() + } + + /** + * Add a custom operator definition + * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. + */ + addOperator (operatorOrName, cb) { + let operator + if (operatorOrName instanceof Operator) { + operator = operatorOrName + } else { + operator = new Operator(operatorOrName, cb) + } + debug(`operatorMap::addOperator name:${operator.name}`) + this.operators.set(operator.name, operator) + } + + /** + * Remove a custom operator definition + * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. + */ + removeOperator (operatorOrName) { + let operatorName + if (operatorOrName instanceof Operator) { + operatorName = operatorOrName.name + } else { + operatorName = operatorOrName + } + + // Delete all the operators that end in :operatorName these + // were decorated on-the-fly leveraging this operator + const suffix = ':' + operatorName + const operatorNames = Array.from(this.operators.keys()) + for (let i = 0; i < operatorNames.length; i++) { + if (operatorNames[i].endsWith(suffix)) { + this.operators.delete(operatorNames[i]) + } + } + + return this.operators.delete(operatorName) + } + + /** + * Add a custom operator decorator + * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc + * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered. + */ + addOperatorDecorator (decoratorOrName, cb) { + let decorator + if (decoratorOrName instanceof OperatorDecorator) { + decorator = decoratorOrName + } else { + decorator = new OperatorDecorator(decoratorOrName, cb) + } + debug(`operatorMap::addOperatorDecorator name:${decorator.name}`) + this.decorators.set(decorator.name, decorator) + } + + /** + * Remove a custom operator decorator + * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc + */ + removeOperatorDecorator (decoratorOrName) { + let decoratorName + if (decoratorOrName instanceof OperatorDecorator) { + decoratorName = decoratorOrName.name + } else { + decoratorName = decoratorOrName + } + + // Delete all the operators that include decoratorName: these + // were decorated on-the-fly leveraging this decorator + const prefix = decoratorName + ':' + const operatorNames = Array.from(this.operators.keys()) + for (let i = 0; i < operatorNames.length; i++) { + if (operatorNames[i].includes(prefix)) { + this.operators.delete(operatorNames[i]) + } + } + + return this.decorators.delete(decoratorName) + } + + /** + * Get the Operator, or null applies decorators as needed + * @param {string} name - the name of the operator including any decorators + * @returns an operator or null + */ + get (name) { + const decorators = [] + let opName = name + // while we don't already have this operator + while (!this.operators.has(opName)) { + // try splitting on the decorator symbol (:) + const firstDecoratorIndex = opName.indexOf(':') + if (firstDecoratorIndex > 0) { + // if there is a decorator, and it's a valid decorator + const decoratorName = opName.slice(0, firstDecoratorIndex) + const decorator = this.decorators.get(decoratorName) + if (!decorator) { + debug(`operatorMap::get invalid decorator named ${decoratorName}`) + return null + } + // we're going to apply this later, use unshift since we'll apply in reverse order + decorators.unshift(decorator) + // continue looking for a known operator with the rest of the name + opName = opName.slice(firstDecoratorIndex + 1) + } else { + debug(`operatorMap::get no operator named ${opName}`) + return null + } + } + + let op = this.operators.get(opName) + // apply all the decorators + for (let i = 0; i < decorators.length; i++) { + op = decorators[i].decorate(op) + // create an entry for the decorated operation so we don't need + // to do this again + this.operators.set(op.name, op) + } + // return the operation + return op + } +} diff --git a/test/engine-operator-map.test.js b/test/engine-operator-map.test.js new file mode 100644 index 00000000..a54afa2a --- /dev/null +++ b/test/engine-operator-map.test.js @@ -0,0 +1,86 @@ +'use strict' + +import { expect } from 'chai' +import engineFactory, { Operator, OperatorDecorator } from '../src/index' + +const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => { + return factValue[0] === jsonValue +}) + +const never = new OperatorDecorator('never', () => false) + +describe('Engine Operator Map', () => { + let engine + beforeEach(() => { + engine = engineFactory() + engine.addOperator(startsWithLetter) + engine.addOperatorDecorator(never) + }) + + describe('undecorated operator', () => { + let op + beforeEach(() => { + op = engine.operators.get('startsWithLetter') + }) + + it('has the operator', () => { + expect(op).not.to.be.null() + }) + + it('the operator evaluates correctly', () => { + expect(op.evaluate('test', 't')).to.be.true() + }) + + it('after being removed the operator is null', () => { + engine.operators.removeOperator(startsWithLetter) + op = engine.operators.get('startsWithLetter') + expect(op).to.be.null() + }) + }) + + describe('decorated operator', () => { + let op + beforeEach(() => { + op = engine.operators.get('never:startsWithLetter') + }) + + it('has the operator', () => { + expect(op).not.to.be.null() + }) + + it('the operator evaluates correctly', () => { + expect(op.evaluate('test', 't')).to.be.false() + }) + + it('removing the base operator removes the decorated version', () => { + engine.operators.removeOperator(startsWithLetter) + op = engine.operators.get('never:startsWithLetter') + expect(op).to.be.null() + }) + + it('removing the decorator removes the decorated operator', () => { + engine.operators.removeOperatorDecorator(never) + op = engine.operators.get('never:startsWithLetter') + expect(op).to.be.null() + }) + }) + + describe('combinatorics with default operators', () => { + it('combines every, some, not, and greaterThanInclusive operators', () => { + const odds = [1, 3, 5, 7] + const evens = [2, 4, 6, 8] + + // technically not:greaterThanInclusive is the same as lessThan + const op = engine.operators.get('everyFact:someValue:not:greaterThanInclusive') + expect(op.evaluate(odds, evens)).to.be.true() + }) + }) + + it('the swap decorator', () => { + const factValue = 1; + const jsonValue = [1, 2, 3]; + + const op = engine.operators.get('swap:contains'); + expect(op.evaluate(factValue, jsonValue)).to.be.true() + }) +}) diff --git a/test/engine.test.js b/test/engine.test.js index 60304662..8ad3ca74 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -5,8 +5,6 @@ import engineFactory, { Fact, Rule, Operator } from '../src/index' import defaultOperators from '../src/engine-default-operators' describe('Engine', () => { - const operatorCount = defaultOperators.length - let engine let sandbox before(() => { @@ -34,7 +32,9 @@ describe('Engine', () => { it('initializes with the default state', () => { expect(engine.status).to.equal('READY') expect(engine.rules.length).to.equal(0) - expect(engine.operators.size).to.equal(operatorCount) + defaultOperators.forEach(op => { + expect(engine.operators.get(op.name)).to.be.an.instanceof(Operator) + }) }) it('can be initialized with rules', () => { @@ -199,37 +199,31 @@ describe('Engine', () => { describe('addOperator()', () => { it('adds the operator', () => { - expect(engine.operators.size).to.equal(operatorCount) engine.addOperator('startsWithLetter', (factValue, jsonValue) => { return factValue[0] === jsonValue }) - expect(engine.operators.size).to.equal(operatorCount + 1) expect(engine.operators.get('startsWithLetter')).to.exist() expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator) }) it('accepts an operator instance', () => { - expect(engine.operators.size).to.equal(operatorCount) const op = new Operator('my-operator', _ => true) engine.addOperator(op) - expect(engine.operators.size).to.equal(operatorCount + 1) expect(engine.operators.get('my-operator')).to.equal(op) }) }) describe('removeOperator()', () => { it('removes the operator', () => { - expect(engine.operators.size).to.equal(operatorCount) engine.addOperator('startsWithLetter', (factValue, jsonValue) => { return factValue[0] === jsonValue }) - expect(engine.operators.size).to.equal(operatorCount + 1) + expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator) engine.removeOperator('startsWithLetter') - expect(engine.operators.size).to.equal(operatorCount) + expect(engine.operators.get('startsWithLetter')).to.be.null() }) it('can only remove added operators', () => { - expect(engine.operators.size).to.equal(operatorCount) const isRemoved = engine.removeOperator('nonExisting') expect(isRemoved).to.equal(false) }) diff --git a/test/operator-decorator.test.js b/test/operator-decorator.test.js new file mode 100644 index 00000000..3c10ff4d --- /dev/null +++ b/test/operator-decorator.test.js @@ -0,0 +1,40 @@ +'use strict' + +import { OperatorDecorator, Operator } from '../src/index' + +const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => { + return factValue[0] === jsonValue +}) + +describe('OperatorDecorator', () => { + describe('constructor()', () => { + function subject (...args) { + return new OperatorDecorator(...args) + } + + it('adds the decorator', () => { + const decorator = subject('test', () => false) + expect(decorator.name).to.equal('test') + expect(decorator.cb).to.an.instanceof(Function) + }) + + it('decorator name', () => { + expect(() => { + subject() + }).to.throw(/Missing decorator name/) + }) + + it('decorator definition', () => { + expect(() => { + subject('test') + }).to.throw(/Missing decorator callback/) + }) + }) + + describe('decorating', () => { + const subject = new OperatorDecorator('test', () => false).decorate(startsWithLetter) + it('creates a new operator with the prefixed name', () => { + expect(subject.name).to.equal('test:startsWithLetter') + }) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 15b61fb8..d8af3ecc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -42,6 +42,10 @@ export class Engine { ): void; removeOperator(operator: Operator | string): boolean; + addOperatorDecorator(decorator: OperatorDecorator): void; + addOperatorDecorator(decoratorName: string, callback: OperatorDecoratorEvaluator): void; + removeOperatorDecorator(decorator: OperatorDecorator | string): boolean; + addFact(fact: Fact): this; addFact( id: string, @@ -72,6 +76,19 @@ export class Operator { ); } +export interface OperatorDecoratorEvaluator { + (factValue: A, compareToValue: B, next: OperatorEvaluator): boolean +} + +export class OperatorDecorator { + public name: string; + constructor( + name: string, + evaluator: OperatorDecoratorEvaluator, + validator?: (factValue: A) => boolean + ) +} + export class Almanac { constructor(options?: AlmanacOptions); factValue( diff --git a/types/index.test-d.ts b/types/index.test-d.ts index f4ae3c15..13b7460c 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -7,6 +7,8 @@ import rulesEngine, { Fact, Operator, OperatorEvaluator, + OperatorDecorator, + OperatorDecoratorEvaluator, PathResolver, Rule, RuleProperties, @@ -83,6 +85,23 @@ const operator: Operator = new Operator( expectType(engine.addOperator(operator)); expectType(engine.removeOperator(operator)); +// Operator Decorator tests +const operatorDecoratorEvaluator: OperatorDecoratorEvaluator = ( + a: number[], + b: number, + next: OperatorEvaluator +) => next(a[0], b); +expectType( + engine.addOperatorDecorator("first", operatorDecoratorEvaluator) +); +const operatorDecorator: OperatorDecorator = new OperatorDecorator( + "first", + operatorDecoratorEvaluator, + (a: number[]) => a.length > 0 +); +expectType(engine.addOperatorDecorator(operatorDecorator)); +expectType(engine.removeOperatorDecorator(operatorDecorator)); + // Fact tests const fact = new Fact("test-fact", 3); const dynamicFact = new Fact("test-fact", () => [42]); From 36987ef89f6c7d8304065082008e45c6114abd70 Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Fri, 11 Oct 2024 09:45:47 -0400 Subject: [PATCH 36/65] Removed Lodash from almanac.js The lodash package is being brought in via a transitive depenendcy, remove it entirely and in-line the method --- src/almanac.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/almanac.js b/src/almanac.js index 572336a6..610e2808 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -5,7 +5,6 @@ import { UndefinedFactError } from './errors' import debug from './debug' import { JSONPath } from 'jsonpath-plus' -import isObjectLike from 'lodash/isObjectLike' function defaultPathResolver (value, path) { return JSONPath({ path, json: value, wrap: false }) @@ -161,7 +160,7 @@ export default class Almanac { debug(`condition::evaluate extracting object property ${path}`) return factValuePromise .then(factValue => { - if (isObjectLike(factValue)) { + if (factValue != null && typeof factValue === 'object') { const pathValue = this.pathResolver(factValue, path) debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`) return pathValue @@ -179,7 +178,7 @@ export default class Almanac { * 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' } + if (value != null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' } return this.factValue(value.fact, value.params, value.path) } return Promise.resolve(value) From c39b45a824cfe4614313ec0fc76de01ea167ea2b Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Fri, 11 Oct 2024 09:47:28 -0400 Subject: [PATCH 37/65] Remove Lodash from rule result Lodash is a transitive dependency, instead of relying on it just in-line the simple isObjectLike code --- src/rule-result.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rule-result.js b/src/rule-result.js index 79a6c75f..13171de4 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -1,7 +1,6 @@ 'use strict' import deepClone from 'clone' -import isObject from 'lodash/isObjectLike' export default class RuleResult { constructor (conditions, event, priority, name) { @@ -17,7 +16,7 @@ export default class RuleResult { } resolveEventParams (almanac) { - if (isObject(this.event.params)) { + if (this.event.params != null && typeof this.event.params === 'object') { const updates = [] for (const key in this.event.params) { if (Object.prototype.hasOwnProperty.call(this.event.params, key)) { From cff1b67bc711474adc20fc2ba0075e8a5626ae93 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Sat, 10 Aug 2024 18:18:32 -0500 Subject: [PATCH 38/65] updated on method overload to include generic method call --- types/index.d.ts | 8 +++----- types/index.test-d.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index c6ace7fc..3c0060eb 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -51,9 +51,7 @@ export class Engine { removeFact(factOrId: string | Fact): boolean; getFact(factId: string): Fact; - on(eventName: "success", handler: EventHandler): this; - on(eventName: "failure", handler: EventHandler): this; - on(eventName: string, handler: EventHandler): this; + on(eventName: string, handler: EventHandler): this; run(facts?: Record, runOptions?: RunOptions): Promise; stop(): this; @@ -119,8 +117,8 @@ export interface Event { export type PathResolver = (value: object, path: string) => any; -export type EventHandler = ( - event: Event, +export type EventHandler = ( + event: T, almanac: Almanac, ruleResult: RuleResult ) => void; diff --git a/types/index.test-d.ts b/types/index.test-d.ts index dcf55419..20fb5508 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -4,12 +4,14 @@ import rulesEngine, { Almanac, EngineResult, Engine, + Event, Fact, Operator, OperatorEvaluator, PathResolver, Rule, RuleProperties, + RuleResult, RuleSerializable } from "../"; @@ -93,6 +95,16 @@ expectType(engine.addFact(fact)); expectType(engine.addFact(dynamicFact)); expectType(engine.removeFact(fact)); expectType>(engine.getFact("test")); +engine.on('success', (event, almanac, ruleResult) => { + expectType(event) + expectType(almanac) + expectType(ruleResult) +}) +engine.on<{ foo: Array }>('foo', (event, almanac, ruleResult) => { + expectType<{ foo: Array }>(event) + expectType(almanac) + expectType(ruleResult) +}) // Run the Engine expectType>(engine.run({ displayMessage: true })); From ed0429cc4826ffb93e251291d59d82032f451985 Mon Sep 17 00:00:00 2001 From: Amer Trkic Date: Mon, 14 Oct 2024 09:39:03 +0200 Subject: [PATCH 39/65] Commit @jagernet-ops suggestion to use use the instanceof operator --- src/almanac.js | 2 +- src/rule-result.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/almanac.js b/src/almanac.js index 610e2808..355fb900 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -178,7 +178,7 @@ export default class Almanac { * Interprets value as either a primitive, or if a fact, retrieves the fact value */ getValue (value) { - if (value != null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' } + if (Boolean(value instanceof Object) && 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/rule-result.js b/src/rule-result.js index 13171de4..5e811a98 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -16,7 +16,7 @@ export default class RuleResult { } resolveEventParams (almanac) { - if (this.event.params != null && typeof this.event.params === 'object') { + if (this.event.params instanceof Object) { const updates = [] for (const key in this.event.params) { if (Object.prototype.hasOwnProperty.call(this.event.params, key)) { From 6ede34eecbbff666eb12fafb90722baa0a689894 Mon Sep 17 00:00:00 2001 From: Amer Trkic Date: Mon, 14 Oct 2024 15:31:12 +0200 Subject: [PATCH 40/65] Based on @chris-pardy suggestion using typeof because its more performant and reliable --- src/almanac.js | 2 +- src/rule-result.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/almanac.js b/src/almanac.js index 355fb900..610e2808 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -178,7 +178,7 @@ export default class Almanac { * Interprets value as either a primitive, or if a fact, retrieves the fact value */ getValue (value) { - if (Boolean(value instanceof Object) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' } + if (value != null && typeof value === 'object' && 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/rule-result.js b/src/rule-result.js index 5e811a98..09350c7e 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -16,7 +16,7 @@ export default class RuleResult { } resolveEventParams (almanac) { - if (this.event.params instanceof Object) { + if (this.event.params !== null && typeof this.event.params === 'object') { const updates = [] for (const key in this.event.params) { if (Object.prototype.hasOwnProperty.call(this.event.params, key)) { From 9b603f5149f4c467c1e1db3bdfe4d6288545285c Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Thu, 10 Oct 2024 23:46:42 -0400 Subject: [PATCH 41/65] Significantly simplify the prioritizeAndRun Method Significantly simplify the method by leveraging the short-circuiting of the || and && operators. Replaces #368 Co-Author Mahi --- src/rule.js | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/src/rule.js b/src/rule.js index c893a143..cdbc5cb6 100644 --- a/src/rule.js +++ b/src/rule.js @@ -274,36 +274,16 @@ class Rule extends EventEmitter { // this also covers the 'not' case which should only ever have a single condition return evaluateCondition(conditions[0]) } - let method = Array.prototype.some - if (operator === 'all') { - method = Array.prototype.every - } const orderedSets = this.prioritizeConditions(conditions) - let cursor = Promise.resolve() + let cursor = Promise.resolve(operator === 'all') // use for() loop over Array.forEach to support IE8 without polyfill for (let i = 0; i < orderedSets.length; i++) { const set = orderedSets[i] - let stop = false cursor = cursor.then((setResult) => { - // after the first set succeeds, don't fire off the remaining promises - if ((operator === 'any' && setResult === true) || stop) { - debug( - 'prioritizeAndRun::detected truthy result; skipping remaining conditions' - ) - stop = true - return true - } - - // after the first set fails, don't fire off the remaining promises - if ((operator === 'all' && setResult === false) || stop) { - debug( - 'prioritizeAndRun::detected falsey result; skipping remaining conditions' - ) - stop = true - return false - } - // all conditions passed; proceed with running next set in parallel - return evaluateConditions(set, method) + // rely on the short-circuiting behavior of || and && to avoid evaluating subsequent conditions + return operator === 'any' + ? (setResult || evaluateConditions(set, Array.prototype.some)) + : (setResult && evaluateConditions(set, Array.prototype.every)) }) } return cursor From 86d210dfcd4476f211053c72664f36bedd48c4a5 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Fri, 11 Oct 2024 00:24:36 -0400 Subject: [PATCH 42/65] Improvements To Debug Change the call signature, leverage built-in formatting on console. Move debug enablement checks to file load. Replaces #363 Resolves #362 Co-Author Nirmal Patel --- examples/13-using-operator-decorators.js | 5 ++--- src/almanac.js | 14 +++++++------- src/condition.js | 9 ++++++--- src/debug.js | 8 ++++++-- src/engine.js | 8 ++++---- src/operator-map.js | 8 ++++---- src/rule.js | 2 +- test/engine-operator-map.test.js | 6 +++--- 8 files changed, 33 insertions(+), 27 deletions(-) diff --git a/examples/13-using-operator-decorators.js b/examples/13-using-operator-decorators.js index fbfeaa53..413271eb 100644 --- a/examples/13-using-operator-decorators.js +++ b/examples/13-using-operator-decorators.js @@ -79,12 +79,11 @@ async function start () { } } - engine.addRule(caseInsensitiveValidTags); + engine.addRule(caseInsensitiveValidTags) // third run with a tag that is valid if case insensitive facts = { tags: ['dev', 'PROD'] } - await engine.run(facts); - + await engine.run(facts) } start() diff --git a/src/almanac.js b/src/almanac.js index 58cc2432..ee9451ae 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -106,7 +106,7 @@ export default class Almanac { } else { fact = new Fact(id, valueOrMethod, options) } - debug(`almanac::addFact id:${factId}`) + debug('almanac::addFact', { id: factId }) this.factMap.set(factId, fact) if (fact.isConstant()) { this._setFactValue(fact, {}, fact.value) @@ -121,7 +121,7 @@ export default class Almanac { * @param {Mixed} value - constant value of the fact */ addRuntimeFact (factId, value) { - debug(`almanac::addRuntimeFact id:${factId}`) + debug('almanac::addRuntimeFact', { id: factId }) const fact = new Fact(factId, value) return this._addConstantFact(fact) } @@ -151,22 +151,22 @@ export default class Almanac { const cacheVal = cacheKey && this.factResultsCache.get(cacheKey) if (cacheVal) { factValuePromise = Promise.resolve(cacheVal) - debug(`almanac::factValue cache hit for fact:${factId}`) + debug('almanac::factValue cache hit for fact', { id: factId }) } else { - debug(`almanac::factValue cache miss for fact:${factId}; calculating`) + debug('almanac::factValue cache miss, calculating', { id: factId }) factValuePromise = this._setFactValue(fact, params, fact.calculate(params, this)) } } if (path) { - debug(`condition::evaluate extracting object property ${path}`) + debug('condition::evaluate extracting object', { property: path }) return factValuePromise .then(factValue => { if (isObjectLike(factValue)) { const pathValue = this.pathResolver(factValue, path) - debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`) + debug('condition::evaluate extracting object', { property: path, received: pathValue }) return pathValue } else { - debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`) + debug('condition::evaluate could not compute object path of non-object', { path, factValue, type: typeof factValue }) return factValue } }) diff --git a/src/condition.js b/src/condition.js index 5f5775dc..28a86a36 100644 --- a/src/condition.js +++ b/src/condition.js @@ -101,9 +101,12 @@ export default class Condition { ]).then(([rightHandSideValue, leftHandSideValue]) => { const result = op.evaluate(leftHandSideValue, rightHandSideValue) debug( - `condition::evaluate <${JSON.stringify(leftHandSideValue)} ${ - this.operator - } ${JSON.stringify(rightHandSideValue)}?> (${result})` + 'condition::evaluate', { + leftHandSideValue, + operator: this.operator, + rightHandSideValue, + result + } ) return { result, diff --git a/src/debug.js b/src/debug.js index 7bd54777..d8744ca9 100644 --- a/src/debug.js +++ b/src/debug.js @@ -1,10 +1,14 @@ -export default function debug (message) { + +function createDebug () { try { if ((typeof process !== 'undefined' && process.env && process.env.DEBUG && process.env.DEBUG.match(/json-rules-engine/)) || (typeof window !== 'undefined' && window.localStorage && window.localStorage.debug && window.localStorage.debug.match(/json-rules-engine/))) { - console.log(message) + return console.debug.bind(console) } } catch (ex) { // Do nothing } + return () => {} } + +export default createDebug() diff --git a/src/engine.js b/src/engine.js index 4bd77cab..4cb8751e 100644 --- a/src/engine.js +++ b/src/engine.js @@ -172,7 +172,7 @@ class Engine extends EventEmitter { } else { fact = new Fact(id, valueOrMethod, options) } - debug(`engine::addFact id:${factId}`) + debug('engine::addFact', { id: factId }) this.facts.set(factId, fact) return this } @@ -241,11 +241,11 @@ class Engine extends EventEmitter { evaluateRules (ruleArray, almanac) { return Promise.all(ruleArray.map((rule) => { if (this.status !== RUNNING) { - debug(`engine::run status:${this.status}; skipping remaining rules`) + debug('engine::run, skipping remaining rules', { status: this.status }) return Promise.resolve() } return rule.evaluate(almanac).then((ruleResult) => { - debug(`engine::run ruleResult:${ruleResult.result}`) + debug('engine::run', { ruleResult: ruleResult.result }) almanac.addResult(ruleResult) if (ruleResult.result) { almanac.addEvent(ruleResult.event, 'success') @@ -286,7 +286,7 @@ class Engine extends EventEmitter { } almanac.addFact(fact) - debug(`engine::run initialized runtime fact:${fact.id} with ${fact.value}<${typeof fact.value}>`) + debug('engine::run initialized runtime fact', { id: fact.id, value: fact.value, type: typeof fact.value }) } const orderedSets = this.prioritizeRules() let cursor = Promise.resolve() diff --git a/src/operator-map.js b/src/operator-map.js index 9e00c0e1..741e302c 100644 --- a/src/operator-map.js +++ b/src/operator-map.js @@ -22,7 +22,7 @@ export default class OperatorMap { } else { operator = new Operator(operatorOrName, cb) } - debug(`operatorMap::addOperator name:${operator.name}`) + debug('operatorMap::addOperator', { name: operator.name }) this.operators.set(operator.name, operator) } @@ -64,7 +64,7 @@ export default class OperatorMap { } else { decorator = new OperatorDecorator(decoratorOrName, cb) } - debug(`operatorMap::addOperatorDecorator name:${decorator.name}`) + debug('operatorMap::addOperatorDecorator', { name: decorator.name }) this.decorators.set(decorator.name, decorator) } @@ -110,7 +110,7 @@ export default class OperatorMap { const decoratorName = opName.slice(0, firstDecoratorIndex) const decorator = this.decorators.get(decoratorName) if (!decorator) { - debug(`operatorMap::get invalid decorator named ${decoratorName}`) + debug('operatorMap::get invalid decorator', { name: decoratorName }) return null } // we're going to apply this later, use unshift since we'll apply in reverse order @@ -118,7 +118,7 @@ export default class OperatorMap { // continue looking for a known operator with the rest of the name opName = opName.slice(firstDecoratorIndex + 1) } else { - debug(`operatorMap::get no operator named ${opName}`) + debug('operatorMap::get no operator', { name: opName }) return null } } diff --git a/src/rule.js b/src/rule.js index cdbc5cb6..39e5a327 100644 --- a/src/rule.js +++ b/src/rule.js @@ -249,7 +249,7 @@ class Rule extends EventEmitter { return Promise.all( conditions.map((condition) => evaluateCondition(condition)) ).then((conditionResults) => { - debug('rule::evaluateConditions results', conditionResults) + debug('rule::evaluateConditions', { results: conditionResults }) return method.call(conditionResults, (result) => result === true) }) } diff --git a/test/engine-operator-map.test.js b/test/engine-operator-map.test.js index a54afa2a..b5ae9672 100644 --- a/test/engine-operator-map.test.js +++ b/test/engine-operator-map.test.js @@ -77,10 +77,10 @@ describe('Engine Operator Map', () => { }) it('the swap decorator', () => { - const factValue = 1; - const jsonValue = [1, 2, 3]; + const factValue = 1 + const jsonValue = [1, 2, 3] - const op = engine.operators.get('swap:contains'); + const op = engine.operators.get('swap:contains') expect(op.evaluate(factValue, jsonValue)).to.be.true() }) }) From ec38ce0622fbc14de3a80f6a47b3901003a7a15e Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Thu, 17 Oct 2024 10:56:41 -0400 Subject: [PATCH 43/65] Version 6.6.0 Update the minor version to 6.6.0 to release the Operator Decorator feature and other fixes. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 69297949..43686dd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.5.0", + "version": "6.6.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From f1bf29f3cede1f3df6b83e6939af047a53680d16 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Tue, 22 Oct 2024 10:12:33 -0400 Subject: [PATCH 44/65] Increment Major Version Increment the Major Version Number and setup deployment --- .github/workflows/deploy.yml | 2 +- .github/workflows/node.js.yml | 2 +- package.json | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 646a2d96..8a4626c2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ name: Deploy Package on: push: - branches: [ master ] + branches: [ master, v7 ] jobs: build: diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 1ad05a66..8317332a 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,7 +5,7 @@ name: Node.js CI on: pull_request: - branches: [ master, next-major ] + branches: [ master, v7 ] jobs: build: diff --git a/package.json b/package.json index 43686dd8..8271b7a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "6.6.0", + "version": "7.0.0-alpha.1", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -23,6 +23,9 @@ "engine", "rules engine" ], + "publishConfig": { + "tag": "next" + }, "standard": { "parser": "babel-eslint", "ignore": [ From c26e19a8a72aff04a34018fb40aa129f2febecd1 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Thu, 24 Oct 2024 14:04:47 -0400 Subject: [PATCH 45/65] Upgrade JSON-Path-Plus Upgrade the version of JSON-Path-Plus and set a new minimum node version --- .github/workflows/deploy.yml | 2 +- .github/workflows/node.js.yml | 2 +- package.json | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 646a2d96..4eee134e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - node-version: [14.x, 16.x, 18.x] + node-version: [18.x, 20.x, 22.x] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 1ad05a66..96b690e6 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - node-version: [14.x, 16.x, 18.x] + node-version: [18.x, 20.x, 22.x] steps: - uses: actions/checkout@v2 diff --git a/package.json b/package.json index 43686dd8..9515bd87 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", + "engines": { + "node": ">=18.0.0" + }, "scripts": { "test": "mocha && npm run lint --silent && npm run test:types", "test:types": "tsd", @@ -89,6 +92,6 @@ "clone": "^2.1.2", "eventemitter2": "^6.4.4", "hash-it": "^6.0.0", - "jsonpath-plus": "^7.2.0" + "jsonpath-plus": "^10.0.0" } } From 69bbc2705495019154e8c7438a075f784a5e2fee Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Thu, 24 Oct 2024 17:33:13 -0400 Subject: [PATCH 46/65] Release V7 --- .github/workflows/deploy.yml | 2 +- .github/workflows/node.js.yml | 2 +- package.json | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c7818e5..305277ba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ name: Deploy Package on: push: - branches: [ master, v7 ] + branches: [ master, v6 ] jobs: build: diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 8c72f737..92ee104b 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,7 +5,7 @@ name: Node.js CI on: pull_request: - branches: [ master, v7 ] + branches: [ master, v6 ] jobs: build: diff --git a/package.json b/package.json index 812756a1..8f7aa861 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "7.0.0-alpha.1", + "version": "7.0.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -26,9 +26,6 @@ "engine", "rules engine" ], - "publishConfig": { - "tag": "next" - }, "standard": { "parser": "babel-eslint", "ignore": [ From 1d00beff4e4f000e623ca0b3885e3f7749d5dfce Mon Sep 17 00:00:00 2001 From: Emile Fokkema <146825801+emilefokkemanavara@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:25:12 +0000 Subject: [PATCH 47/65] Squashed commit of the following: commit 1356b48980e1f8a134f1adddd6b15ad168a8ffdd Author: Emile Fokkema Date: Sun Nov 10 20:08:27 2024 +0100 result and factResult are not always there commit 725fb66df8bba2644a8c5531dd689ac7163f1a8e Author: Emile Fokkema Date: Sat Nov 9 20:07:13 2024 +0100 condition reference can be part of the result commit cb5f3c7206c98325bfbe9c5fd5d6b9b4e4c82ae2 Author: Emile Fokkema <146825801+emilefokkemanavara@users.noreply.github.com> Date: Fri Nov 8 16:27:34 2024 +0000 there are no condition references in the result commit c68869253481b10416d2e15c5186afdcb7ca8da5 Author: Emile Fokkema <146825801+emilefokkemanavara@users.noreply.github.com> Date: Fri Nov 8 16:27:09 2024 +0000 begin to add condition result types commit 7a90a81656b4449bdffd587871c0315472879894 Author: Emile Fokkema <146825801+emilefokkemanavara@users.noreply.github.com> Date: Fri Nov 8 16:26:42 2024 +0000 add toJSON method to RuleResult --- test/engine-event.test.js | 64 +++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 41 ++++++++++++++++++++++++- types/index.test-d.ts | 21 +++++++++++-- 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/test/engine-event.test.js b/test/engine-event.test.js index c929d92e..d86f75cb 100644 --- a/test/engine-event.test.js +++ b/test/engine-event.test.js @@ -20,6 +20,10 @@ describe('Engine: event', () => { canOrderDrinks: true } } + + const awesomeEvent = { + type: 'awesome' + } /** * sets up a simple 'any' rule with 2 conditions */ @@ -92,6 +96,46 @@ describe('Engine: event', () => { engine.addFact('gender', 'male') // gender succeeds } + function setupWithConditionReference () { + const conditionName = 'awesomeCondition' + const conditions = { + any: [{ condition: conditionName }] + } + engine = engineFactory() + const ruleOptions = { conditions, event: awesomeEvent, priority: 100 } + const rule = factories.rule(ruleOptions) + engine.addRule(rule) + engine.setCondition(conditionName, { + all: [{ + name: 'over 21', + fact: 'age', + operator: 'greaterThanInclusive', + value: 21 + }] + }) + engine.addFact('age', 21) + } + + function setupWithUndefinedCondition () { + const conditionName = 'conditionThatIsNotDefined' + const conditions = { + any: [ + { condition: conditionName, name: 'nameOfTheUndefinedConditionReference' }, + { + name: 'over 21', + fact: 'age', + operator: 'greaterThanInclusive', + value: 21 + } + ] + } + engine = engineFactory([], { allowUndefinedConditions: true }) + const ruleOptions = { conditions, event: awesomeEvent, priority: 100 } + const rule = factories.rule(ruleOptions) + engine.addRule(rule) + engine.addFact('age', 21) + } + context('engine events: simple', () => { beforeEach(() => simpleSetup()) @@ -602,4 +646,24 @@ describe('Engine: event', () => { expect(JSON.stringify(ruleResult)).to.equal(expected) }) }) + + context('rule events: json serializing with condition reference', () => { + beforeEach(() => setupWithConditionReference()) + it('serializes properties', async () => { + const { results: [ruleResult] } = await engine.run() + const expected = '{"conditions":{"priority":1,"any":[{"priority":1,"all":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true}]}]},"event":{"type":"awesome"},"priority":100,"result":true}' + expect(JSON.stringify(ruleResult)).to.equal(expected) + }) + }) + + context('rule events: json serializing with condition reference that is undefined', () => { + beforeEach(() => setupWithUndefinedCondition()) + it('serializes properties', async () => { + const { results: [ruleResult] } = await engine.run() + const { conditions: { any: [conditionReference] } } = ruleResult + expect(conditionReference.result).to.equal(false) + const expected = '{"conditions":{"priority":1,"any":[{"name":"nameOfTheUndefinedConditionReference","condition":"conditionThatIsNotDefined"},{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true}]},"event":{"type":"awesome"},"priority":100,"result":true}' + expect(JSON.stringify(ruleResult)).to.equal(expected) + }) + }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 81f08dcb..827e79ff 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -153,12 +153,22 @@ export type RuleSerializable = Pick< "conditions" | "event" | "name" | "priority" >; +export type RuleResultSerializable = Pick< + Required, + "name" | "event" | "priority" | "result"> & { + conditions: TopLevelConditionResultSerializable + } + export interface RuleResult { name: string; - conditions: TopLevelCondition; + conditions: TopLevelConditionResult; event?: Event; priority?: number; result: any; + toJSON(): string; + toJSON( + stringify: T + ): T extends true ? string : RuleResultSerializable; } export class Rule implements RuleProperties { @@ -176,6 +186,14 @@ export class Rule implements RuleProperties { ): T extends true ? string : RuleSerializable; } +interface BooleanConditionResultProperties { + result?: boolean +} + +interface ConditionResultProperties extends BooleanConditionResultProperties { + factResult?: unknown +} + interface ConditionProperties { fact: string; operator: string; @@ -186,25 +204,46 @@ interface ConditionProperties { name?: string; } +type ConditionPropertiesResult = ConditionProperties & ConditionResultProperties + type NestedCondition = ConditionProperties | TopLevelCondition; +type NestedConditionResult = ConditionPropertiesResult | TopLevelConditionResult; type AllConditions = { all: NestedCondition[]; name?: string; priority?: number; }; +type AllConditionsResult = AllConditions & { + all: NestedConditionResult[] +} & BooleanConditionResultProperties type AnyConditions = { any: NestedCondition[]; name?: string; priority?: number; }; +type AnyConditionsResult = AnyConditions & { + any: NestedConditionResult[] +} & BooleanConditionResultProperties type NotConditions = { not: NestedCondition; name?: string; priority?: number }; +type NotConditionsResult = NotConditions & {not: NestedConditionResult} & BooleanConditionResultProperties; type ConditionReference = { condition: string; name?: string; priority?: number; }; +type ConditionReferenceResult = ConditionReference & BooleanConditionResultProperties export type TopLevelCondition = | AllConditions | AnyConditions | NotConditions | ConditionReference; +export type TopLevelConditionResult = + | AllConditionsResult + | AnyConditionsResult + | NotConditionsResult + | ConditionReferenceResult +export type TopLevelConditionResultSerializable = + | AllConditionsResult + | AnyConditionsResult + | NotConditionsResult + | ConditionReference diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 6d5b37b1..a7e11202 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -14,7 +14,11 @@ import rulesEngine, { Rule, RuleProperties, RuleResult, - RuleSerializable + RuleSerializable, + TopLevelConditionResult, + AnyConditionsResult, + AllConditionsResult, + NotConditionsResult } from "../"; // setup basic fixture data @@ -126,7 +130,20 @@ engine.on<{ foo: Array }>('foo', (event, almanac, ruleResult) => { }) // Run the Engine -expectType>(engine.run({ displayMessage: true })); +const result = engine.run({ displayMessage: true }) +expectType>(result); + +const topLevelConditionResult = result.then(r => r.results[0].conditions); +expectType>(topLevelConditionResult) + +const topLevelAnyConditionsResult = topLevelConditionResult.then(r => (r as AnyConditionsResult).result); +expectType>(topLevelAnyConditionsResult) + +const topLevelAllConditionsResult = topLevelConditionResult.then(r => (r as AllConditionsResult).result); +expectType>(topLevelAllConditionsResult) + +const topLevelNotConditionsResult = topLevelConditionResult.then(r => (r as NotConditionsResult).result); +expectType>(topLevelNotConditionsResult) // Alamanac tests const almanac: Almanac = (await engine.run()).almanac; From 8aec4e0deebcce4fe2453ee8092347d5a6c64c35 Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Tue, 12 Nov 2024 10:49:00 -0500 Subject: [PATCH 48/65] Upgrade Version Number to deploy 7.1.0 Upgrade the Version Number to version 7.1.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8f7aa861..a34e958d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "7.0.0", + "version": "7.1.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -59,7 +59,7 @@ }, "author": "Cache Hamm ", "contributors": [ - "Chris Pardy " + "Chris Pardy " ], "license": "ISC", "bugs": { From 8f978b99c36cdb66fe3246b3a48ed1d6084a78b9 Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Tue, 12 Nov 2024 13:01:14 -0500 Subject: [PATCH 49/65] Upgrade JSON Path Plus to 10.1.0 Resolves #394 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a34e958d..cb04ff40 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,6 @@ "clone": "^2.1.2", "eventemitter2": "^6.4.4", "hash-it": "^6.0.0", - "jsonpath-plus": "^10.0.0" + "jsonpath-plus": "^10.1.0" } } From 0d35a5812d4fb3243625d347f08d8b5963a23e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?iago=20sardi=C3=B1a?= Date: Sat, 16 Nov 2024 18:42:33 +0100 Subject: [PATCH 50/65] Add factResult to fact-comparison values --- src/rule.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/rule.js b/src/rule.js index 39e5a327..035b8bd7 100644 --- a/src/rule.js +++ b/src/rule.js @@ -231,6 +231,9 @@ class Rule extends EventEmitter { .then((evaluationResult) => { const passes = evaluationResult.result condition.factResult = evaluationResult.leftHandSideValue + if(typeof condition.value === 'object' && condition.value !== null){ + condition.value.factResult = evaluationResult.rightHandSideValue; + } condition.result = passes return passes }) From eeafc9406e38b5de348a86fd0c75a2bcc06603b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iago=20Sardi=C3=B1a?= Date: Sat, 16 Nov 2024 18:49:20 +0100 Subject: [PATCH 51/65] Fix test and comply with styling --- src/rule.js | 4 ++-- test/acceptance/acceptance.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/rule.js b/src/rule.js index 035b8bd7..eb30080e 100644 --- a/src/rule.js +++ b/src/rule.js @@ -231,8 +231,8 @@ class Rule extends EventEmitter { .then((evaluationResult) => { const passes = evaluationResult.result condition.factResult = evaluationResult.leftHandSideValue - if(typeof condition.value === 'object' && condition.value !== null){ - condition.value.factResult = evaluationResult.rightHandSideValue; + if (typeof condition.value === 'object' && condition.value !== null) { + condition.value.factResult = evaluationResult.rightHandSideValue } condition.result = passes return passes diff --git a/test/acceptance/acceptance.js b/test/acceptance/acceptance.js index fb68cc77..5b7b5c96 100644 --- a/test/acceptance/acceptance.js +++ b/test/acceptance/acceptance.js @@ -210,6 +210,7 @@ describe('Acceptance', () => { result: true, value: { fact: 'rule-created-fact', + factResult: 2, path: '$.array' } } From 33eb4d2282653601fdabcf7a50017e2df299ac32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iago=20Sardi=C3=B1a?= Date: Sun, 17 Nov 2024 12:45:26 +0100 Subject: [PATCH 52/65] Add valueResult unconditionally and more testing --- src/rule.js | 4 +--- test/acceptance/acceptance.js | 10 ++++++++-- types/index.d.ts | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/rule.js b/src/rule.js index eb30080e..3d52fbf3 100644 --- a/src/rule.js +++ b/src/rule.js @@ -231,9 +231,7 @@ class Rule extends EventEmitter { .then((evaluationResult) => { const passes = evaluationResult.result condition.factResult = evaluationResult.leftHandSideValue - if (typeof condition.value === 'object' && condition.value !== null) { - condition.value.factResult = evaluationResult.rightHandSideValue - } + condition.valueResult = evaluationResult.rightHandSideValue condition.result = passes return passes }) diff --git a/test/acceptance/acceptance.js b/test/acceptance/acceptance.js index 5b7b5c96..ca470e27 100644 --- a/test/acceptance/acceptance.js +++ b/test/acceptance/acceptance.js @@ -36,6 +36,7 @@ describe('Acceptance', () => { path: '$.values', value: 2, factResult: [2], + valueResult: 2, result: true }, { @@ -43,6 +44,7 @@ describe('Acceptance', () => { operator: 'in', value: [2], factResult: 2, + valueResult: [2], result: true } ], @@ -169,7 +171,8 @@ describe('Acceptance', () => { }, path: '$.values', result: true, - value: 2 + value: 2, + valueResult: 2 }, { fact: 'low-priority', @@ -178,6 +181,9 @@ describe('Acceptance', () => { result: true, value: [ 2 + ], + valueResult: [ + 2 ] } ], @@ -202,6 +208,7 @@ describe('Acceptance', () => { factResult: [ 2 ], + valueResult: 2, operator: 'containsDivisibleValuesOf', params: { factParam: 1 @@ -210,7 +217,6 @@ describe('Acceptance', () => { result: true, value: { fact: 'rule-created-fact', - factResult: 2, path: '$.array' } } diff --git a/types/index.d.ts b/types/index.d.ts index 827e79ff..6dea6b8d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -192,6 +192,7 @@ interface BooleanConditionResultProperties { interface ConditionResultProperties extends BooleanConditionResultProperties { factResult?: unknown + valueResult?: unknown } interface ConditionProperties { From babfb32f671617ce3f64b21104bf6f65c2438316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iago=20Sardi=C3=B1a?= Date: Sun, 17 Nov 2024 12:56:18 +0100 Subject: [PATCH 53/65] Test for valueResult in factComparison suite --- test/engine-fact-comparison.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/engine-fact-comparison.test.js b/test/engine-fact-comparison.test.js index 39af5903..feb08c88 100644 --- a/test/engine-fact-comparison.test.js +++ b/test/engine-fact-comparison.test.js @@ -118,4 +118,23 @@ describe('Engine: fact to fact comparison', () => { expect(eventSpy.callCount).to.equal(0) }) }) + + context('constant facts: checking valueResult and factResult', () => { + const constantCondition = { + all: [{ + fact: 'height', + operator: 'lessThanInclusive', + value: { + fact: 'width' + } + }] + } + it('result has the correct valueResult and factResult properties', async () => { + setup(constantCondition) + const result = await engine.run({ height: 1, width: 2 }) + + expect(result.results[0].conditions.all[0].factResult).to.equal(1) + expect(result.results[0].conditions.all[0].valueResult).to.equal(2) + }) + }) }) From 3678a58563b144e67b90fbda7e603a352bd6b568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iago=20Sardi=C3=B1a?= Date: Sun, 17 Nov 2024 13:13:22 +0100 Subject: [PATCH 54/65] Add valueResult to condition serializer --- src/condition.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/condition.js b/src/condition.js index 28a86a36..56689598 100644 --- a/src/condition.js +++ b/src/condition.js @@ -62,6 +62,9 @@ export default class Condition { if (this.factResult !== undefined) { props.factResult = this.factResult } + if (this.valueResult !== undefined) { + props.valueResult = this.valueResult + } if (this.result !== undefined) { props.result = this.result } From 8e8b3f17a4f9042cff79f378c799ec4e4c3ddd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iago=20Sardi=C3=B1a?= Date: Sun, 17 Nov 2024 13:15:05 +0100 Subject: [PATCH 55/65] Fix serialized tests --- test/engine-event.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/engine-event.test.js b/test/engine-event.test.js index d86f75cb..a4035dd8 100644 --- a/test/engine-event.test.js +++ b/test/engine-event.test.js @@ -642,7 +642,7 @@ describe('Engine: event', () => { await engine.run() const ruleResult = successSpy.getCall(0).args[2] const expected = - '{"conditions":{"priority":1,"any":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}' + '{"conditions":{"priority":1,"any":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"valueResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"valueResult":true,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}' expect(JSON.stringify(ruleResult)).to.equal(expected) }) }) @@ -651,7 +651,7 @@ describe('Engine: event', () => { beforeEach(() => setupWithConditionReference()) it('serializes properties', async () => { const { results: [ruleResult] } = await engine.run() - const expected = '{"conditions":{"priority":1,"any":[{"priority":1,"all":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true}]}]},"event":{"type":"awesome"},"priority":100,"result":true}' + const expected = '{"conditions":{"priority":1,"any":[{"priority":1,"all":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"valueResult":21,"result":true}]}]},"event":{"type":"awesome"},"priority":100,"result":true}' expect(JSON.stringify(ruleResult)).to.equal(expected) }) }) @@ -662,7 +662,7 @@ describe('Engine: event', () => { const { results: [ruleResult] } = await engine.run() const { conditions: { any: [conditionReference] } } = ruleResult expect(conditionReference.result).to.equal(false) - const expected = '{"conditions":{"priority":1,"any":[{"name":"nameOfTheUndefinedConditionReference","condition":"conditionThatIsNotDefined"},{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true}]},"event":{"type":"awesome"},"priority":100,"result":true}' + const expected = '{"conditions":{"priority":1,"any":[{"name":"nameOfTheUndefinedConditionReference","condition":"conditionThatIsNotDefined"},{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"valueResult":21,"result":true}]},"event":{"type":"awesome"},"priority":100,"result":true}' expect(JSON.stringify(ruleResult)).to.equal(expected) }) }) From eadcd732dc04fe8e88bea2077d901c76e291ae20 Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Mon, 18 Nov 2024 11:41:25 -0500 Subject: [PATCH 56/65] Bump Version to 7.2.0 New version 7.2.0 This will include changes to allow the resolved value of values to be present on the condition results. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb04ff40..7cf0d15a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "7.1.0", + "version": "7.2.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From 0d2c1c4191d06346de04205d679e2d70f5358ce1 Mon Sep 17 00:00:00 2001 From: danish Date: Thu, 21 Nov 2024 05:47:38 +0530 Subject: [PATCH 57/65] bump jsonpath-plus to v10.2.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cb04ff40..db5bfed5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "7.1.0", + "version": "7.2.1", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -92,6 +92,6 @@ "clone": "^2.1.2", "eventemitter2": "^6.4.4", "hash-it": "^6.0.0", - "jsonpath-plus": "^10.1.0" + "jsonpath-plus": "^10.2.0" } } From 933105f55f0ef08a61c543a1a76e2197b9557958 Mon Sep 17 00:00:00 2001 From: Emile Fokkema Date: Thu, 21 Nov 2024 22:37:58 +0100 Subject: [PATCH 58/65] call Rule.ruleEvent by its correct name --- types/index.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 6dea6b8d..0f88c834 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,7 +28,7 @@ export default function engineFactory( export class Engine { constructor(rules?: Array, options?: EngineOptions); - addRule(rule: RuleProperties): this; + addRule(rule: RuleProperties | Rule): this; removeRule(ruleOrName: Rule | string): boolean; updateRule(rule: Rule): void; @@ -171,11 +171,11 @@ export interface RuleResult { ): T extends true ? string : RuleResultSerializable; } -export class Rule implements RuleProperties { +export class Rule { constructor(ruleProps: RuleProperties | string); name: string; conditions: TopLevelCondition; - event: Event; + ruleEvent: Event; priority: number; setConditions(conditions: TopLevelCondition): this; setEvent(event: Event): this; From 16a80a7af4e610b51c8f7d348a0ac56bb2d9beca Mon Sep 17 00:00:00 2001 From: Emile Fokkema <146825801+emilefokkemanavara@users.noreply.github.com> Date: Wed, 18 Dec 2024 10:23:45 +0000 Subject: [PATCH 59/65] let Rule have both event and ruleEvent --- src/rule.js | 1 + test/rule.test.js | 4 ++++ types/index.d.ts | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/rule.js b/src/rule.js index 3d52fbf3..98007e8f 100644 --- a/src/rule.js +++ b/src/rule.js @@ -101,6 +101,7 @@ class Rule extends EventEmitter { this.ruleEvent = { type: event.type } + this.event = this.ruleEvent if (event.params) this.ruleEvent.params = event.params return this } diff --git a/test/rule.test.js b/test/rule.test.js index fc3357b8..75a5fee4 100644 --- a/test/rule.test.js +++ b/test/rule.test.js @@ -30,6 +30,7 @@ describe('Rule', () => { expect(rule.priority).to.eql(opts.priority) expect(rule.conditions).to.eql(opts.conditions) expect(rule.ruleEvent).to.eql(opts.event) + expect(rule.event).to.eql(opts.event) expect(rule.name).to.eql(opts.name) }) @@ -52,6 +53,7 @@ describe('Rule', () => { expect(rule.priority).to.eql(opts.priority) expect(rule.conditions).to.eql(opts.conditions) expect(rule.ruleEvent).to.eql(opts.event) + expect(rule.event).to.eql(opts.event) expect(rule.name).to.eql(opts.name) }) }) @@ -322,6 +324,7 @@ describe('Rule', () => { expect(hydratedRule.conditions).to.eql(rule.conditions) expect(hydratedRule.priority).to.eql(rule.priority) expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent) + expect(hydratedRule.event).to.eql(rule.event) expect(hydratedRule.name).to.eql(rule.name) }) @@ -333,6 +336,7 @@ describe('Rule', () => { expect(hydratedRule.conditions).to.eql(rule.conditions) expect(hydratedRule.priority).to.eql(rule.priority) expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent) + expect(hydratedRule.event).to.eql(rule.event) expect(hydratedRule.name).to.eql(rule.name) }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 0f88c834..2153f99a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,7 +28,7 @@ export default function engineFactory( export class Engine { constructor(rules?: Array, options?: EngineOptions); - addRule(rule: RuleProperties | Rule): this; + addRule(rule: RuleProperties): this; removeRule(ruleOrName: Rule | string): boolean; updateRule(rule: Rule): void; @@ -171,11 +171,12 @@ export interface RuleResult { ): T extends true ? string : RuleResultSerializable; } -export class Rule { +export class Rule implements RuleProperties { constructor(ruleProps: RuleProperties | string); name: string; conditions: TopLevelCondition; ruleEvent: Event; + event: Event priority: number; setConditions(conditions: TopLevelCondition): this; setEvent(event: Event): this; From 72d7b1aa1c806d1ed96763e6e3cbefec6d917ee6 Mon Sep 17 00:00:00 2001 From: Emile Fokkema <146825801+emilefokkemanavara@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:09:19 +0000 Subject: [PATCH 60/65] deprecate `ruleEvent` property --- types/index.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/types/index.d.ts b/types/index.d.ts index 2153f99a..aaa22f8a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -175,6 +175,9 @@ export class Rule implements RuleProperties { constructor(ruleProps: RuleProperties | string); name: string; conditions: TopLevelCondition; + /** + * @deprecated Use {@link Rule.event} instead. + */ ruleEvent: Event; event: Event priority: number; From 554b4416d01ceaa624c28ae60042bca7f1b9e46f Mon Sep 17 00:00:00 2001 From: chris-pardy Date: Wed, 18 Dec 2024 12:53:54 -0500 Subject: [PATCH 61/65] Increment minor version Increment Minor Version Number to release type fixes around ruleEvent property on the rule class. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db5bfed5..cea4bbc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "7.2.1", + "version": "7.3.0", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", From 0818adb44b7ec9c3e325f5d668c60d0afc334fb2 Mon Sep 17 00:00:00 2001 From: Benjamin <47060874+Ben-CA@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:43:03 -0600 Subject: [PATCH 62/65] Typo - change to removeOperatorDecorator Change removeOperator to removeOperatorDecorator --- docs/engine.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engine.md b/docs/engine.md index b944adc6..7b65643d 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -236,7 +236,7 @@ engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => { return next(factValue.toLowerCase(), jsonValue.toLowerCase()) }) -engine.removeOperator('first'); +engine.removeOperatorDecorator('first'); ``` ### engine.setCondition(String name, Object conditions) From 1f0dbb401bce0c9704a53c9cd634271a45b29e3c Mon Sep 17 00:00:00 2001 From: Danish Khan Date: Thu, 20 Feb 2025 07:41:53 +0530 Subject: [PATCH 63/65] Update package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index db5bfed5..477902ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "7.2.1", + "version": "7.3.1", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -92,6 +92,6 @@ "clone": "^2.1.2", "eventemitter2": "^6.4.4", "hash-it": "^6.0.0", - "jsonpath-plus": "^10.2.0" + "jsonpath-plus": "^10.3.0" } } From f9ff458773f744ae93b203c50a600e9bf719e28f Mon Sep 17 00:00:00 2001 From: dmay-trm Date: Thu, 22 May 2025 15:25:57 -0400 Subject: [PATCH 64/65] syncing to upstream and adding our patch --- package.json | 4 +++- webpack.config.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 webpack.config.js diff --git a/package.json b/package.json index 477902ad..f2f91167 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,9 @@ "sinon-chai": "^3.7.0", "snazzy": "^9.0.0", "standard": "^16.0.3", - "tsd": "^0.17.0" + "tsd": "^0.17.0", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" }, "dependencies": { "clone": "^2.1.2", diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 00000000..ce84d516 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,31 @@ +const path = require('path'); +const webpack = require('webpack'); + +let commitHash = require('child_process') + .execSync('git rev-parse --short HEAD') + .toString() + .replace(/(\r\n|\n|\r)/gm, ""); + +module.exports = { + entry: './src/index.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: `bundle.${commitHash}.[contenthash].js`, + library: 'jre', + libraryTarget: 'var' + }, + plugins: [ + // Needed to bypass the setTimeout + // used by EventEmitter + // Since we do NOT actually pass in a non-zero + // and we do not need things to execute on a separate thread + // we can just execute the method immediately + new webpack.DefinePlugin({ + setTimeout: '(fn => fn())', + }), + new webpack.EnvironmentPlugin({ + DEBUG: false, + }) + ], + mode: 'production', +}; From 62e92b4f5cc2adb08fcc8b86ef3f8efa4758c828 Mon Sep 17 00:00:00 2001 From: dmay-trm Date: Thu, 22 May 2025 15:29:54 -0400 Subject: [PATCH 65/65] keep our existing workflow files --- .github/workflows/deploy.yml | 42 ----------------------------------- .github/workflows/node.js.yml | 7 +++--- 2 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 305277ba..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - -name: Deploy Package - -on: - push: - branches: [ master, v6 ] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run build --if-present - - run: npm run lint - - run: npm test - deploy: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: 18.x - - run: npm install - - run: npm run build --if-present - # https://github.com/marketplace/actions/npm-publish - - uses: JS-DevTools/npm-publish@v2 - with: - token: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 92ee104b..d842635b 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -4,8 +4,10 @@ name: Node.js CI on: + push: + branches: [ master, next-major ] pull_request: - branches: [ master, v6 ] + branches: [ master, next-major ] jobs: build: @@ -14,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [12.x, 14.x] steps: - uses: actions/checkout@v2 @@ -24,5 +26,4 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install - run: npm run build --if-present - - run: npm run lint - run: npm test