diff --git a/docs/engine.md b/docs/engine.md index 46d0a1b..7665fa0 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 187c230..518989e 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -14,7 +14,8 @@ 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 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 0000000..29fe2e3 --- /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. + */ diff --git a/examples/package.json b/examples/package.json index 2af5d11..1758ee5 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": "../" } } diff --git a/src/condition.js b/src/condition.js index e7a3770..b5f8102 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 3bd958e..6434d53 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 c4f488b..a26e035 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 0000000..d4867b9 --- /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 8ee08fe..fc3357b 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 3cf93c0..ec28eed 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;