diff --git a/docs/almanac.md b/docs/almanac.md index a5d05a11..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) @@ -14,8 +15,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 @@ -33,8 +34,28 @@ almanac .then( value => console.log(value)) ``` +### almanac.addFact(String id, Function [definitionFunc], Object [options]) + +Sets a fact in the almanac. Used in conjunction with rule and engine event emissions. + +```js +// constant facts: +engine.addFact('speed-of-light', 299792458) + +// facts computed via function +engine.addFact('account-type', function getAccountType(params, almanac) { + // ... +}) + +// facts with options: +engine.addFact('account-type', function getAccountType(params, almanac) { + // ... +}, { cache: false, priority: 500 }) +``` + ### almanac.addRuntimeFact(String factId, Mixed value) +**Deprecated** Use `almanac.addFact` instead Sets a constant fact mid-run. Often used in conjunction with rule and engine event emissions. ```js diff --git a/docs/engine.md b/docs/engine.md index 46d0a1bf..7b65643d 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -12,6 +12,10 @@ 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-) * [engine.stop() -> Engine](#enginestop---engine) * [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult) @@ -43,6 +47,13 @@ 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) + +`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]) @@ -172,6 +183,127 @@ 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.removeOperatorDecorator('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. + +```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: []}) @@ -195,6 +327,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/docs/rules.md b/docs/rules.md index 20a3f551..7f6d4238 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) @@ -26,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) @@ -134,9 +140,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, 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: @@ -158,14 +164,46 @@ 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 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` @@ -316,9 +354,32 @@ 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``` 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. @@ -350,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/02-nested-boolean-logic.js b/examples/02-nested-boolean-logic.js index 0dab6dfc..8fcc0446 100644 --- a/examples/02-nested-boolean-logic.js +++ b/examples/02-nested-boolean-logic.js @@ -31,17 +31,21 @@ async function start () { fact: 'personalFoulCount', operator: 'greaterThanInclusive', value: 5 - }] + }], + name: 'short foul limit' }, { all: [{ fact: 'gameDuration', operator: 'equal', value: 48 }, { - fact: 'personalFoulCount', - operator: 'greaterThanInclusive', - value: 6 - }] + not: { + fact: 'personalFoulCount', + operator: 'lessThan', + value: 6 + } + }], + name: 'long foul limit' }] }, event: { // define the event to fire when the conditions evaluate truthy 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/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. + */ 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/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/examples/13-using-operator-decorators.js b/examples/13-using-operator-decorators.js new file mode 100644 index 00000000..413271eb --- /dev/null +++ b/examples/13-using-operator-decorators.js @@ -0,0 +1,97 @@ +'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/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": "../" } } diff --git a/package.json b/package.json index ec89430d..f2f91167 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "json-rules-engine", - "version": "6.1.0", + "version": "7.3.1", "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", @@ -55,6 +58,9 @@ ] }, "author": "Cache Hamm ", + "contributors": [ + "Chris Pardy " + ], "license": "ISC", "bugs": { "url": "https://github.com/cachecontrol/json-rules-engine/issues" @@ -80,13 +86,14 @@ "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", "eventemitter2": "^6.4.4", - "hash-it": "^5.0.0", - "jsonpath-plus": "^5.0.7", - "lodash.isobjectlike": "^4.0.0" + "hash-it": "^6.0.0", + "jsonpath-plus": "^10.3.0" } } diff --git a/src/almanac.js b/src/almanac.js index 7a9a8e07..effc7329 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 }) @@ -17,25 +16,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}>`) - } } /** @@ -103,13 +90,37 @@ 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 */ addRuntimeFact (factId, value) { - debug(`almanac::addRuntimeFact id:${factId}`) + debug('almanac::addRuntimeFact', { id: factId }) const fact = new Fact(factId, value) return this._addConstantFact(fact) } @@ -139,22 +150,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)) { + if (factValue != null && typeof factValue === 'object') { 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 } }) @@ -162,4 +173,14 @@ export default class Almanac { return factValuePromise } + + /** + * 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' } + return this.factValue(value.fact, value.params, value.path) + } + return Promise.resolve(value) + } } diff --git a/src/condition.js b/src/condition.js index 98a76909..56689598 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) { @@ -10,19 +9,21 @@ 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) - }) - } 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') - if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required') + if (subConditionsIsArray) { + this[booleanOperator] = subConditions.map((c) => new Condition(c)) + } else { + 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') } // a non-boolean condition does not have a priority by default. this allows // priority to be dictated by the fact definition @@ -42,9 +43,18 @@ 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) { - 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 if (this.isConditionReference()) { + props.condition = this.condition } else { props.operator = this.operator props.value = this.value @@ -52,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 } @@ -68,17 +81,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 @@ -91,49 +93,70 @@ 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', { + leftHandSideValue, + operator: this.operator, + rightHandSideValue, + result + } + ) + return { + result, + leftHandSideValue, + rightHandSideValue, + operator: this.operator + } + }) } /** * 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 () { 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/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-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 3bd958e1..4cb8751e 100644 --- a/src/engine.js +++ b/src/engine.js @@ -2,11 +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' @@ -21,12 +23,16 @@ class Engine extends EventEmitter { super() 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.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)) } /** @@ -92,36 +98,63 @@ 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 * @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) } /** @@ -139,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 } @@ -208,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') @@ -232,14 +265,29 @@ class Engine extends EventEmitter { * @param {Object} runOptions - run options * @return {Promise} resolves when the engine has completed running */ - run (runtimeFacts = {}) { + run (runtimeFacts = {}, runOptions = {}) { debug('engine::run started') this.status = RUNNING - const almanacOptions = { + + const almanac = runOptions.almanac || new Almanac({ allowUndefinedFacts: this.allowUndefinedFacts, pathResolver: this.pathResolver + }) + + this.facts.forEach(fact => { + almanac.addFact(fact) + }) + for (const factId in runtimeFacts) { + let fact + if (runtimeFacts[factId] instanceof Fact) { + fact = runtimeFacts[factId] + } else { + fact = new Fact(factId, runtimeFacts[factId]) + } + + almanac.addFact(fact) + debug('engine::run initialized runtime fact', { id: fact.id, value: fact.value, type: typeof fact.value }) } - const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions) const orderedSets = this.prioritizeRules() let cursor = Promise.resolve() // for each rule set, evaluate in parallel, diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index 339c3c06..bed371d5 100644 --- a/src/json-rules-engine.js +++ b/src/json-rules-engine.js @@ -2,8 +2,10 @@ import Engine from './engine' 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 } +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..741e302c --- /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', { name: 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', { name: 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/src/rule-result.js b/src/rule-result.js index de163176..09350c7e 100644 --- a/src/rule-result.js +++ b/src/rule-result.js @@ -15,6 +15,23 @@ export default class RuleResult { this.result = result } + resolveEventParams (almanac) { + 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)) { + 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 5db01916..98007e8f 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,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')) { - 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') && + !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 @@ -85,10 +93,15 @@ 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 } + this.event = this.ruleEvent if (event.params) this.ruleEvent.params = event.params return this } @@ -169,9 +182,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]) } /** @@ -180,7 +195,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 @@ -188,25 +208,31 @@ 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(condition) + } else if (condition.isBooleanOperator()) { const subConditions = condition[condition.operator] 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 - return comparisonPromise.then(comparisonValue => { + // for booleans, rule passing is determined by the all/any/not result + 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.valueResult = evaluationResult.rightHandSideValue condition.result = passes return passes }) @@ -220,55 +246,46 @@ 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) + }) } /** - * 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) } - let method = Array.prototype.some - if (operator === 'all') { - method = Array.prototype.every + 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]) } 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 @@ -292,22 +309,72 @@ 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) + } + + /** + * 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` + ) + } + } 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 */ 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(() => ruleResult) + return processEvent.then(() => 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) + ) + } else if (ruleResult.conditions.not) { + return not(ruleResult.conditions.not).then((result) => + processResult(result) + ) } else { - return all(ruleResult.conditions.all) - .then(result => processResult(result)) + return realize( + ruleResult.conditions + ).then((result) => processResult(result)) } } } diff --git a/test/acceptance/acceptance.js b/test/acceptance/acceptance.js index fb68cc77..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 diff --git a/test/almanac.test.js b/test/almanac.test.js index d5381016..b9701839 100644 --- a/test/almanac.test.js +++ b/test/almanac.test.js @@ -23,20 +23,33 @@ describe('Almanac', () => { }) it('adds runtime facts', () => { - almanac = new Almanac(new Map(), { modelId: 'XYZ' }) + almanac = new Almanac() + almanac.addFact('modelId', 'XYZ') expect(almanac.factMap.get('modelId').value).to.equal('XYZ') }) }) - describe('constructor', () => { + describe('addFact', () => { it('supports runtime facts as key => values', () => { - almanac = new Almanac(new Map(), { fact1: 3 }) + almanac = new Almanac() + almanac.addFact('fact1', 3) return expect(almanac.factValue('fact1')).to.eventually.equal(3) }) + it('supporrts runtime facts as dynamic callbacks', async () => { + almanac = new Almanac() + almanac.addFact('fact1', () => { + factSpy() + return Promise.resolve(3) + }) + await expect(almanac.factValue('fact1')).to.eventually.equal(3) + await expect(factSpy).to.have.been.calledOnce() + }) + it('supports runtime fact instances', () => { const fact = new Fact('fact1', 3) - almanac = new Almanac(new Map(), { fact1: fact }) + almanac = new Almanac() + almanac.addFact(fact) return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value) }) }) @@ -69,9 +82,8 @@ describe('Almanac', () => { if (params.userId) return params.userId return 'unknown' }) - const factMap = new Map() - factMap.set(fact.id, fact) - almanac = new Almanac(factMap) + almanac = new Almanac() + almanac.addFact(fact) }) it('allows parameters to be passed to the fact', async () => { @@ -106,10 +118,9 @@ describe('Almanac', () => { describe('_getFact', _ => { it('retrieves the fact object', () => { - const facts = new Map() const fact = new Fact('id', 1) - facts.set(fact.id, fact) - almanac = new Almanac(facts) + almanac = new Almanac() + almanac.addFact(fact) expect(almanac._getFact('id')).to.equal(fact) }) }) @@ -124,9 +135,8 @@ describe('Almanac', () => { function setup (f = new Fact('id', 1)) { fact = f - const facts = new Map() - facts.set(fact.id, fact) - almanac = new Almanac(facts) + almanac = new Almanac() + almanac.addFact(fact) } let fact const FACT_VALUE = 2 @@ -154,9 +164,8 @@ describe('Almanac', () => { name: 'Thomas' }] }) - const factMap = new Map() - factMap.set(fact.id, fact) - almanac = new Almanac(factMap) + almanac = new Almanac() + almanac.addFact(fact) const result = await almanac.factValue('foo', null, '$..name') expect(result).to.deep.equal(['George', 'Thomas']) }) @@ -167,9 +176,8 @@ describe('Almanac', () => { factSpy() return 'unknown' }, factOptions) - const factMap = new Map() - factMap.set(fact.id, fact) - almanac = new Almanac(factMap) + almanac = new Almanac() + almanac.addFact(fact) } it('evaluates the fact every time when fact caching is off', () => { diff --git a/test/condition.test.js b/test/condition.test.js index 248c6ed3..cd1d8f54 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', () => { @@ -56,6 +58,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', () => { @@ -69,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', () => { @@ -96,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) }) @@ -213,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 @@ -223,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) @@ -251,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 @@ -267,6 +292,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', () => { diff --git a/test/engine-condition.test.js b/test/engine-condition.test.js new file mode 100644 index 00000000..028a856e --- /dev/null +++ b/test/engine-condition.test.js @@ -0,0 +1,321 @@ +'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) + }) + }) + + 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() + }) + }) +}) diff --git a/test/engine-event.test.js b/test/engine-event.test.js index 3b1ce8ff..a4035dd8 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' @@ -20,20 +20,28 @@ describe('Engine: event', () => { canOrderDrinks: true } } + + const awesomeEvent = { + type: 'awesome' + } /** * sets up a simple 'any' rule with 2 conditions */ 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 +58,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 } @@ -81,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()) @@ -91,6 +146,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 +233,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 +251,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 +318,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) } @@ -282,6 +345,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()) @@ -367,6 +534,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 () => { @@ -375,7 +641,28 @@ 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,"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) + }) + }) + + 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,"valueResult":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,"valueResult":21,"result":true}]},"event":{"type":"awesome"},"priority":100,"result":true}' expect(JSON.stringify(ruleResult)).to.equal(expected) }) }) 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) + }) + }) }) 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-operator-map.test.js b/test/engine-operator-map.test.js new file mode 100644 index 00000000..b5ae9672 --- /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-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() + }) + }) }) 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/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/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) }) }) diff --git a/test/rule.test.js b/test/rule.test.js index bc3fb6a6..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) }) }) @@ -109,7 +111,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", "not", or "condition"/) }) }) }) @@ -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 b3d39e2a..aaa22f8a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,8 +1,17 @@ -export interface EngineOptions { +export interface AlmanacOptions { allowUndefinedFacts?: boolean; pathResolver?: PathResolver; } +export interface EngineOptions extends AlmanacOptions { + allowUndefinedConditions?: boolean; + replaceFactsInEventParams?: boolean; +} + +export interface RunOptions { + almanac?: Almanac; +} + export interface EngineResult { events: Event[]; failureEvents: Event[]; @@ -23,13 +32,20 @@ export class Engine { removeRule(ruleOrName: Rule | string): boolean; updateRule(rule: Rule): void; - addOperator(operator: Operator): Map; + setCondition(name: string, conditions: TopLevelCondition): this; + removeCondition(name: string): boolean; + + addOperator(operator: Operator): void; addOperator( operatorName: string, callback: OperatorEvaluator - ): Map; + ): 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, @@ -39,11 +55,9 @@ 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): Promise; + run(facts?: Record, runOptions?: RunOptions): Promise; stop(): this; } @@ -60,12 +74,32 @@ 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( factId: string, params?: Record, path?: string ): Promise; + addFact(fact: Fact): this; + addFact( + id: string, + valueCallback: DynamicFactCallback | T, + options?: FactOptions + ): this; addRuntimeFact(factId: string, value: any): void; } @@ -98,13 +132,10 @@ 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, +export type EventHandler = ( + event: T, almanac: Almanac, ruleResult: RuleResult ) => void; @@ -122,19 +153,33 @@ 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 { constructor(ruleProps: RuleProperties | string); name: string; conditions: TopLevelCondition; - event: Event; + /** + * @deprecated Use {@link Rule.event} instead. + */ + ruleEvent: Event; + event: Event priority: number; setConditions(conditions: TopLevelCondition): this; setEvent(event: Event): this; @@ -145,6 +190,15 @@ export class Rule implements RuleProperties { ): T extends true ? string : RuleSerializable; } +interface BooleanConditionResultProperties { + result?: boolean +} + +interface ConditionResultProperties extends BooleanConditionResultProperties { + factResult?: unknown + valueResult?: unknown +} + interface ConditionProperties { fact: string; operator: string; @@ -152,9 +206,49 @@ interface ConditionProperties { path?: string; priority?: number; params?: Record; + name?: string; } +type ConditionPropertiesResult = ConditionProperties & ConditionResultProperties + type NestedCondition = ConditionProperties | TopLevelCondition; -type AllConditions = { all: NestedCondition[] }; -type AnyConditions = { any: NestedCondition[] }; -export type TopLevelCondition = AllConditions | AnyConditions; +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 dcf55419..a7e11202 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -4,13 +4,21 @@ import rulesEngine, { Almanac, EngineResult, Engine, + Event, Fact, Operator, OperatorEvaluator, + OperatorDecorator, + OperatorDecoratorEvaluator, PathResolver, Rule, RuleProperties, - RuleSerializable + RuleResult, + RuleSerializable, + TopLevelConditionResult, + AnyConditionsResult, + AllConditionsResult, + NotConditionsResult } from "../"; // setup basic fixture data @@ -72,7 +80,7 @@ const operatorEvaluator: OperatorEvaluator = ( a: number, b: number ) => a === b; -expectType>( +expectType( engine.addOperator("test", operatorEvaluator) ); const operator: Operator = new Operator( @@ -80,9 +88,26 @@ const operator: Operator = new Operator( operatorEvaluator, (num: number) => num > 0 ); -expectType>(engine.addOperator(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]); @@ -93,9 +118,32 @@ 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 })); +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; 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', +};