├── .babelrc ├── .github └── workflows │ ├── deploy.yml │ └── node.js.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── almanac.md ├── engine.md ├── facts.md ├── rules.md └── walkthrough.md ├── examples ├── .gitignore ├── 01-hello-world.js ├── 02-nested-boolean-logic.js ├── 03-dynamic-facts.js ├── 04-fact-dependency.js ├── 05-optimizing-runtime-with-fact-priorities.js ├── 06-custom-operators.js ├── 07-rule-chaining.js ├── 08-fact-comparison.js ├── 09-rule-results.js ├── 10-condition-sharing.js ├── 11-using-facts-in-events.js ├── 12-using-custom-almanac.js ├── 13-using-operator-decorators.js ├── package-lock.json ├── package.json └── support │ └── account-api-client.js ├── package.json ├── src ├── almanac.js ├── condition.js ├── debug.js ├── engine-default-operator-decorators.js ├── engine-default-operators.js ├── engine.js ├── errors.js ├── fact.js ├── index.js ├── json-rules-engine.js ├── operator-decorator.js ├── operator-map.js ├── operator.js ├── rule-result.js └── rule.js ├── test ├── acceptance │ └── acceptance.js ├── almanac.test.js ├── condition.test.js ├── engine-all.test.js ├── engine-any.test.js ├── engine-cache.test.js ├── engine-condition.test.js ├── engine-controls.test.js ├── engine-custom-properties.test.js ├── engine-error-handling.test.js ├── engine-event.test.js ├── engine-fact-comparison.test.js ├── engine-fact-priority.test.js ├── engine-fact.test.js ├── engine-facts-calling-facts.test.js ├── engine-failure.test.js ├── engine-not.test.js ├── engine-operator-map.test.js ├── engine-operator.test.js ├── engine-parallel-condition-cache.test.js ├── engine-recusive-rules.test.js ├── engine-rule-priority.js ├── engine-run.test.js ├── engine.test.js ├── fact.test.js ├── index.test.js ├── operator-decorator.test.js ├── operator.test.js ├── performance.test.js ├── rule.test.js └── support │ ├── bootstrap.js │ ├── condition-factory.js │ ├── example_runner.sh │ └── rule-factory.js └── types ├── index.d.ts └── index.test-d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Deploy Package 5 | 6 | on: 7 | push: 8 | branches: [ master, v6 ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18.x, 20.x, 22.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | - run: npm run build --if-present 27 | - run: npm run lint 28 | - run: npm test 29 | deploy: 30 | needs: build 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: actions/setup-node@v1 35 | with: 36 | node-version: 18.x 37 | - run: npm install 38 | - run: npm run build --if-present 39 | # https://github.com/marketplace/actions/npm-publish 40 | - uses: JS-DevTools/npm-publish@v2 41 | with: 42 | token: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | pull_request: 8 | branches: [ master, v6 ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18.x, 20.x, 22.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | - run: npm run build --if-present 27 | - run: npm run lint 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | npm-debug.log 3 | .vscode 4 | .idea 5 | .DS_Store 6 | dist 7 | *.tgz 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | types/*.test-d.ts 3 | .travis.yml 4 | .gitignore 5 | test/ 6 | examples/ 7 | docs/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### 6.1.0 / 2021-06-03 2 | * engine.removeRule() now supports removing rules by name 3 | * Added engine.updateRule(rule) 4 | 5 | #### 6.0.1 / 2021-03-09 6 | * Updates Typescript types to include `failureEvents` in EngineResult. 7 | 8 | #### 6.0.0 / 2020-12-22 9 | * BREAKING CHANGES 10 | * To continue using [selectn](https://github.com/wilmoore/selectn.js) syntax for condition `path`s, use the new `pathResolver` feature. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). Add the following to the engine constructor: 11 | ```js 12 | const pathResolver = (object, path) => { 13 | return selectn(path)(object) 14 | } 15 | const engine = new Engine(rules, { pathResolver }) 16 | ``` 17 | (fixes #205) 18 | * Engine and Rule events `on('success')`, `on('failure')`, and Rule callbacks `onSuccess` and `onFailure` now honor returned promises; any event handler that returns a promise will be waited upon to resolve before engine execution continues. (fixes #235) 19 | * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future. 20 | * The `success-events` fact used to store successful events has been converted to an internal data structure and will no longer appear in the almanac's facts. (fixes #187) 21 | * NEW FEATURES 22 | * Engine constructor now accepts a `pathResolver` option for resolving condition `path` properties. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). (fixes #210) 23 | * Engine.run() now returns three additional data structures: 24 | * `failureEvents`, an array of all failed rules events. (fixes #192) 25 | * `results`, an array of RuleResults for each successful rule (fixes #216) 26 | * `failureResults`, an array of RuleResults for each failed rule 27 | 28 | 29 | #### 5.3.0 / 2020-12-02 30 | * Allow facts to have a value of `undefined` 31 | 32 | #### 5.2.0 / 2020-11-31 33 | * No changes; published to correct an accidental publish of untagged alpha 34 | 35 | #### 5.0.4 / 2020-09-26 36 | * Upgrade dependencies to latest 37 | 38 | #### 5.0.3 / 2020-01-26 39 | * Upgrade jsonpath-plus dependency, to fix inconsistent scalar results (#175) 40 | 41 | #### 5.0.2 / 2020-01-18 42 | * BUGFIX: Add missing `DEBUG` log for almanac.addRuntimeFact() 43 | 44 | #### 5.0.1 / 2020-01-18 45 | * BUGFIX: `DEBUG` envs works with cookies disables 46 | 47 | #### 5.0.0 / 2019-11-29 48 | * BREAKING CHANGES 49 | * Rule conditions' `path` property is now interpreted using [json-path](https://goessner.net/articles/JsonPath/) 50 | * To continue using the old syntax (provided via [selectn](https://github.com/wilmoore/selectn.js)), `npm install selectn` as a direct dependency, and `json-rules-engine` will continue to interpret legacy paths this way. 51 | * Any path starting with `$` will be assumed to use `json-path` syntax 52 | 53 | #### 4.1.0 / 2019-09-27 54 | * Export Typescript definitions (@brianphillips) 55 | 56 | #### 4.0.0 / 2019-08-22 57 | * BREAKING CHANGES 58 | * `engine.run()` now returns a hash of events and almanac: `{ events: [], almanac: Almanac instance }`. Previously in v3, the `run()` returned the `events` array. 59 | * For example, `const events = await engine.run()` under v3 will need to be changed to `const { events } = await engine.run()` under v4. 60 | 61 | #### 3.1.0 / 2019-07-19 62 | * Feature: `rule.setName()` and `ruleResult.name` 63 | 64 | #### 3.0.3 / 2019-07-15 65 | * Fix "localStorage.debug" not working in browsers 66 | 67 | #### 3.0.2 / 2019-05-23 68 | * Fix "process" not defined error in browsers lacking node.js global shims 69 | 70 | #### 3.0.0 / 2019-05-17 71 | * BREAKING CHANGES 72 | * Previously all conditions with undefined facts would resolve false. With this change, undefined facts values are treated as `undefined`. 73 | * Greatly improved performance of `allowUndefinedfacts = true` engine option 74 | * Reduce package bundle size by ~40% 75 | 76 | #### 2.3.5 / 2019-04-26 77 | * Replace debug with vanilla console.log 78 | 79 | #### 2.3.4 / 2019-04-26 80 | * Use Array.isArray instead of instanceof to test Array parameters to address edge cases 81 | 82 | #### 2.3.3 / 2019-04-23 83 | * Fix rules cache not clearing after removeRule() 84 | 85 | #### 2.3.2 / 2018-12-28 86 | * Upgrade all dependencies to latest 87 | 88 | #### 2.3.1 / 2018-12-03 89 | * IE8 compatibility: replace Array.forEach with for loop (@knalbandianbrightgrove) 90 | 91 | #### 2.3.0 / 2018-05-03 92 | * Engine.removeFact() - removes fact from the engine (@SaschaDeWaal) 93 | * Engine.removeRule() - removes rule from the engine (@SaschaDeWaal) 94 | * Engine.removeOperator() - removes operator from the engine (@SaschaDeWaal) 95 | 96 | #### 2.2.0 / 2018-04-19 97 | * Performance: Constant facts now perform 18-26X better 98 | * Performance: Removes await/async transpilation and json.stringify calls, significantly improving overall performance 99 | 100 | #### 2.1.0 / 2018-02-19 101 | * Publish dist updates for 2.0.3 102 | 103 | #### 2.0.3 / 2018-01-29 104 | * Add factResult and result to the JSON generated for Condition (@bjacobso) 105 | 106 | #### 2.0.2 / 2017-07-24 107 | * Bugfix IE8 support 108 | 109 | #### 2.0.1 / 2017-07-05 110 | * Bugfix rule result serialization 111 | 112 | #### 2.0.0 / 2017-04-21 113 | * Publishing 2.0.0 114 | 115 | #### 2.0.0-beta2 / 2017-04-10 116 | * Fix fact path object checking to work with objects that have prototypes (lodash isObjectLike instead of isPlainObject) 117 | 118 | #### 2.0.0-beta1 / 2017-04-09 119 | * Add rule results 120 | * Document fact .path ability to parse properties containing dots 121 | * Bump dependencies 122 | * BREAKING CHANGES 123 | * `engine.on('failure', (rule, almanac))` is now `engine.on('failure', (event, almanac, ruleResult))` 124 | * `engine.on(eventType, (eventParams, engine))` is now `engine.on(eventType, (eventParams, almanac, ruleResult))` 125 | 126 | #### 1.5.1 / 2017-03-19 127 | * Bugfix almanac.factValue skipping interpreting condition "path" for cached facts 128 | 129 | #### 1.5.0 / 2017-03-12 130 | * Add fact comparison conditions 131 | 132 | #### 1.4.0 / 2017-01-23 133 | * Add `allowUndefinedFacts` engine option 134 | 135 | #### 1.3.1 / 2017-01-16 136 | * Bump object-hash dependency to latest 137 | 138 | #### 1.3.0 / 2016-10-24 139 | * Rule event emissions 140 | * Rule chaining 141 | 142 | #### 1.2.1 / 2016-10-22 143 | * Use Array.indexOf instead of Array.includes for older node version compatibility 144 | 145 | #### 1.2.0 / 2016-09-13 146 | * Fact path support 147 | 148 | #### 1.1.0 / 2016-09-11 149 | * Custom operator support 150 | 151 | #### 1.0.4 / 2016-06-18 152 | * fix issue #6; runtime facts unique to each run() 153 | 154 | #### 1.0.3 / 2016-06-15 155 | * fix issue #5; dependency error babel-core/register 156 | 157 | #### 1.0.0 / 2016-05-01 158 | * api stable; releasing 1.0 159 | * engine.run() now returns triggered events 160 | 161 | #### 1.0.0-beta10 / 2016-04-16 162 | * Completed the 'fact-dependecy' advanced example 163 | * Updated addFact and addRule engine methods to return 'this' for easy chaining 164 | 165 | #### 1.0.0-beta9 / 2016-04-11 166 | * Completed the 'basic' example 167 | * [BREAKING CHANGE] update engine.on('success') and engine.on('failure') to pass the current almanac instance as the second argument, rather than the engine 168 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2017 Cache Hamm 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 14 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![json-rules-engine](http://i.imgur.com/MAzq7l2.png) 2 | [![js-standard-style](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard) 3 | [![Build Status](https://github.com/cachecontrol/json-rules-engine/workflows/Node.js%20CI/badge.svg?branch=master)](https://github.com/cachecontrol/json-rules-engine/workflows/Node.js%20CI/badge.svg?branch=master) 4 | 5 | [![npm version](https://badge.fury.io/js/json-rules-engine.svg)](https://badge.fury.io/js/json-rules-engine) 6 | [![install size](https://packagephobia.now.sh/badge?p=json-rules-engine)](https://packagephobia.now.sh/result?p=json-rules-engine) 7 | [![npm downloads](https://img.shields.io/npm/dm/json-rules-engine.svg)](https://www.npmjs.com/package/json-rules-engine) 8 | 9 | A rules engine expressed in JSON 10 | 11 | * [Synopsis](#synopsis) 12 | * [Features](#features) 13 | * [Installation](#installation) 14 | * [Docs](#docs) 15 | * [Examples](#examples) 16 | * [Basic Example](#basic-example) 17 | * [Advanced Example](#advanced-example) 18 | * [Debugging](#debugging) 19 | * [Node](#node) 20 | * [Browser](#browser) 21 | * [Related Projects](#related-projects) 22 | * [License](#license) 23 | 24 | ## Synopsis 25 | 26 | ```json-rules-engine``` is a powerful, lightweight rules engine. Rules are composed of simple json structures, making them human readable and easy to persist. 27 | 28 | ## Features 29 | 30 | * Rules expressed in simple, easy to read JSON 31 | * Full support for ```ALL``` and ```ANY``` boolean operators, including recursive nesting 32 | * Fast by default, faster with configuration; priority levels and cache settings for fine tuning performance 33 | * Secure; no use of eval() 34 | * Isomorphic; runs in node and browser 35 | * Lightweight & extendable; 17kb gzipped w/few dependencies 36 | 37 | ## Installation 38 | 39 | ```bash 40 | $ npm install json-rules-engine 41 | ``` 42 | 43 | ## Docs 44 | 45 | - [engine](./docs/engine.md) 46 | - [rules](./docs/rules.md) 47 | - [almanac](./docs/almanac.md) 48 | - [facts](./docs/facts.md) 49 | 50 | ## Examples 51 | 52 | See the [Examples](./examples), which demonstrate the major features and capabilities. 53 | 54 | ## Basic Example 55 | 56 | This example demonstrates an engine for detecting whether a basketball player has fouled out (a player who commits five personal fouls over the course of a 40-minute game, or six in a 48-minute game, fouls out). 57 | 58 | ```js 59 | const { Engine } = require('json-rules-engine') 60 | 61 | 62 | /** 63 | * Setup a new engine 64 | */ 65 | let engine = new Engine() 66 | 67 | // define a rule for detecting the player has exceeded foul limits. Foul out any player who: 68 | // (has committed 5 fouls AND game is 40 minutes) OR (has committed 6 fouls AND game is 48 minutes) 69 | engine.addRule({ 70 | conditions: { 71 | any: [{ 72 | all: [{ 73 | fact: 'gameDuration', 74 | operator: 'equal', 75 | value: 40 76 | }, { 77 | fact: 'personalFoulCount', 78 | operator: 'greaterThanInclusive', 79 | value: 5 80 | }] 81 | }, { 82 | all: [{ 83 | fact: 'gameDuration', 84 | operator: 'equal', 85 | value: 48 86 | }, { 87 | fact: 'personalFoulCount', 88 | operator: 'greaterThanInclusive', 89 | value: 6 90 | }] 91 | }] 92 | }, 93 | event: { // define the event to fire when the conditions evaluate truthy 94 | type: 'fouledOut', 95 | params: { 96 | message: 'Player has fouled out!' 97 | } 98 | } 99 | }) 100 | 101 | /** 102 | * Define facts the engine will use to evaluate the conditions above. 103 | * Facts may also be loaded asynchronously at runtime; see the advanced example below 104 | */ 105 | let facts = { 106 | personalFoulCount: 6, 107 | gameDuration: 40 108 | } 109 | 110 | // Run the engine to evaluate 111 | engine 112 | .run(facts) 113 | .then(({ events }) => { 114 | events.map(event => console.log(event.params.message)) 115 | }) 116 | 117 | /* 118 | * Output: 119 | * 120 | * Player has fouled out! 121 | */ 122 | ``` 123 | 124 | This is available in the [examples](./examples/02-nested-boolean-logic.js) 125 | 126 | ## Advanced Example 127 | 128 | This example demonstates an engine for identifying employees who work for Microsoft and are taking Christmas day off. 129 | 130 | This demonstrates an engine which uses asynchronous fact data. 131 | Fact information is loaded via API call during runtime, and the results are cached and recycled for all 3 conditions. 132 | It also demonstates use of the condition _path_ feature to reference properties of objects returned by facts. 133 | 134 | ```js 135 | const { Engine } = require('json-rules-engine') 136 | 137 | // example client for making asynchronous requests to an api, database, etc 138 | import apiClient from './account-api-client' 139 | 140 | /** 141 | * Setup a new engine 142 | */ 143 | let engine = new Engine() 144 | 145 | /** 146 | * Rule for identifying microsoft employees taking pto on christmas 147 | * 148 | * the account-information fact returns: 149 | * { company: 'XYZ', status: 'ABC', ptoDaysTaken: ['YYYY-MM-DD', 'YYYY-MM-DD'] } 150 | */ 151 | let microsoftRule = { 152 | conditions: { 153 | all: [{ 154 | fact: 'account-information', 155 | operator: 'equal', 156 | value: 'microsoft', 157 | path: '$.company' // access the 'company' property of "account-information" 158 | }, { 159 | fact: 'account-information', 160 | operator: 'in', 161 | value: ['active', 'paid-leave'], // 'status' can be active or paid-leave 162 | path: '$.status' // access the 'status' property of "account-information" 163 | }, { 164 | fact: 'account-information', 165 | operator: 'contains', // the 'ptoDaysTaken' property (an array) must contain '2016-12-25' 166 | value: '2016-12-25', 167 | path: '$.ptoDaysTaken' // access the 'ptoDaysTaken' property of "account-information" 168 | }] 169 | }, 170 | event: { 171 | type: 'microsoft-christmas-pto', 172 | params: { 173 | message: 'current microsoft employee taking christmas day off' 174 | } 175 | } 176 | } 177 | engine.addRule(microsoftRule) 178 | 179 | /** 180 | * 'account-information' fact executes an api call and retrieves account data, feeding the results 181 | * into the engine. The major advantage of this technique is that although there are THREE conditions 182 | * requiring this data, only ONE api call is made. This results in much more efficient runtime performance 183 | * and fewer network requests. 184 | */ 185 | engine.addFact('account-information', function (params, almanac) { 186 | console.log('loading account information...') 187 | return almanac.factValue('accountId') 188 | .then((accountId) => { 189 | return apiClient.getAccountInformation(accountId) 190 | }) 191 | }) 192 | 193 | // define fact(s) known at runtime 194 | let facts = { accountId: 'lincoln' } 195 | engine 196 | .run(facts) 197 | .then(({ events }) => { 198 | console.log(facts.accountId + ' is a ' + events.map(event => event.params.message)) 199 | }) 200 | 201 | /* 202 | * OUTPUT: 203 | * 204 | * loading account information... // <-- API call is made ONCE and results recycled for all 3 conditions 205 | * lincoln is a current microsoft employee taking christmas day off 206 | */ 207 | ``` 208 | 209 | This is available in the [examples](./examples/03-dynamic-facts.js) 210 | 211 | ## Debugging 212 | 213 | To see what the engine is doing under the hood, debug output can be turned on via: 214 | 215 | ### Node 216 | 217 | ```bash 218 | DEBUG=json-rules-engine 219 | ``` 220 | 221 | ### Browser 222 | ```js 223 | // set debug flag in local storage & refresh page to see console output 224 | localStorage.debug = 'json-rules-engine' 225 | ``` 226 | 227 | ## Related Projects 228 | 229 | https://github.com/vinzdeveloper/json-rule-editor - configuration ui for json-rules-engine: 230 | 231 | rule editor 2 232 | 233 | 234 | ## License 235 | [ISC](./LICENSE) 236 | -------------------------------------------------------------------------------- /docs/almanac.md: -------------------------------------------------------------------------------- 1 | # Almanac 2 | 3 | * [Overview](#overview) 4 | * [Methods](#methods) 5 | * [almanac.factValue(Fact fact, Object params, String path) -> Promise](#almanacfactvaluefact-fact-object-params-string-path---promise) 6 | * [almanac.addFact(String id, Function [definitionFunc], Object [options])](#almanacaddfactstring-id-function-definitionfunc-object-options) 7 | * [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value) 8 | * [almanac.getEvents(String outcome) -> Events[]](#almanacgeteventsstring-outcome---events) 9 | * [almanac.getResults() -> RuleResults[]](#almanacgetresults---ruleresults) 10 | * [Common Use Cases](#common-use-cases) 11 | * [Fact dependencies](#fact-dependencies) 12 | * [Retrieve fact values when handling events](#retrieve-fact-values-when-handling-events) 13 | * [Rule Chaining](#rule-chaining) 14 | 15 | ## Overview 16 | 17 | An almanac collects facts through an engine run cycle. As the engine computes fact values, 18 | the results are stored in the almanac and cached. If the engine detects a fact computation has 19 | been previously computed, it reuses the cached result from the almanac. Every time ```engine.run()``` is invoked, 20 | a new almanac is instantiated. 21 | 22 | The almanac for the current engine run is available as arguments passed to the fact evaluation methods and 23 | to the engine ```success``` event. The almanac may be used to define additional facts during runtime. 24 | 25 | ## Methods 26 | 27 | ### almanac.factValue(Fact fact, Object params, String path) -> Promise 28 | 29 | Computes the value of the provided fact + params. If "path" is provided, it will be used as a [json-path](https://goessner.net/articles/JsonPath/) accessor on the fact's return object. 30 | 31 | ```js 32 | almanac 33 | .factValue('account-information', { accountId: 1 }, '.balance') 34 | .then( value => console.log(value)) 35 | ``` 36 | 37 | ### almanac.addFact(String id, Function [definitionFunc], Object [options]) 38 | 39 | Sets a fact in the almanac. Used in conjunction with rule and engine event emissions. 40 | 41 | ```js 42 | // constant facts: 43 | engine.addFact('speed-of-light', 299792458) 44 | 45 | // facts computed via function 46 | engine.addFact('account-type', function getAccountType(params, almanac) { 47 | // ... 48 | }) 49 | 50 | // facts with options: 51 | engine.addFact('account-type', function getAccountType(params, almanac) { 52 | // ... 53 | }, { cache: false, priority: 500 }) 54 | ``` 55 | 56 | ### almanac.addRuntimeFact(String factId, Mixed value) 57 | 58 | **Deprecated** Use `almanac.addFact` instead 59 | Sets a constant fact mid-run. Often used in conjunction with rule and engine event emissions. 60 | 61 | ```js 62 | almanac.addRuntimeFact('account-id', 1) 63 | ``` 64 | 65 | ### almanac.getEvents(String outcome) -> Events[] 66 | 67 | Returns events by outcome ("success" or "failure") for the current engine run() 68 | 69 | ```js 70 | almanac.getEvents() // all events for every rule evaluated thus far 71 | 72 | almanac.getEvents('success') // array of success events 73 | 74 | almanac.getEvents('failure') // array of failure events 75 | ``` 76 | 77 | ### almanac.getResults() -> RuleResults[] 78 | 79 | Returns [rule results](./rules#rule-results) for the current engine run() 80 | 81 | ```js 82 | almanac.getResults() 83 | ``` 84 | 85 | ## Common Use Cases 86 | 87 | ### Fact dependencies 88 | 89 | The most common use of the almanac is to access data computed by other facts during runtime. This allows 90 | leveraging the engine's caching mechanisms to design more efficient rules. 91 | 92 | The [fact-dependency](../examples/04-fact-dependency.js) example demonstrates a real world application of this technique. 93 | 94 | For example, say there were two facts: _is-funded-account_ and _account-balance_. Both facts depend on the same _account-information_ data set. 95 | Using the Almanac, each fact can be defined to call a **base** fact responsible for loading the data. This causes the engine 96 | to make the API call for loading account information only once per account. 97 | 98 | ```js 99 | /* 100 | * Base fact for retrieving account data information. 101 | * Engine will automatically cache results by accountId 102 | */ 103 | let accountInformation = new Fact('account-information', function(params, almanac) { 104 | return request 105 | .get({ url: `http://my-service/account/${params.accountId}`) 106 | .then(function (response) { 107 | return response.data 108 | }) 109 | }) 110 | 111 | /* 112 | * Calls the account-information fact with the appropriate accountId. 113 | * Receives a promise w/results unique to the accountId 114 | */ 115 | let isFundedAccount = new Fact('is-funded-account', function(params, almanac) { 116 | return almanac.factValue('account-information', { accountId: params.accountId }).then(info => { 117 | return info.funded === true 118 | }) 119 | }) 120 | 121 | /* 122 | * Calls the account-information fact with the appropriate accountId. 123 | * Receives a promise w/results unique to the accountId 124 | */ 125 | let accountBalance = new Fact('account-balance', function(params, almanac) { 126 | return almanac.factValue('account-information', { accountId: params.accountId }).then(info => { 127 | return info.balance 128 | }) 129 | }) 130 | 131 | /* 132 | * Add the facts the the engine 133 | */ 134 | engine.addFact(accountInformation) 135 | engine.addFact(isFundedAccount) 136 | engine.addFact(accountBalance) 137 | 138 | /* 139 | * Run the engine. 140 | * account-information will be loaded ONCE for the account, regardless of how many 141 | * times is-funded-account or account-balance are mentioned in the rules 142 | */ 143 | 144 | engine.run({ accountId: 1 }) 145 | ``` 146 | 147 | ### Retrieve fact values when handling events 148 | 149 | When a rule evalutes truthy and its ```event``` is called, new facts may be defined by the event handler. 150 | Note that with this technique, the rule priority becomes important; if a rule is expected to 151 | define a fact value, it's important that rule be run prior to other rules that reference the fact. To 152 | learn more about setting rule priorities, see the [rule documentation](./rules.md). 153 | 154 | ```js 155 | engine.on('success', (event, almanac) => { 156 | // Retrieve user's account info based on the event params 157 | const info = await almanac.factValue('account-information', event.params.accountId) 158 | 159 | // make an api call using the results 160 | await request.post({ url: `http://my-service/toggle?funded=${!info.funded}`) 161 | 162 | // engine execution continues when promise returned to 'success' callback resolves 163 | }) 164 | ``` 165 | 166 | ### Rule Chaining 167 | 168 | The `almanac.addRuntimeFact()` method may be used in conjunction with event emissions to 169 | set fact values during runtime, effectively enabling _rule-chaining_. Note that ordering 170 | of rule execution is enabled via the `priority` option, and is crucial component to propertly 171 | configuring rule chaining. 172 | 173 | ```js 174 | engine.addRule({ 175 | conditions, 176 | event, 177 | onSuccess: function (event, almanac) { 178 | almanac.addRuntimeFact('rule-1-passed', true) // track that the rule passed 179 | }, 180 | onFailure: function (event, almanac) { 181 | almanac.addRuntimeFact('rule-1-passed', false) // track that the rule failed 182 | }, 183 | priority: 10 // a higher priority ensures this rule will be run prior to subsequent rules 184 | }) 185 | 186 | // in a later rule: 187 | engine.addRule({ 188 | conditions: { 189 | all: [{ 190 | fact: 'rule-1-passed', 191 | operator: 'equal', 192 | value: true 193 | } 194 | }, 195 | priority: 1 // lower priority ensures this is run AFTER its predecessor 196 | } 197 | ``` 198 | 199 | See the [full example](../examples/07-rule-chaining.js) 200 | -------------------------------------------------------------------------------- /docs/facts.md: -------------------------------------------------------------------------------- 1 | # Facts 2 | 3 | Facts are methods or constants registered with the engine prior to runtime and referenced within rule conditions. Each fact method should be a pure function that may return a either computed value, or promise that resolves to a computed value. 4 | As rule conditions are evaluated during runtime, they retrieve fact values dynamically and use the condition _operator_ to compare the fact result with the condition _value_. 5 | 6 | * [Methods](#methods) 7 | * [constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance](#constructorstring-id-constantfunctionobject-params-almanac-almanac-object-options---instance) 8 | 9 | ## Methods 10 | 11 | ### constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance 12 | 13 | ```js 14 | // constant value facts 15 | let fact = new Fact('apiKey', '4feca34f9d67e99b8af2') 16 | 17 | // dynamic facts 18 | let fact = new Fact('account-type', (params, almanac) => { 19 | // ... 20 | }) 21 | 22 | // facts with options: 23 | engine.addFact('account-type', (params, almanac) => { 24 | // ... 25 | }, { cache: false, priority: 500 }) 26 | ``` 27 | 28 | **options** 29 | * { cache: Boolean } - Sets whether the engine should cache the result of this fact. Cache key is based on the factId and 'params' passed to it. Default: *true* 30 | * { priority: Integer } - Sets when the fact should run in relation to other facts and conditions. The higher the priority value, the sooner the fact will run. Default: *1* 31 | -------------------------------------------------------------------------------- /docs/walkthrough.md: -------------------------------------------------------------------------------- 1 | # Walkthrough 2 | 3 | * [Step 1: Create an Engine](#step-1-create-an-engine) 4 | * [Step 2: Add Rules](#step-2-add-rules) 5 | * [Step 3: Define Facts](#step-3-define-facts) 6 | * [Step 4: Handing Events](#step-4-handing-events) 7 | * [Step 5: Run the engine](#step-5-run-the-engine) 8 | 9 | ## Step 1: Create an Engine 10 | 11 | ```js 12 | let { Engine } = require('json-rules-engine'); 13 | let engine = new Engine(); 14 | ``` 15 | 16 | More on engines can be found [here](./engine.md) 17 | 18 | ## Step 2: Add Rules 19 | 20 | Rules are composed of two components: conditions and events. _Conditions_ are a set of requirements that must be met to trigger the rule's _event_. 21 | 22 | ```js 23 | let event = { 24 | type: 'young-adult-rocky-mnts', 25 | params: { 26 | giftCard: 'amazon', 27 | value: 50 28 | } 29 | }; 30 | let conditions = { 31 | all: [ 32 | { 33 | fact: 'age', 34 | operator: 'greaterThanInclusive', 35 | value: 18 36 | }, { 37 | fact: 'age', 38 | operator: 'lessThanInclusive', 39 | value: 25 40 | }, 41 | { 42 | any: [ 43 | { 44 | fact: 'state', 45 | params: { 46 | country: 'us' 47 | }, 48 | operator: 'equal', 49 | value: 'CO' 50 | }, { 51 | fact: 'state', 52 | params: { 53 | country: 'us' 54 | }, 55 | operator: 'equal', 56 | value: 'UT' 57 | } 58 | ] 59 | } 60 | ] 61 | }; 62 | engine.addRule({ conditions, event }); 63 | ``` 64 | 65 | The example above demonstrates a rule for finding individuals between _18 and 25_ who live in either _Utah or Colorado_. 66 | 67 | More on rules can be found [here](./rules.md) 68 | 69 | ### Step 3: Define Facts 70 | 71 | Facts are constant values or pure functions. Using the current example, if the engine were to be run, it would throw an exception: `Undefined fact:'age'` (note: this behavior can be disable via [engine options](./engine.md#Options)). 72 | 73 | Let's define some facts: 74 | 75 | ```js 76 | /* 77 | * Define the 'state' fact 78 | */ 79 | let stateFact = function(params, almanac) { 80 | // rule "params" value is passed to the fact 81 | // 'almanac' can be used to lookup other facts 82 | // via almanac.factValue() 83 | return almanac.factValue('zip-code') 84 | .then(zip => { 85 | return stateLookupByZip(params.country, zip); 86 | }); 87 | }; 88 | engine.addFact('state', stateFact); 89 | 90 | /* 91 | * Define the 'age' fact 92 | */ 93 | let ageFact = function(params, almanac) { 94 | // facts may return a promise when performing asynchronous operations 95 | // such as database calls, http requests, etc to gather data 96 | return almanac.factValue('userId').then((userId) => { 97 | return getUser(userId); 98 | }).then((user) => { 99 | return user.age; 100 | }) 101 | }; 102 | engine.addFact('age', ageFact); 103 | 104 | /* 105 | * Define the 'zip-code' fact 106 | */ 107 | let zipCodeFact = function(params, almanac) { 108 | return almanac.factValue('userId').then((userId) => { 109 | return getUser(userId); 110 | }).then((user) => { 111 | return user.zipCode; 112 | }) 113 | }; 114 | engine.addFact('zip-code', zipCodeFact); 115 | ``` 116 | 117 | Now when the engine is run, it will call the methods above whenever it encounters the ```fact: "age"``` or ```fact: "state"``` properties. 118 | 119 | **Important:** facts should be *pure functions*; their computed values will vary based on the ```params``` argument. By establishing facts as pure functions, it allows the rules engine to cache results throughout each ```run()```; facts called multiple times with the same ```params``` will trigger the computation once and cache the results for future calls. If fact caching not desired, this behavior can be turned off via the options; see the [docs](./facts.md). 120 | 121 | More on facts can be found [here](./facts.md). More on almanacs can be found [here](./almanac.md) 122 | 123 | 124 | ## Step 4: Handing Events 125 | 126 | When rule conditions are met, the application needs to respond to the event that is emitted. 127 | 128 | ```js 129 | // subscribe directly to the 'young-adult' event 130 | engine.on('young-adult-rocky-mnts', (params) => { 131 | // params: { 132 | // giftCard: 'amazon', 133 | // value: 50 134 | // } 135 | }); 136 | 137 | // - OR - 138 | 139 | // subscribe to any event emitted by the engine 140 | engine.on('success', function (event, almanac, ruleResult) { 141 | console.log('Success event:\n', event); 142 | // event: { 143 | // type: "young-adult-rocky-mnts", 144 | // params: { 145 | // giftCard: 'amazon', 146 | // value: 50 147 | // } 148 | // } 149 | }); 150 | ``` 151 | 152 | ## Step 5: Run the engine 153 | 154 | Running an engine executes the rules, and fires off event events for conditions that were met. The fact results cache will be cleared with each ```run()``` 155 | 156 | ```js 157 | // evaluate the rules 158 | //engine.run(); 159 | 160 | // Optionally, facts known at runtime may be passed to run() 161 | engine.run({ userId: 1 }); // any time a rule condition requires 'userId', '1' will be returned 162 | 163 | // run() returns a promise 164 | engine.run({ userId: 4 }).then(({ events }) => { 165 | console.log('all rules executed; the following events were triggered: ', events.map(result => JSON.stringify(event))) 166 | }); 167 | ``` 168 | Helper methods (for this example) 169 | ```js 170 | function stateLookupByZip(country, zip) { 171 | var state; 172 | switch (zip.toString()) { 173 | case '80014': 174 | state = 'CO'; 175 | break; 176 | case '84101': 177 | state = 'UT'; 178 | break; 179 | case '90210': 180 | state = 'CA'; 181 | break; 182 | default: 183 | state = 'NY'; 184 | } 185 | 186 | return state; 187 | } 188 | 189 | var users = { 190 | 1: {age: 22, zipCode: 80014}, 191 | 2: {age: 16, zipCode: 80014}, 192 | 3: {age: 35, zipCode: 84101}, 193 | 4: {age: 23, zipCode: 90210}, 194 | }; 195 | 196 | function getUser(id) { 197 | return new Promise((resolve, reject) => { 198 | setTimeout(() => { 199 | resolve(users[id]); 200 | }, 500); 201 | }); 202 | } 203 | ``` 204 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | npm-debug.log 3 | .vscode 4 | .idea 5 | .DS_Store 6 | *.tgz 7 | -------------------------------------------------------------------------------- /examples/01-hello-world.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is the hello-world example from the README. 4 | * 5 | * Usage: 6 | * node ./examples/01-hello-world.js 7 | * 8 | * For detailed output: 9 | * DEBUG=json-rules-engine node ./examples/01-hello-world.js 10 | */ 11 | 12 | require('colors') 13 | const { Engine } = require('json-rules-engine') 14 | 15 | async function start () { 16 | /** 17 | * Setup a new engine 18 | */ 19 | const engine = new Engine() 20 | 21 | /** 22 | * Create a rule 23 | */ 24 | engine.addRule({ 25 | // define the 'conditions' for when "hello world" should display 26 | conditions: { 27 | all: [{ 28 | fact: 'displayMessage', 29 | operator: 'equal', 30 | value: true 31 | }] 32 | }, 33 | // define the 'event' that will fire when the condition evaluates truthy 34 | event: { 35 | type: 'message', 36 | params: { 37 | data: 'hello-world!' 38 | } 39 | } 40 | }) 41 | 42 | /** 43 | * Define a 'displayMessage' as a constant value 44 | * Fact values do NOT need to be known at engine runtime; see the 45 | * 03-dynamic-facts.js example for how to pull in data asynchronously during runtime 46 | */ 47 | const facts = { displayMessage: true } 48 | 49 | // engine.run() evaluates the rule using the facts provided 50 | const { events } = await engine.run(facts) 51 | 52 | events.map(event => console.log(event.params.data.green)) 53 | } 54 | 55 | start() 56 | /* 57 | * OUTPUT: 58 | * 59 | * hello-world! 60 | */ 61 | -------------------------------------------------------------------------------- /examples/02-nested-boolean-logic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This example demonstates nested boolean logic - e.g. (x OR y) AND (a OR b). 4 | * 5 | * Usage: 6 | * node ./examples/02-nested-boolean-logic.js 7 | * 8 | * For detailed output: 9 | * DEBUG=json-rules-engine node ./examples/02-nested-boolean-logic.js 10 | */ 11 | 12 | require('colors') 13 | const { Engine } = require('json-rules-engine') 14 | 15 | async function start () { 16 | /** 17 | * Setup a new engine 18 | */ 19 | const engine = new Engine() 20 | 21 | // define a rule for detecting the player has exceeded foul limits. Foul out any player who: 22 | // (has committed 5 fouls AND game is 40 minutes) OR (has committed 6 fouls AND game is 48 minutes) 23 | engine.addRule({ 24 | conditions: { 25 | any: [{ 26 | all: [{ 27 | fact: 'gameDuration', 28 | operator: 'equal', 29 | value: 40 30 | }, { 31 | fact: 'personalFoulCount', 32 | operator: 'greaterThanInclusive', 33 | value: 5 34 | }], 35 | name: 'short foul limit' 36 | }, { 37 | all: [{ 38 | fact: 'gameDuration', 39 | operator: 'equal', 40 | value: 48 41 | }, { 42 | not: { 43 | fact: 'personalFoulCount', 44 | operator: 'lessThan', 45 | value: 6 46 | } 47 | }], 48 | name: 'long foul limit' 49 | }] 50 | }, 51 | event: { // define the event to fire when the conditions evaluate truthy 52 | type: 'fouledOut', 53 | params: { 54 | message: 'Player has fouled out!' 55 | } 56 | } 57 | }) 58 | 59 | /** 60 | * define the facts 61 | * note: facts may be loaded asynchronously at runtime; see the advanced example below 62 | */ 63 | const facts = { 64 | personalFoulCount: 6, 65 | gameDuration: 40 66 | } 67 | 68 | const { events } = await engine.run(facts) 69 | 70 | events.map(event => console.log(event.params.message.red)) 71 | } 72 | start() 73 | /* 74 | * OUTPUT: 75 | * 76 | * Player has fouled out! 77 | */ 78 | -------------------------------------------------------------------------------- /examples/03-dynamic-facts.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This example demonstrates computing fact values at runtime, and leveraging the 'path' feature 4 | * to select object properties returned by facts 5 | * 6 | * Usage: 7 | * node ./examples/03-dynamic-facts.js 8 | * 9 | * For detailed output: 10 | * DEBUG=json-rules-engine node ./examples/03-dynamic-facts.js 11 | */ 12 | 13 | require('colors') 14 | const { Engine } = require('json-rules-engine') 15 | 16 | // example client for making asynchronous requests to an api, database, etc 17 | const apiClient = require('./support/account-api-client') 18 | 19 | async function start () { 20 | /** 21 | * Setup a new engine 22 | */ 23 | const engine = new Engine() 24 | 25 | /** 26 | * Rule for identifying microsoft employees taking pto on christmas 27 | * 28 | * the account-information fact returns: 29 | * { company: 'XYZ', status: 'ABC', ptoDaysTaken: ['YYYY-MM-DD', 'YYYY-MM-DD'] } 30 | */ 31 | const microsoftRule = { 32 | conditions: { 33 | all: [{ 34 | fact: 'account-information', 35 | operator: 'equal', 36 | value: 'microsoft', 37 | path: '$.company' // access the 'company' property of "account-information" 38 | }, { 39 | fact: 'account-information', 40 | operator: 'in', 41 | value: ['active', 'paid-leave'], // 'status'' can be active or paid-leave 42 | path: '$.status' // access the 'status' property of "account-information" 43 | }, { 44 | fact: 'account-information', 45 | operator: 'contains', 46 | value: '2016-12-25', 47 | path: '$.ptoDaysTaken' // access the 'ptoDaysTaken' property of "account-information" 48 | }] 49 | }, 50 | event: { 51 | type: 'microsoft-christmas-pto', 52 | params: { 53 | message: 'current microsoft employee taking christmas day off' 54 | } 55 | } 56 | } 57 | engine.addRule(microsoftRule) 58 | 59 | /** 60 | * 'account-information' fact executes an api call and retrieves account data, feeding the results 61 | * into the engine. The major advantage of this technique is that although there are THREE conditions 62 | * requiring this data, only ONE api call is made. This results in much more efficient runtime performance. 63 | */ 64 | engine.addFact('account-information', function (params, almanac) { 65 | return almanac.factValue('accountId') 66 | .then(accountId => { 67 | return apiClient.getAccountInformation(accountId) 68 | }) 69 | }) 70 | 71 | // define fact(s) known at runtime 72 | const facts = { accountId: 'lincoln' } 73 | const { events } = await engine.run(facts) 74 | 75 | console.log(facts.accountId + ' is a ' + events.map(event => event.params.message)) 76 | } 77 | start() 78 | 79 | /* 80 | * OUTPUT: 81 | * 82 | * loading account information for "lincoln" 83 | * lincoln is a current microsoft employee taking christmas day off 84 | * 85 | * NOTES: 86 | * 87 | * - Notice that although all 3 conditions required data from the "account-information" fact, 88 | * the account-information api call is executed only ONCE. This is because fact results are 89 | * cached by default, increasing performance and lowering network requests. 90 | * 91 | * - See the 'fact' docs on how to disable fact caching 92 | */ 93 | -------------------------------------------------------------------------------- /examples/04-fact-dependency.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is an advanced example that demonstrates facts with dependencies 4 | * on other facts. In addition, it demonstrates facts that load data asynchronously 5 | * from outside sources (api's, databases, etc) 6 | * 7 | * Usage: 8 | * node ./examples/04-fact-dependency.js 9 | * 10 | * For detailed output: 11 | * DEBUG=json-rules-engine node ./examples/04-fact-dependency.js 12 | */ 13 | 14 | require('colors') 15 | const { Engine } = require('json-rules-engine') 16 | const accountClient = require('./support/account-api-client') 17 | 18 | async function start () { 19 | /** 20 | * Setup a new engine 21 | */ 22 | const engine = new Engine() 23 | 24 | /** 25 | * Rule for identifying microsoft employees that have been terminated. 26 | * - Demonstrates re-using a same fact with different parameters 27 | * - Demonstrates calling a base fact, which serves to load data once and reuse later 28 | */ 29 | const microsoftRule = { 30 | conditions: { 31 | all: [{ 32 | fact: 'account-information', 33 | operator: 'equal', 34 | value: 'microsoft', 35 | path: '$.company' 36 | }, { 37 | fact: 'account-information', 38 | operator: 'equal', 39 | value: 'terminated', 40 | path: '$.status' 41 | }] 42 | }, 43 | event: { type: 'microsoft-terminated-employees' } 44 | } 45 | engine.addRule(microsoftRule) 46 | 47 | /** 48 | * Rule for identifying accounts older than 5 years 49 | * - Demonstrates calling a base fact, also shared by the account-information-field fact 50 | * - Demonstrates performing computations on data retrieved by base fact 51 | */ 52 | const tenureRule = { 53 | conditions: { 54 | all: [{ 55 | fact: 'employee-tenure', 56 | operator: 'greaterThanInclusive', 57 | value: 5, 58 | params: { 59 | unit: 'years' 60 | } 61 | }] 62 | }, 63 | event: { type: 'five-year-tenure' } 64 | } 65 | engine.addRule(tenureRule) 66 | 67 | /** 68 | * Register listeners with the engine for rule success and failure 69 | */ 70 | let facts 71 | engine 72 | .on('success', event => { 73 | console.log(facts.accountId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.') 74 | }) 75 | .on('failure', event => { 76 | console.log(facts.accountId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.') 77 | }) 78 | 79 | /** 80 | * 'account-information' fact executes an api call and retrieves account data 81 | * - Demonstrates facts called only by other facts and never mentioned directly in a rule 82 | */ 83 | engine.addFact('account-information', (params, almanac) => { 84 | return almanac.factValue('accountId') 85 | .then(accountId => { 86 | return accountClient.getAccountInformation(accountId) 87 | }) 88 | }) 89 | 90 | /** 91 | * 'employee-tenure' fact retrieves account-information, and computes the duration of employment 92 | * since the account was created using 'accountInformation.createdAt' 93 | */ 94 | engine.addFact('employee-tenure', (params, almanac) => { 95 | return almanac.factValue('account-information') 96 | .then(accountInformation => { 97 | const created = new Date(accountInformation.createdAt) 98 | const now = new Date() 99 | switch (params.unit) { 100 | case 'years': 101 | return now.getFullYear() - created.getFullYear() 102 | case 'milliseconds': 103 | default: 104 | return now.getTime() - created.getTime() 105 | } 106 | }) 107 | .catch(console.log) 108 | }) 109 | 110 | // first run, using washington's facts 111 | console.log('-- FIRST RUN --') 112 | facts = { accountId: 'washington' } 113 | await engine.run(facts) 114 | 115 | console.log('-- SECOND RUN --') 116 | // second run, using jefferson's facts; facts & evaluation are independent of the first run 117 | facts = { accountId: 'jefferson' } 118 | await engine.run(facts) 119 | 120 | /* 121 | * NOTES: 122 | * 123 | * - Notice that although a total of 6 conditions were evaluated using 124 | * account-information (3 rule conditions x 2 accounts), the account-information api call 125 | * is only called twice -- once for each account. This is due to the base fact caching the results 126 | * for washington and jefferson after the initial data load. 127 | */ 128 | } 129 | start() 130 | 131 | /* 132 | * OUTPUT: 133 | * 134 | * loading account information for "washington" 135 | * washington DID meet conditions for the microsoft-terminated-employees rule. 136 | * washington did NOT meet conditions for the five-year-tenure rule. 137 | * loading account information for "jefferson" 138 | * jefferson did NOT meet conditions for the microsoft-terminated-employees rule. 139 | * jefferson DID meet conditions for the five-year-tenure rule. 140 | */ 141 | -------------------------------------------------------------------------------- /examples/05-optimizing-runtime-with-fact-priorities.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is an advanced example that demonstrates using fact priorities to optimize the rules engine. 4 | * 5 | * Usage: 6 | * node ./examples/05-optimizing-runtime-with-fact-priorities.js 7 | * 8 | * For detailed output: 9 | * DEBUG=json-rules-engine node ./examples/05-optimizing-runtime-with-fact-priorities.js 10 | */ 11 | 12 | require('colors') 13 | const { Engine } = require('json-rules-engine') 14 | const accountClient = require('./support/account-api-client') 15 | 16 | async function start () { 17 | /** 18 | * Setup a new engine 19 | */ 20 | const engine = new Engine() 21 | 22 | /** 23 | * - Demonstrates setting high performance (cpu) facts higher than low performing (network call) facts. 24 | */ 25 | const microsoftRule = { 26 | conditions: { 27 | all: [{ 28 | fact: 'account-information', 29 | operator: 'equal', 30 | value: true 31 | }, { 32 | fact: 'date', 33 | operator: 'lessThan', 34 | value: 1467331200000 // unix ts for 2016-07-01; truthy when current date is prior to 2016-07-01 35 | }] 36 | }, 37 | event: { type: 'microsoft-employees' } 38 | } 39 | engine.addRule(microsoftRule) 40 | 41 | /** 42 | * Register listeners with the engine for rule success and failure 43 | */ 44 | const facts = { accountId: 'washington' } 45 | engine 46 | .on('success', event => { 47 | console.log(facts.accountId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.') 48 | }) 49 | .on('failure', event => { 50 | console.log(facts.accountId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.') 51 | }) 52 | 53 | /** 54 | * Low and High Priorities. 55 | * Facts that do not have a priority set default to 1 56 | * @type {Integer} - Facts are run in priority from highest to lowest. 57 | */ 58 | const HIGH = 100 59 | const LOW = 1 60 | 61 | /** 62 | * 'account-information' fact executes an api call - network calls are expensive, so 63 | * we set this fact to be LOW priority; it will only be evaluated after all higher priority facts 64 | * evaluate truthy 65 | */ 66 | engine.addFact('account-information', (params, almanac) => { 67 | // this fact will not be evaluated, because the "date" fact will fail first 68 | console.log('Checking the "account-information" fact...') // this message will not appear 69 | return almanac.factValue('accountId') 70 | .then((accountId) => { 71 | return accountClient.getAccountInformation(accountId) 72 | }) 73 | }, { priority: LOW }) 74 | 75 | /** 76 | * 'date' fact returns the current unix timestamp in ms. 77 | * Because this is cheap to compute, we set it to "HIGH" priority 78 | */ 79 | engine.addFact('date', (params, almanac) => { 80 | console.log('Checking the "date" fact...') 81 | return Date.now() 82 | }, { priority: HIGH }) 83 | 84 | // define fact(s) known at runtime 85 | await engine.run() 86 | } 87 | start() 88 | 89 | /* 90 | * OUTPUT: 91 | * 92 | * Checking the "date" fact first.. 93 | * washington did NOT meet conditions for the microsoft-employees rule. 94 | */ 95 | 96 | /* 97 | * NOTES: 98 | * 99 | * - Notice that the "account-information" fact was never evaluated, saving a network call and speeding up 100 | * the engine by an order of magnitude(or more!). Swap the priorities of the facts to see both run. 101 | */ 102 | -------------------------------------------------------------------------------- /examples/06-custom-operators.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This example demonstrates using custom operators. 4 | * 5 | * A custom operator is created for detecting whether the word starts with a particular letter, 6 | * and a 'word' fact is defined for providing the test string 7 | * 8 | * In this example, Facts are passed to run() as constants known at runtime. For a more 9 | * complex example demonstrating asynchronously computed facts, see the fact-dependency example. 10 | * 11 | * Usage: 12 | * node ./examples/06-custom-operators.js 13 | * 14 | * For detailed output: 15 | * DEBUG=json-rules-engine node ./examples/06-custom-operators.js 16 | */ 17 | 18 | require('colors') 19 | const { Engine } = require('json-rules-engine') 20 | 21 | async function start () { 22 | /** 23 | * Setup a new engine 24 | */ 25 | const engine = new Engine() 26 | 27 | /** 28 | * Define a 'startsWith' custom operator, for use in later rules 29 | */ 30 | engine.addOperator('startsWith', (factValue, jsonValue) => { 31 | if (!factValue.length) return false 32 | return factValue[0].toLowerCase() === jsonValue.toLowerCase() 33 | }) 34 | 35 | /** 36 | * Add rule for detecting words that start with 'a' 37 | */ 38 | const ruleA = { 39 | conditions: { 40 | all: [{ 41 | fact: 'word', 42 | operator: 'startsWith', 43 | value: 'a' 44 | }] 45 | }, 46 | event: { 47 | type: 'start-with-a' 48 | } 49 | } 50 | engine.addRule(ruleA) 51 | 52 | /* 53 | * Add rule for detecting words that start with 'b' 54 | */ 55 | const ruleB = { 56 | conditions: { 57 | all: [{ 58 | fact: 'word', 59 | operator: 'startsWith', 60 | value: 'b' 61 | }] 62 | }, 63 | event: { 64 | type: 'start-with-b' 65 | } 66 | } 67 | engine.addRule(ruleB) 68 | 69 | // utility for printing output 70 | const printEventType = { 71 | 'start-with-a': 'start with "a"', 72 | 'start-with-b': 'start with "b"' 73 | } 74 | 75 | /** 76 | * Register listeners with the engine for rule success and failure 77 | */ 78 | let facts 79 | engine 80 | .on('success', event => { 81 | console.log(facts.word + ' DID '.green + printEventType[event.type]) 82 | }) 83 | .on('failure', event => { 84 | console.log(facts.word + ' did ' + 'NOT'.red + ' ' + printEventType[event.type]) 85 | }) 86 | 87 | /** 88 | * Each run() of the engine executes on an independent set of facts. We'll run twice, once per word 89 | */ 90 | 91 | // first run, using 'bacon' 92 | facts = { word: 'bacon' } 93 | await engine.run(facts) 94 | 95 | // second run, using 'antelope' 96 | facts = { word: 'antelope' } 97 | await engine.run(facts) 98 | } 99 | start() 100 | 101 | /* 102 | * OUTPUT: 103 | * 104 | * bacon did NOT start with "a" 105 | * bacon DID start with "b" 106 | * antelope DID start with "a" 107 | * antelope did NOT start with "b" 108 | */ 109 | -------------------------------------------------------------------------------- /examples/07-rule-chaining.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is an advanced example demonstrating rules that passed based off the 4 | * results of other rules by adding runtime facts. It also demonstrates 5 | * accessing the runtime facts after engine execution. 6 | * 7 | * Usage: 8 | * node ./examples/07-rule-chaining.js 9 | * 10 | * For detailed output: 11 | * DEBUG=json-rules-engine node ./examples/07-rule-chaining.js 12 | */ 13 | 14 | require('colors') 15 | const { Engine } = require('json-rules-engine') 16 | const { getAccountInformation } = require('./support/account-api-client') 17 | 18 | async function start () { 19 | /** 20 | * Setup a new engine 21 | */ 22 | const engine = new Engine() 23 | 24 | /** 25 | * Rule for identifying people who may like screwdrivers 26 | */ 27 | const drinkRule = { 28 | conditions: { 29 | all: [{ 30 | fact: 'drinksOrangeJuice', 31 | operator: 'equal', 32 | value: true 33 | }, { 34 | fact: 'enjoysVodka', 35 | operator: 'equal', 36 | value: true 37 | }] 38 | }, 39 | event: { type: 'drinks-screwdrivers' }, 40 | priority: 10, // IMPORTANT! Set a higher priority for the drinkRule, so it runs first 41 | onSuccess: async function (event, almanac) { 42 | almanac.addFact('screwdriverAficionado', true) 43 | 44 | // asychronous operations can be performed within callbacks 45 | // engine execution will not proceed until the returned promises is resolved 46 | const accountId = await almanac.factValue('accountId') 47 | const accountInfo = await getAccountInformation(accountId) 48 | almanac.addFact('accountInfo', accountInfo) 49 | }, 50 | onFailure: function (event, almanac) { 51 | almanac.addFact('screwdriverAficionado', false) 52 | } 53 | } 54 | engine.addRule(drinkRule) 55 | 56 | /** 57 | * Rule for identifying people who should be invited to a screwdriver social 58 | * - Only invite people who enjoy screw drivers 59 | * - Only invite people who are sociable 60 | */ 61 | const inviteRule = { 62 | conditions: { 63 | all: [{ 64 | fact: 'screwdriverAficionado', // this fact value is set when the drinkRule is evaluated 65 | operator: 'equal', 66 | value: true 67 | }, { 68 | fact: 'isSociable', 69 | operator: 'equal', 70 | value: true 71 | }, { 72 | fact: 'accountInfo', 73 | path: '$.company', 74 | operator: 'equal', 75 | value: 'microsoft' 76 | }] 77 | }, 78 | event: { type: 'invite-to-screwdriver-social' }, 79 | priority: 5 // Set a lower priority for the drinkRule, so it runs later (default: 1) 80 | } 81 | engine.addRule(inviteRule) 82 | 83 | /** 84 | * Register listeners with the engine for rule success and failure 85 | */ 86 | engine 87 | .on('success', async (event, almanac) => { 88 | const accountInfo = await almanac.factValue('accountInfo') 89 | const accountId = await almanac.factValue('accountId') 90 | console.log(`${accountId}(${accountInfo.company}) ` + 'DID'.green + ` meet conditions for the ${event.type.underline} rule.`) 91 | }) 92 | .on('failure', async (event, almanac) => { 93 | const accountId = await almanac.factValue('accountId') 94 | console.log(`${accountId} did ` + 'NOT'.red + ` meet conditions for the ${event.type.underline} rule.`) 95 | }) 96 | 97 | // define fact(s) known at runtime 98 | let facts = { accountId: 'washington', drinksOrangeJuice: true, enjoysVodka: true, isSociable: true, accountInfo: {} } 99 | 100 | // first run, using washington's facts 101 | let results = await engine.run(facts) 102 | 103 | // isScrewdriverAficionado was a fact set by engine.run() 104 | let isScrewdriverAficionado = results.almanac.factValue('screwdriverAficionado') 105 | console.log(`${facts.accountId} ${isScrewdriverAficionado ? 'IS'.green : 'IS NOT'.red} a screwdriver aficionado`) 106 | 107 | facts = { accountId: 'jefferson', drinksOrangeJuice: true, enjoysVodka: false, isSociable: true, accountInfo: {} } 108 | results = await engine.run(facts) // second run, using jefferson's facts; facts & evaluation are independent of the first run 109 | 110 | isScrewdriverAficionado = await results.almanac.factValue('screwdriverAficionado') 111 | console.log(`${facts.accountId} ${isScrewdriverAficionado ? 'IS'.green : 'IS NOT'.red} a screwdriver aficionado`) 112 | } 113 | 114 | start() 115 | 116 | /* 117 | * OUTPUT: 118 | * 119 | * loading account information for "washington" 120 | * washington(microsoft) DID meet conditions for the drinks-screwdrivers rule. 121 | * washington(microsoft) DID meet conditions for the invite-to-screwdriver-social rule. 122 | * washington IS a screwdriver aficionado 123 | * jefferson did NOT meet conditions for the drinks-screwdrivers rule. 124 | * jefferson did NOT meet conditions for the invite-to-screwdriver-social rule. 125 | * jefferson IS NOT a screwdriver aficionado 126 | */ 127 | -------------------------------------------------------------------------------- /examples/08-fact-comparison.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is a basic example demonstrating a condition that compares two facts 4 | * 5 | * Usage: 6 | * node ./examples/08-fact-comparison.js 7 | * 8 | * For detailed output: 9 | * DEBUG=json-rules-engine node ./examples/08-fact-comparison.js 10 | */ 11 | 12 | require('colors') 13 | const { Engine } = require('json-rules-engine') 14 | 15 | async function start () { 16 | /** 17 | * Setup a new engine 18 | */ 19 | const engine = new Engine() 20 | 21 | /** 22 | * Rule for determining if account has enough money to purchase a $50 gift card product 23 | * 24 | * customer-account-balance >= $50 gift card 25 | */ 26 | const rule = { 27 | conditions: { 28 | all: [{ 29 | // extract 'balance' from the 'customer' account type 30 | fact: 'account', 31 | path: '$.balance', 32 | params: { 33 | accountType: 'customer' 34 | }, 35 | 36 | operator: 'greaterThanInclusive', // >= 37 | 38 | // "value" in this instance is an object containing a fact definition 39 | // fact helpers "path" and "params" are supported here as well 40 | value: { 41 | fact: 'product', 42 | path: '$.price', 43 | params: { 44 | productId: 'giftCard' 45 | } 46 | } 47 | }] 48 | }, 49 | event: { type: 'customer-can-afford-gift-card' } 50 | } 51 | engine.addRule(rule) 52 | 53 | engine.addFact('account', (params, almanac) => { 54 | // get account list 55 | return almanac.factValue('accounts') 56 | .then(accounts => { 57 | // use "params" to filter down to the type specified, in this case the "customer" account 58 | const customerAccount = accounts.filter(account => account.type === params.accountType) 59 | // return the customerAccount object, which "path" will use to pull the "balance" property 60 | return customerAccount[0] 61 | }) 62 | }) 63 | 64 | engine.addFact('product', (params, almanac) => { 65 | // get product list 66 | return almanac.factValue('products') 67 | .then(products => { 68 | // use "params" to filter down to the product specified, in this case the "giftCard" product 69 | const product = products.filter(product => product.productId === params.productId) 70 | // return the product object, which "path" will use to pull the "price" property 71 | return product[0] 72 | }) 73 | }) 74 | 75 | /** 76 | * Register listeners with the engine for rule success and failure 77 | */ 78 | let facts 79 | engine 80 | .on('success', (event, almanac) => { 81 | console.log(facts.userId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.') 82 | }) 83 | .on('failure', event => { 84 | console.log(facts.userId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.') 85 | }) 86 | 87 | // define fact(s) known at runtime 88 | const productList = { 89 | products: [ 90 | { 91 | productId: 'giftCard', 92 | price: 50 93 | }, { 94 | productId: 'widget', 95 | price: 45 96 | }, { 97 | productId: 'widget-plus', 98 | price: 800 99 | } 100 | ] 101 | } 102 | 103 | let userFacts = { 104 | userId: 'washington', 105 | accounts: [{ 106 | type: 'customer', 107 | balance: 500 108 | }, { 109 | type: 'partner', 110 | balance: 0 111 | }] 112 | } 113 | 114 | // compile facts to be fed to the engine 115 | facts = Object.assign({}, userFacts, productList) 116 | 117 | // first run, user can afford a gift card 118 | await engine.run(facts) 119 | 120 | // second run; a user that cannot afford a gift card 121 | userFacts = { 122 | userId: 'jefferson', 123 | accounts: [{ 124 | type: 'customer', 125 | balance: 30 126 | }] 127 | } 128 | facts = Object.assign({}, userFacts, productList) 129 | await engine.run(facts) 130 | } 131 | start() 132 | /* 133 | * OUTPUT: 134 | * 135 | * washington DID meet conditions for the customer-can-afford-gift-card rule. 136 | * jefferson did NOT meet conditions for the customer-can-afford-gift-card rule. 137 | */ 138 | -------------------------------------------------------------------------------- /examples/09-rule-results.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is a basic example demonstrating how to leverage the metadata supplied by rule results 4 | * 5 | * Usage: 6 | * node ./examples/09-rule-results.js 7 | * 8 | * For detailed output: 9 | * DEBUG=json-rules-engine node ./examples/09-rule-results.js 10 | */ 11 | require('colors') 12 | const { Engine } = require('json-rules-engine') 13 | 14 | async function start () { 15 | /** 16 | * Setup a new engine 17 | */ 18 | const engine = new Engine() 19 | 20 | // rule for determining honor role student athletes (student has GPA >= 3.5 AND is an athlete) 21 | engine.addRule({ 22 | conditions: { 23 | all: [{ 24 | fact: 'athlete', 25 | operator: 'equal', 26 | value: true 27 | }, { 28 | fact: 'GPA', 29 | operator: 'greaterThanInclusive', 30 | value: 3.5 31 | }] 32 | }, 33 | event: { // define the event to fire when the conditions evaluate truthy 34 | type: 'honor-roll', 35 | params: { 36 | message: 'Student made the athletics honor-roll' 37 | } 38 | }, 39 | name: 'Athlete GPA Rule' 40 | }) 41 | 42 | function render (message, ruleResult) { 43 | // if rule succeeded, render success message 44 | if (ruleResult.result) { 45 | return console.log(`${message}`.green) 46 | } 47 | // if rule failed, iterate over each failed condition to determine why the student didn't qualify for athletics honor roll 48 | const detail = ruleResult.conditions.all.filter(condition => !condition.result) 49 | .map(condition => { 50 | switch (condition.operator) { 51 | case 'equal': 52 | return `was not an ${condition.fact}` 53 | case 'greaterThanInclusive': 54 | return `${condition.fact} of ${condition.factResult} was too low` 55 | default: 56 | return '' 57 | } 58 | }).join(' and ') 59 | console.log(`${message} ${detail}`.red) 60 | } 61 | 62 | /** 63 | * On success, retrieve the student's username and print rule name for display purposes, and render 64 | */ 65 | engine.on('success', (event, almanac, ruleResult) => { 66 | almanac.factValue('username').then(username => { 67 | render(`${username.bold} succeeded ${ruleResult.name}! ${event.params.message}`, ruleResult) 68 | }) 69 | }) 70 | 71 | /** 72 | * On failure, retrieve the student's username and print rule name for display purposes, and render 73 | */ 74 | engine.on('failure', (event, almanac, ruleResult) => { 75 | almanac.factValue('username').then(username => { 76 | render(`${username.bold} failed ${ruleResult.name} - `, ruleResult) 77 | }) 78 | }) 79 | 80 | // Run the engine for 5 different students 81 | await Promise.all([ 82 | engine.run({ athlete: false, GPA: 3.9, username: 'joe' }), 83 | engine.run({ athlete: true, GPA: 3.5, username: 'larry' }), 84 | engine.run({ athlete: false, GPA: 3.1, username: 'jane' }), 85 | engine.run({ athlete: true, GPA: 4.0, username: 'janet' }), 86 | engine.run({ athlete: true, GPA: 1.1, username: 'sarah' }) 87 | ]) 88 | } 89 | start() 90 | /* 91 | * OUTPUT: 92 | * 93 | * joe failed Athlete GPA Rule - was not an athlete 94 | * larry succeeded Athlete GPA Rule! Student made the athletics honor-roll 95 | * jane failed Athlete GPA Rule - was not an athlete and GPA of 3.1 was too low 96 | * janet succeeded Athlete GPA Rule! Student made the athletics honor-roll 97 | * sarah failed Athlete GPA Rule - GPA of 1.1 was too low 98 | */ 99 | -------------------------------------------------------------------------------- /examples/10-condition-sharing.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is an advanced example demonstrating rules that re-use a condition defined 4 | * in the engine. 5 | * 6 | * Usage: 7 | * node ./examples/10-condition-sharing.js 8 | * 9 | * For detailed output: 10 | * DEBUG=json-rules-engine node ./examples/10-condition-sharing.js 11 | */ 12 | 13 | require('colors') 14 | const { Engine } = require('json-rules-engine') 15 | 16 | async function start () { 17 | /** 18 | * Setup a new engine 19 | */ 20 | const engine = new Engine() 21 | 22 | /** 23 | * Condition that will be used to determine if a user likes screwdrivers 24 | */ 25 | engine.setCondition('screwdriverAficionado', { 26 | all: [ 27 | { 28 | fact: 'drinksOrangeJuice', 29 | operator: 'equal', 30 | value: true 31 | }, 32 | { 33 | fact: 'enjoysVodka', 34 | operator: 'equal', 35 | value: true 36 | } 37 | ] 38 | }) 39 | 40 | /** 41 | * Rule for identifying people who should be invited to a screwdriver social 42 | * - Only invite people who enjoy screw drivers 43 | * - Only invite people who are sociable 44 | */ 45 | const inviteRule = { 46 | conditions: { 47 | all: [ 48 | { 49 | condition: 'screwdriverAficionado' 50 | }, 51 | { 52 | fact: 'isSociable', 53 | operator: 'equal', 54 | value: true 55 | } 56 | ] 57 | }, 58 | event: { type: 'invite-to-screwdriver-social' } 59 | } 60 | engine.addRule(inviteRule) 61 | 62 | /** 63 | * Rule for identifying people who should be invited to the other social 64 | * - Only invite people who don't enjoy screw drivers 65 | * - Only invite people who are sociable 66 | */ 67 | const otherInviteRule = { 68 | conditions: { 69 | all: [ 70 | { 71 | not: { 72 | condition: 'screwdriverAficionado' 73 | } 74 | }, 75 | { 76 | fact: 'isSociable', 77 | operator: 'equal', 78 | value: true 79 | } 80 | ] 81 | }, 82 | event: { type: 'invite-to-other-social' } 83 | } 84 | engine.addRule(otherInviteRule) 85 | 86 | /** 87 | * Register listeners with the engine for rule success and failure 88 | */ 89 | engine 90 | .on('success', async (event, almanac) => { 91 | const accountId = await almanac.factValue('accountId') 92 | console.log( 93 | `${accountId}` + 94 | 'DID'.green + 95 | ` meet conditions for the ${event.type.underline} rule.` 96 | ) 97 | }) 98 | .on('failure', async (event, almanac) => { 99 | const accountId = await almanac.factValue('accountId') 100 | console.log( 101 | `${accountId} did ` + 102 | 'NOT'.red + 103 | ` meet conditions for the ${event.type.underline} rule.` 104 | ) 105 | }) 106 | 107 | // define fact(s) known at runtime 108 | let facts = { 109 | accountId: 'washington', 110 | drinksOrangeJuice: true, 111 | enjoysVodka: true, 112 | isSociable: true 113 | } 114 | 115 | // first run, using washington's facts 116 | await engine.run(facts) 117 | 118 | facts = { 119 | accountId: 'jefferson', 120 | drinksOrangeJuice: true, 121 | enjoysVodka: false, 122 | isSociable: true, 123 | accountInfo: {} 124 | } 125 | 126 | // second run, using jefferson's facts; facts & evaluation are independent of the first run 127 | await engine.run(facts) 128 | } 129 | 130 | start() 131 | 132 | /* 133 | * OUTPUT: 134 | * 135 | * washington DID meet conditions for the invite-to-screwdriver-social rule. 136 | * washington did NOT meet conditions for the invite-to-other-social rule. 137 | * jefferson did NOT meet conditions for the invite-to-screwdriver-social rule. 138 | * jefferson DID meet conditions for the invite-to-other-social rule. 139 | */ 140 | -------------------------------------------------------------------------------- /examples/11-using-facts-in-events.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This is an advanced example demonstrating an event that emits the value 4 | * of a fact in it's parameters. 5 | * 6 | * Usage: 7 | * node ./examples/11-using-facts-in-events.js 8 | * 9 | * For detailed output: 10 | * DEBUG=json-rules-engine node ./examples/11-using-facts-in-events.js 11 | */ 12 | 13 | require('colors') 14 | const { Engine, Fact } = require('json-rules-engine') 15 | 16 | async function start () { 17 | /** 18 | * Setup a new engine 19 | */ 20 | const engine = new Engine([], { replaceFactsInEventParams: true }) 21 | 22 | // in-memory "database" 23 | let currentHighScore = null 24 | const currentHighScoreFact = new Fact('currentHighScore', () => currentHighScore) 25 | 26 | /** 27 | * Rule for when you've gotten the high score 28 | * event will include your score and initials. 29 | */ 30 | const highScoreRule = { 31 | conditions: { 32 | any: [ 33 | { 34 | fact: 'currentHighScore', 35 | operator: 'equal', 36 | value: null 37 | }, 38 | { 39 | fact: 'score', 40 | operator: 'greaterThan', 41 | value: { 42 | fact: 'currentHighScore', 43 | path: '$.score' 44 | } 45 | } 46 | ] 47 | }, 48 | event: { 49 | type: 'highscore', 50 | params: { 51 | initials: { fact: 'initials' }, 52 | score: { fact: 'score' } 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * Rule for when the game is over and you don't have the high score 59 | * event will include the previous high score 60 | */ 61 | const gameOverRule = { 62 | conditions: { 63 | all: [ 64 | { 65 | fact: 'score', 66 | operator: 'lessThanInclusive', 67 | value: { 68 | fact: 'currentHighScore', 69 | path: '$.score' 70 | } 71 | } 72 | ] 73 | }, 74 | event: { 75 | type: 'gameover', 76 | params: { 77 | initials: { 78 | fact: 'currentHighScore', 79 | path: '$.initials' 80 | }, 81 | score: { 82 | fact: 'currentHighScore', 83 | path: '$.score' 84 | } 85 | } 86 | } 87 | } 88 | engine.addRule(highScoreRule) 89 | engine.addRule(gameOverRule) 90 | engine.addFact(currentHighScoreFact) 91 | 92 | /** 93 | * Register listeners with the engine for rule success 94 | */ 95 | engine 96 | .on('success', async ({ params: { initials, score } }) => { 97 | console.log(`HIGH SCORE\n${initials} - ${score}`) 98 | }) 99 | .on('success', ({ type, params }) => { 100 | if (type === 'highscore') { 101 | currentHighScore = params 102 | } 103 | }) 104 | 105 | let facts = { 106 | initials: 'DOG', 107 | score: 968 108 | } 109 | 110 | // first run, without a high score 111 | await engine.run(facts) 112 | 113 | console.log('\n') 114 | 115 | // new player 116 | facts = { 117 | initials: 'AAA', 118 | score: 500 119 | } 120 | 121 | // new player hasn't gotten the high score yet 122 | await engine.run(facts) 123 | 124 | console.log('\n') 125 | 126 | facts = { 127 | initials: 'AAA', 128 | score: 1000 129 | } 130 | 131 | // second run, with a high score 132 | await engine.run(facts) 133 | } 134 | 135 | start() 136 | 137 | /* 138 | * OUTPUT: 139 | * 140 | * NEW SCORE: 141 | * DOG - 968 142 | * 143 | * HIGH SCORE: 144 | * DOG - 968 145 | * 146 | * HIGH SCORE: 147 | * AAA - 1000 148 | */ 149 | -------------------------------------------------------------------------------- /examples/12-using-custom-almanac.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('colors') 4 | const { Almanac, Engine } = require('json-rules-engine') 5 | 6 | /** 7 | * Almanac that support piping values through named functions 8 | */ 9 | class PipedAlmanac extends Almanac { 10 | constructor (options) { 11 | super(options) 12 | this.pipes = new Map() 13 | } 14 | 15 | addPipe (name, pipe) { 16 | this.pipes.set(name, pipe) 17 | } 18 | 19 | factValue (factId, params, path) { 20 | let pipes = [] 21 | if (params && 'pipes' in params && Array.isArray(params.pipes)) { 22 | pipes = params.pipes 23 | delete params.pipes 24 | } 25 | return super.factValue(factId, params, path).then(value => { 26 | return pipes.reduce((value, pipeName) => { 27 | const pipe = this.pipes.get(pipeName) 28 | if (pipe) { 29 | return pipe(value) 30 | } 31 | return value 32 | }, value) 33 | }) 34 | } 35 | } 36 | 37 | async function start () { 38 | const engine = new Engine() 39 | .addRule({ 40 | conditions: { 41 | all: [ 42 | { 43 | fact: 'age', 44 | params: { 45 | // the addOne pipe adds one to the value 46 | pipes: ['addOne'] 47 | }, 48 | operator: 'greaterThanInclusive', 49 | value: 21 50 | } 51 | ] 52 | }, 53 | event: { 54 | type: 'Over 21(ish)' 55 | } 56 | }) 57 | 58 | engine.on('success', async (event, almanac) => { 59 | const name = await almanac.factValue('name') 60 | const age = await almanac.factValue('age') 61 | console.log(`${name} is ${age} years old and ${'is'.green} ${event.type}`) 62 | }) 63 | 64 | engine.on('failure', async (event, almanac) => { 65 | const name = await almanac.factValue('name') 66 | const age = await almanac.factValue('age') 67 | console.log(`${name} is ${age} years old and ${'is not'.red} ${event.type}`) 68 | }) 69 | 70 | const createAlmanacWithPipes = () => { 71 | const almanac = new PipedAlmanac() 72 | almanac.addPipe('addOne', (v) => v + 1) 73 | return almanac 74 | } 75 | 76 | // first run Bob who is less than 20 77 | await engine.run({ name: 'Bob', age: 19 }, { almanac: createAlmanacWithPipes() }) 78 | 79 | // second run Alice who is 21 80 | await engine.run({ name: 'Alice', age: 21 }, { almanac: createAlmanacWithPipes() }) 81 | 82 | // third run Chad who is 20 83 | await engine.run({ name: 'Chad', age: 20 }, { almanac: createAlmanacWithPipes() }) 84 | } 85 | 86 | start() 87 | 88 | /* 89 | * OUTPUT: 90 | * 91 | * Bob is 19 years old and is not Over 21(ish) 92 | * Alice is 21 years old and is Over 21(ish) 93 | * Chad is 20 years old and is Over 21(ish) 94 | */ 95 | -------------------------------------------------------------------------------- /examples/13-using-operator-decorators.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * This example demonstrates using operator decorators. 4 | * 5 | * In this example, a fact contains a list of strings and we want to check if any of these are valid. 6 | * 7 | * Usage: 8 | * node ./examples/12-using-operator-decorators.js 9 | * 10 | * For detailed output: 11 | * DEBUG=json-rules-engine node ./examples/12-using-operator-decorators.js 12 | */ 13 | 14 | require('colors') 15 | const { Engine } = require('json-rules-engine') 16 | 17 | async function start () { 18 | /** 19 | * Setup a new engine 20 | */ 21 | const engine = new Engine() 22 | 23 | /** 24 | * Add a rule for validating a tag (fact) 25 | * against a set of tags that are valid (also a fact) 26 | */ 27 | const validTags = { 28 | conditions: { 29 | all: [{ 30 | fact: 'tags', 31 | operator: 'everyFact:in', 32 | value: { fact: 'validTags' } 33 | }] 34 | }, 35 | event: { 36 | type: 'valid tags' 37 | } 38 | } 39 | 40 | engine.addRule(validTags) 41 | 42 | engine.addFact('validTags', ['dev', 'staging', 'load', 'prod']) 43 | 44 | let facts 45 | 46 | engine 47 | .on('success', event => { 48 | console.log(facts.tags.join(', ') + ' WERE'.green + ' all ' + event.type) 49 | }) 50 | .on('failure', event => { 51 | console.log(facts.tags.join(', ') + ' WERE NOT'.red + ' all ' + event.type) 52 | }) 53 | 54 | // first run with valid tags 55 | facts = { tags: ['dev', 'prod'] } 56 | await engine.run(facts) 57 | 58 | // second run with an invalid tag 59 | facts = { tags: ['dev', 'deleted'] } 60 | await engine.run(facts) 61 | 62 | // add a new decorator to allow for a case-insensitive match 63 | engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => { 64 | return next(factValue.toLowerCase(), jsonValue.toLowerCase()) 65 | }) 66 | 67 | // new rule for case-insensitive validation 68 | const caseInsensitiveValidTags = { 69 | conditions: { 70 | all: [{ 71 | fact: 'tags', 72 | // everyFact has someValue that caseInsensitive is equal 73 | operator: 'everyFact:someValue:caseInsensitive:equal', 74 | value: { fact: 'validTags' } 75 | }] 76 | }, 77 | event: { 78 | type: 'valid tags (case insensitive)' 79 | } 80 | } 81 | 82 | engine.addRule(caseInsensitiveValidTags) 83 | 84 | // third run with a tag that is valid if case insensitive 85 | facts = { tags: ['dev', 'PROD'] } 86 | await engine.run(facts) 87 | } 88 | start() 89 | 90 | /* 91 | * OUTPUT: 92 | * 93 | * dev, prod WERE all valid tags 94 | * dev, deleted WERE NOT all valid tags 95 | * dev, PROD WERE NOT all valid tags 96 | * dev, PROD WERE all valid tags (case insensitive) 97 | */ 98 | -------------------------------------------------------------------------------- /examples/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-rules-engine-examples", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "json-rules-engine-examples", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "json-rules-engine": "../" 13 | } 14 | }, 15 | "..": { 16 | "version": "6.4.2", 17 | "license": "ISC", 18 | "dependencies": { 19 | "clone": "^2.1.2", 20 | "eventemitter2": "^6.4.4", 21 | "hash-it": "^6.0.0", 22 | "jsonpath-plus": "^7.2.0", 23 | "lodash.isobjectlike": "^4.0.0" 24 | }, 25 | "devDependencies": { 26 | "babel-cli": "6.26.0", 27 | "babel-core": "6.26.3", 28 | "babel-eslint": "10.1.0", 29 | "babel-loader": "8.2.2", 30 | "babel-polyfill": "6.26.0", 31 | "babel-preset-es2015": "~6.24.1", 32 | "babel-preset-stage-0": "~6.24.1", 33 | "babel-register": "6.26.0", 34 | "chai": "^4.3.4", 35 | "chai-as-promised": "^7.1.1", 36 | "colors": "~1.4.0", 37 | "dirty-chai": "2.0.1", 38 | "lodash": "4.17.21", 39 | "mocha": "^8.4.0", 40 | "perfy": "^1.1.5", 41 | "sinon": "^11.1.1", 42 | "sinon-chai": "^3.7.0", 43 | "snazzy": "^9.0.0", 44 | "standard": "^16.0.3", 45 | "tsd": "^0.17.0" 46 | } 47 | }, 48 | "node_modules/json-rules-engine": { 49 | "resolved": "..", 50 | "link": true 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-rules-engine-examples", 3 | "version": "1.0.0", 4 | "description": "examples for json-rule-engine", 5 | "main": "", 6 | "private": true, 7 | "scripts": { 8 | "all": "for i in *.js; do node $i; done;" 9 | }, 10 | "author": "Cache Hamm ", 11 | "license": "ISC", 12 | "dependencies": { 13 | "json-rules-engine": "../" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/support/account-api-client.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('colors') 4 | 5 | const accountData = { 6 | washington: { 7 | company: 'microsoft', 8 | status: 'terminated', 9 | ptoDaysTaken: ['2016-12-25', '2016-04-01'], 10 | createdAt: '2012-02-14' 11 | }, 12 | jefferson: { 13 | company: 'apple', 14 | status: 'terminated', 15 | ptoDaysTaken: ['2015-01-25'], 16 | createdAt: '2005-04-03' 17 | }, 18 | lincoln: { 19 | company: 'microsoft', 20 | status: 'active', 21 | ptoDaysTaken: ['2016-02-21', '2016-12-25', '2016-03-28'], 22 | createdAt: '2015-06-26' 23 | } 24 | } 25 | 26 | /** 27 | * mock api client for retrieving account information 28 | */ 29 | module.exports = { 30 | getAccountInformation: (accountId) => { 31 | const message = 'loading account information for "' + accountId + '"' 32 | console.log(message.dim) 33 | return new Promise((resolve, reject) => { 34 | setImmediate(() => { 35 | resolve(accountData[accountId]) 36 | }) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-rules-engine", 3 | "version": "7.3.1", 4 | "description": "Rules Engine expressed in simple json", 5 | "main": "dist/index.js", 6 | "types": "types/index.d.ts", 7 | "engines": { 8 | "node": ">=18.0.0" 9 | }, 10 | "scripts": { 11 | "test": "mocha && npm run lint --silent && npm run test:types", 12 | "test:types": "tsd", 13 | "lint": "standard --verbose --env mocha | snazzy || true", 14 | "lint:fix": "standard --fix --env mocha", 15 | "prepublishOnly": "npm run build", 16 | "build": "babel --stage 1 -d dist/ src/", 17 | "watch": "babel --watch --stage 1 -d dist/ src", 18 | "examples": "./test/support/example_runner.sh" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/cachecontrol/json-rules-engine" 23 | }, 24 | "keywords": [ 25 | "rules", 26 | "engine", 27 | "rules engine" 28 | ], 29 | "standard": { 30 | "parser": "babel-eslint", 31 | "ignore": [ 32 | "/dist", 33 | "/examples/node_modules" 34 | ], 35 | "globals": [ 36 | "context", 37 | "xcontext", 38 | "describe", 39 | "xdescribe", 40 | "it", 41 | "xit", 42 | "before", 43 | "beforeEach", 44 | "expect", 45 | "factories" 46 | ] 47 | }, 48 | "mocha": { 49 | "require": [ 50 | "babel-core/register", 51 | "babel-polyfill" 52 | ], 53 | "file": "./test/support/bootstrap.js", 54 | "checkLeaks": true, 55 | "recursive": true, 56 | "globals": [ 57 | "expect" 58 | ] 59 | }, 60 | "author": "Cache Hamm ", 61 | "contributors": [ 62 | "Chris Pardy " 63 | ], 64 | "license": "ISC", 65 | "bugs": { 66 | "url": "https://github.com/cachecontrol/json-rules-engine/issues" 67 | }, 68 | "homepage": "https://github.com/cachecontrol/json-rules-engine", 69 | "devDependencies": { 70 | "babel-cli": "6.26.0", 71 | "babel-core": "6.26.3", 72 | "babel-eslint": "10.1.0", 73 | "babel-loader": "8.2.2", 74 | "babel-polyfill": "6.26.0", 75 | "babel-preset-es2015": "~6.24.1", 76 | "babel-preset-stage-0": "~6.24.1", 77 | "babel-register": "6.26.0", 78 | "chai": "^4.3.4", 79 | "chai-as-promised": "^7.1.1", 80 | "colors": "~1.4.0", 81 | "dirty-chai": "2.0.1", 82 | "lodash": "4.17.21", 83 | "mocha": "^8.4.0", 84 | "perfy": "^1.1.5", 85 | "sinon": "^11.1.1", 86 | "sinon-chai": "^3.7.0", 87 | "snazzy": "^9.0.0", 88 | "standard": "^16.0.3", 89 | "tsd": "^0.17.0" 90 | }, 91 | "dependencies": { 92 | "clone": "^2.1.2", 93 | "eventemitter2": "^6.4.4", 94 | "hash-it": "^6.0.0", 95 | "jsonpath-plus": "^10.3.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/almanac.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Fact from './fact' 4 | import { UndefinedFactError } from './errors' 5 | import debug from './debug' 6 | 7 | import { JSONPath } from 'jsonpath-plus' 8 | 9 | function defaultPathResolver (value, path) { 10 | return JSONPath({ path, json: value, wrap: false }) 11 | } 12 | 13 | /** 14 | * Fact results lookup 15 | * Triggers fact computations and saves the results 16 | * A new almanac is used for every engine run() 17 | */ 18 | export default class Almanac { 19 | constructor (options = {}) { 20 | this.factMap = new Map() 21 | this.factResultsCache = new Map() // { cacheKey: Promise } 22 | this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts) 23 | this.pathResolver = options.pathResolver || defaultPathResolver 24 | this.events = { success: [], failure: [] } 25 | this.ruleResults = [] 26 | } 27 | 28 | /** 29 | * Adds a success event 30 | * @param {Object} event 31 | */ 32 | addEvent (event, outcome) { 33 | if (!outcome) throw new Error('outcome required: "success" | "failure"]') 34 | this.events[outcome].push(event) 35 | } 36 | 37 | /** 38 | * retrieve successful events 39 | */ 40 | getEvents (outcome = '') { 41 | if (outcome) return this.events[outcome] 42 | return this.events.success.concat(this.events.failure) 43 | } 44 | 45 | /** 46 | * Adds a rule result 47 | * @param {Object} event 48 | */ 49 | addResult (ruleResult) { 50 | this.ruleResults.push(ruleResult) 51 | } 52 | 53 | /** 54 | * retrieve successful events 55 | */ 56 | getResults () { 57 | return this.ruleResults 58 | } 59 | 60 | /** 61 | * Retrieve fact by id, raising an exception if it DNE 62 | * @param {String} factId 63 | * @return {Fact} 64 | */ 65 | _getFact (factId) { 66 | return this.factMap.get(factId) 67 | } 68 | 69 | /** 70 | * Registers fact with the almanac 71 | * @param {[type]} fact [description] 72 | */ 73 | _addConstantFact (fact) { 74 | this.factMap.set(fact.id, fact) 75 | this._setFactValue(fact, {}, fact.value) 76 | } 77 | 78 | /** 79 | * Sets the computed value of a fact 80 | * @param {Fact} fact 81 | * @param {Object} params - values for differentiating this fact value from others, used for cache key 82 | * @param {Mixed} value - computed value 83 | */ 84 | _setFactValue (fact, params, value) { 85 | const cacheKey = fact.getCacheKey(params) 86 | const factValue = Promise.resolve(value) 87 | if (cacheKey) { 88 | this.factResultsCache.set(cacheKey, factValue) 89 | } 90 | return factValue 91 | } 92 | 93 | /** 94 | * Add a fact definition to the engine. Facts are called by rules as they are evaluated. 95 | * @param {object|Fact} id - fact identifier or instance of Fact 96 | * @param {function} definitionFunc - function to be called when computing the fact value for a given rule 97 | * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance 98 | */ 99 | addFact (id, valueOrMethod, options) { 100 | let factId = id 101 | let fact 102 | if (id instanceof Fact) { 103 | factId = id.id 104 | fact = id 105 | } else { 106 | fact = new Fact(id, valueOrMethod, options) 107 | } 108 | debug('almanac::addFact', { id: factId }) 109 | this.factMap.set(factId, fact) 110 | if (fact.isConstant()) { 111 | this._setFactValue(fact, {}, fact.value) 112 | } 113 | return this 114 | } 115 | 116 | /** 117 | * Adds a constant fact during runtime. Can be used mid-run() to add additional information 118 | * @deprecated use addFact 119 | * @param {String} fact - fact identifier 120 | * @param {Mixed} value - constant value of the fact 121 | */ 122 | addRuntimeFact (factId, value) { 123 | debug('almanac::addRuntimeFact', { id: factId }) 124 | const fact = new Fact(factId, value) 125 | return this._addConstantFact(fact) 126 | } 127 | 128 | /** 129 | * Returns the value of a fact, based on the given parameters. Utilizes the 'almanac' maintained 130 | * by the engine, which cache's fact computations based on parameters provided 131 | * @param {string} factId - fact identifier 132 | * @param {Object} params - parameters to feed into the fact. By default, these will also be used to compute the cache key 133 | * @param {String} path - object 134 | * @return {Promise} a promise which will resolve with the fact computation. 135 | */ 136 | factValue (factId, params = {}, path = '') { 137 | let factValuePromise 138 | const fact = this._getFact(factId) 139 | if (fact === undefined) { 140 | if (this.allowUndefinedFacts) { 141 | return Promise.resolve(undefined) 142 | } else { 143 | return Promise.reject(new UndefinedFactError(`Undefined fact: ${factId}`)) 144 | } 145 | } 146 | if (fact.isConstant()) { 147 | factValuePromise = Promise.resolve(fact.calculate(params, this)) 148 | } else { 149 | const cacheKey = fact.getCacheKey(params) 150 | const cacheVal = cacheKey && this.factResultsCache.get(cacheKey) 151 | if (cacheVal) { 152 | factValuePromise = Promise.resolve(cacheVal) 153 | debug('almanac::factValue cache hit for fact', { id: factId }) 154 | } else { 155 | debug('almanac::factValue cache miss, calculating', { id: factId }) 156 | factValuePromise = this._setFactValue(fact, params, fact.calculate(params, this)) 157 | } 158 | } 159 | if (path) { 160 | debug('condition::evaluate extracting object', { property: path }) 161 | return factValuePromise 162 | .then(factValue => { 163 | if (factValue != null && typeof factValue === 'object') { 164 | const pathValue = this.pathResolver(factValue, path) 165 | debug('condition::evaluate extracting object', { property: path, received: pathValue }) 166 | return pathValue 167 | } else { 168 | debug('condition::evaluate could not compute object path of non-object', { path, factValue, type: typeof factValue }) 169 | return factValue 170 | } 171 | }) 172 | } 173 | 174 | return factValuePromise 175 | } 176 | 177 | /** 178 | * Interprets value as either a primitive, or if a fact, retrieves the fact value 179 | */ 180 | getValue (value) { 181 | if (value != null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' } 182 | return this.factValue(value.fact, value.params, value.path) 183 | } 184 | return Promise.resolve(value) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/condition.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import debug from './debug' 4 | 5 | export default class Condition { 6 | constructor (properties) { 7 | if (!properties) throw new Error('Condition: constructor options required') 8 | const booleanOperator = Condition.booleanOperator(properties) 9 | Object.assign(this, properties) 10 | if (booleanOperator) { 11 | const subConditions = properties[booleanOperator] 12 | const subConditionsIsArray = Array.isArray(subConditions) 13 | if (booleanOperator !== 'not' && !subConditionsIsArray) { throw new Error(`"${booleanOperator}" must be an array`) } 14 | if (booleanOperator === 'not' && subConditionsIsArray) { throw new Error(`"${booleanOperator}" cannot be an array`) } 15 | this.operator = booleanOperator 16 | // boolean conditions always have a priority; default 1 17 | this.priority = parseInt(properties.priority, 10) || 1 18 | if (subConditionsIsArray) { 19 | this[booleanOperator] = subConditions.map((c) => new Condition(c)) 20 | } else { 21 | this[booleanOperator] = new Condition(subConditions) 22 | } 23 | } else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) { 24 | if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') } 25 | if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') } 26 | if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') } 27 | 28 | // a non-boolean condition does not have a priority by default. this allows 29 | // priority to be dictated by the fact definition 30 | if (Object.prototype.hasOwnProperty.call(properties, 'priority')) { 31 | properties.priority = parseInt(properties.priority, 10) 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * Converts the condition into a json-friendly structure 38 | * @param {Boolean} stringify - whether to return as a json string 39 | * @returns {string,object} json string or json-friendly object 40 | */ 41 | toJSON (stringify = true) { 42 | const props = {} 43 | if (this.priority) { 44 | props.priority = this.priority 45 | } 46 | if (this.name) { 47 | props.name = this.name 48 | } 49 | const oper = Condition.booleanOperator(this) 50 | if (oper) { 51 | if (Array.isArray(this[oper])) { 52 | props[oper] = this[oper].map((c) => c.toJSON(false)) 53 | } else { 54 | props[oper] = this[oper].toJSON(false) 55 | } 56 | } else if (this.isConditionReference()) { 57 | props.condition = this.condition 58 | } else { 59 | props.operator = this.operator 60 | props.value = this.value 61 | props.fact = this.fact 62 | if (this.factResult !== undefined) { 63 | props.factResult = this.factResult 64 | } 65 | if (this.valueResult !== undefined) { 66 | props.valueResult = this.valueResult 67 | } 68 | if (this.result !== undefined) { 69 | props.result = this.result 70 | } 71 | if (this.params) { 72 | props.params = this.params 73 | } 74 | if (this.path) { 75 | props.path = this.path 76 | } 77 | } 78 | if (stringify) { 79 | return JSON.stringify(props) 80 | } 81 | return props 82 | } 83 | 84 | /** 85 | * Takes the fact result and compares it to the condition 'value', using the operator 86 | * LHS OPER RHS 87 | * 88 | * 89 | * @param {Almanac} almanac 90 | * @param {Map} operatorMap - map of available operators, keyed by operator name 91 | * @returns {Boolean} - evaluation result 92 | */ 93 | evaluate (almanac, operatorMap) { 94 | if (!almanac) return Promise.reject(new Error('almanac required')) 95 | if (!operatorMap) return Promise.reject(new Error('operatorMap required')) 96 | if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) } 97 | 98 | const op = operatorMap.get(this.operator) 99 | if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) } 100 | 101 | return Promise.all([ 102 | almanac.getValue(this.value), 103 | almanac.factValue(this.fact, this.params, this.path) 104 | ]).then(([rightHandSideValue, leftHandSideValue]) => { 105 | const result = op.evaluate(leftHandSideValue, rightHandSideValue) 106 | debug( 107 | 'condition::evaluate', { 108 | leftHandSideValue, 109 | operator: this.operator, 110 | rightHandSideValue, 111 | result 112 | } 113 | ) 114 | return { 115 | result, 116 | leftHandSideValue, 117 | rightHandSideValue, 118 | operator: this.operator 119 | } 120 | }) 121 | } 122 | 123 | /** 124 | * Returns the boolean operator for the condition 125 | * If the condition is not a boolean condition, the result will be 'undefined' 126 | * @return {string 'all', 'any', or 'not'} 127 | */ 128 | static booleanOperator (condition) { 129 | if (Object.prototype.hasOwnProperty.call(condition, 'any')) { 130 | return 'any' 131 | } else if (Object.prototype.hasOwnProperty.call(condition, 'all')) { 132 | return 'all' 133 | } else if (Object.prototype.hasOwnProperty.call(condition, 'not')) { 134 | return 'not' 135 | } 136 | } 137 | 138 | /** 139 | * Returns the condition's boolean operator 140 | * Instance version of Condition.isBooleanOperator 141 | * @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition) 142 | */ 143 | booleanOperator () { 144 | return Condition.booleanOperator(this) 145 | } 146 | 147 | /** 148 | * Whether the operator is boolean ('all', 'any', 'not') 149 | * @returns {Boolean} 150 | */ 151 | isBooleanOperator () { 152 | return Condition.booleanOperator(this) !== undefined 153 | } 154 | 155 | /** 156 | * Whether the condition represents a reference to a condition 157 | * @returns {Boolean} 158 | */ 159 | isConditionReference () { 160 | return Object.prototype.hasOwnProperty.call(this, 'condition') 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/debug.js: -------------------------------------------------------------------------------- 1 | 2 | function createDebug () { 3 | try { 4 | if ((typeof process !== 'undefined' && process.env && process.env.DEBUG && process.env.DEBUG.match(/json-rules-engine/)) || 5 | (typeof window !== 'undefined' && window.localStorage && window.localStorage.debug && window.localStorage.debug.match(/json-rules-engine/))) { 6 | return console.debug.bind(console) 7 | } 8 | } catch (ex) { 9 | // Do nothing 10 | } 11 | return () => {} 12 | } 13 | 14 | export default createDebug() 15 | -------------------------------------------------------------------------------- /src/engine-default-operator-decorators.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import OperatorDecorator from './operator-decorator' 4 | 5 | const OperatorDecorators = [] 6 | 7 | OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray)) 8 | OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv)))) 9 | OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray)) 10 | OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv)))) 11 | OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue))) 12 | OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue))) 13 | 14 | export default OperatorDecorators 15 | -------------------------------------------------------------------------------- /src/engine-default-operators.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Operator from './operator' 4 | 5 | const Operators = [] 6 | Operators.push(new Operator('equal', (a, b) => a === b)) 7 | Operators.push(new Operator('notEqual', (a, b) => a !== b)) 8 | Operators.push(new Operator('in', (a, b) => b.indexOf(a) > -1)) 9 | Operators.push(new Operator('notIn', (a, b) => b.indexOf(a) === -1)) 10 | 11 | Operators.push(new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray)) 12 | Operators.push(new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray)) 13 | 14 | function numberValidator (factValue) { 15 | return Number.parseFloat(factValue).toString() !== 'NaN' 16 | } 17 | Operators.push(new Operator('lessThan', (a, b) => a < b, numberValidator)) 18 | Operators.push(new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator)) 19 | Operators.push(new Operator('greaterThan', (a, b) => a > b, numberValidator)) 20 | Operators.push(new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator)) 21 | 22 | export default Operators 23 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export class UndefinedFactError extends Error { 4 | constructor (...props) { 5 | super(...props) 6 | this.code = 'UNDEFINED_FACT' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/fact.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import hash from 'hash-it' 4 | 5 | class Fact { 6 | /** 7 | * Returns a new fact instance 8 | * @param {string} id - fact unique identifer 9 | * @param {object} options 10 | * @param {boolean} options.cache - whether to cache the fact's value for future rules 11 | * @param {primitive|function} valueOrMethod - constant primitive, or method to call when computing the fact's value 12 | * @return {Fact} 13 | */ 14 | constructor (id, valueOrMethod, options) { 15 | this.id = id 16 | const defaultOptions = { cache: true } 17 | if (typeof options === 'undefined') { 18 | options = defaultOptions 19 | } 20 | if (typeof valueOrMethod !== 'function') { 21 | this.value = valueOrMethod 22 | this.type = this.constructor.CONSTANT 23 | } else { 24 | this.calculationMethod = valueOrMethod 25 | this.type = this.constructor.DYNAMIC 26 | } 27 | 28 | if (!this.id) throw new Error('factId required') 29 | 30 | this.priority = parseInt(options.priority || 1, 10) 31 | this.options = Object.assign({}, defaultOptions, options) 32 | this.cacheKeyMethod = this.defaultCacheKeys 33 | return this 34 | } 35 | 36 | isConstant () { 37 | return this.type === this.constructor.CONSTANT 38 | } 39 | 40 | isDynamic () { 41 | return this.type === this.constructor.DYNAMIC 42 | } 43 | 44 | /** 45 | * Return the fact value, based on provided parameters 46 | * @param {object} params 47 | * @param {Almanac} almanac 48 | * @return {any} calculation method results 49 | */ 50 | calculate (params, almanac) { 51 | // if constant fact w/set value, return immediately 52 | if (Object.prototype.hasOwnProperty.call(this, 'value')) { 53 | return this.value 54 | } 55 | return this.calculationMethod(params, almanac) 56 | } 57 | 58 | /** 59 | * Return a cache key (MD5 string) based on parameters 60 | * @param {object} obj - properties to generate a hash key from 61 | * @return {string} MD5 string based on the hash'd object 62 | */ 63 | static hashFromObject (obj) { 64 | return hash(obj) 65 | } 66 | 67 | /** 68 | * Default properties to use when caching a fact 69 | * Assumes every fact is a pure function, whose computed value will only 70 | * change when input params are modified 71 | * @param {string} id - fact unique identifer 72 | * @param {object} params - parameters passed to fact calcution method 73 | * @return {object} id + params 74 | */ 75 | defaultCacheKeys (id, params) { 76 | return { params, id } 77 | } 78 | 79 | /** 80 | * Generates the fact's cache key(MD5 string) 81 | * Returns nothing if the fact's caching has been disabled 82 | * @param {object} params - parameters that would be passed to the computation method 83 | * @return {string} cache key 84 | */ 85 | getCacheKey (params) { 86 | if (this.options.cache === true) { 87 | const cacheProperties = this.cacheKeyMethod(this.id, params) 88 | const hash = Fact.hashFromObject(cacheProperties) 89 | return hash 90 | } 91 | } 92 | } 93 | 94 | Fact.CONSTANT = 'CONSTANT' 95 | Fact.DYNAMIC = 'DYNAMIC' 96 | 97 | export default Fact 98 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('./json-rules-engine') 4 | -------------------------------------------------------------------------------- /src/json-rules-engine.js: -------------------------------------------------------------------------------- 1 | import Engine from './engine' 2 | import Fact from './fact' 3 | import Rule from './rule' 4 | import Operator from './operator' 5 | import Almanac from './almanac' 6 | import OperatorDecorator from './operator-decorator' 7 | 8 | export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator } 9 | export default function (rules, options) { 10 | return new Engine(rules, options) 11 | } 12 | -------------------------------------------------------------------------------- /src/operator-decorator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Operator from './operator' 4 | 5 | export default class OperatorDecorator { 6 | /** 7 | * Constructor 8 | * @param {string} name - decorator identifier 9 | * @param {function(factValue, jsonValue, next)} callback - callback that takes the next operator as a parameter 10 | * @param {function} [factValueValidator] - optional validator for asserting the data type of the fact 11 | * @returns {OperatorDecorator} - instance 12 | */ 13 | constructor (name, cb, factValueValidator) { 14 | this.name = String(name) 15 | if (!name) throw new Error('Missing decorator name') 16 | if (typeof cb !== 'function') throw new Error('Missing decorator callback') 17 | this.cb = cb 18 | this.factValueValidator = factValueValidator 19 | if (!this.factValueValidator) this.factValueValidator = () => true 20 | } 21 | 22 | /** 23 | * Takes the fact result and compares it to the condition 'value', using the callback 24 | * @param {Operator} operator - fact result 25 | * @returns {Operator} - whether the values pass the operator test 26 | */ 27 | decorate (operator) { 28 | const next = operator.evaluate.bind(operator) 29 | return new Operator( 30 | `${this.name}:${operator.name}`, 31 | (factValue, jsonValue) => { 32 | return this.cb(factValue, jsonValue, next) 33 | }, 34 | this.factValueValidator 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/operator-map.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Operator from './operator' 4 | import OperatorDecorator from './operator-decorator' 5 | import debug from './debug' 6 | 7 | export default class OperatorMap { 8 | constructor () { 9 | this.operators = new Map() 10 | this.decorators = new Map() 11 | } 12 | 13 | /** 14 | * Add a custom operator definition 15 | * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc 16 | * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. 17 | */ 18 | addOperator (operatorOrName, cb) { 19 | let operator 20 | if (operatorOrName instanceof Operator) { 21 | operator = operatorOrName 22 | } else { 23 | operator = new Operator(operatorOrName, cb) 24 | } 25 | debug('operatorMap::addOperator', { name: operator.name }) 26 | this.operators.set(operator.name, operator) 27 | } 28 | 29 | /** 30 | * Remove a custom operator definition 31 | * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc 32 | * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. 33 | */ 34 | removeOperator (operatorOrName) { 35 | let operatorName 36 | if (operatorOrName instanceof Operator) { 37 | operatorName = operatorOrName.name 38 | } else { 39 | operatorName = operatorOrName 40 | } 41 | 42 | // Delete all the operators that end in :operatorName these 43 | // were decorated on-the-fly leveraging this operator 44 | const suffix = ':' + operatorName 45 | const operatorNames = Array.from(this.operators.keys()) 46 | for (let i = 0; i < operatorNames.length; i++) { 47 | if (operatorNames[i].endsWith(suffix)) { 48 | this.operators.delete(operatorNames[i]) 49 | } 50 | } 51 | 52 | return this.operators.delete(operatorName) 53 | } 54 | 55 | /** 56 | * Add a custom operator decorator 57 | * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc 58 | * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered. 59 | */ 60 | addOperatorDecorator (decoratorOrName, cb) { 61 | let decorator 62 | if (decoratorOrName instanceof OperatorDecorator) { 63 | decorator = decoratorOrName 64 | } else { 65 | decorator = new OperatorDecorator(decoratorOrName, cb) 66 | } 67 | debug('operatorMap::addOperatorDecorator', { name: decorator.name }) 68 | this.decorators.set(decorator.name, decorator) 69 | } 70 | 71 | /** 72 | * Remove a custom operator decorator 73 | * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc 74 | */ 75 | removeOperatorDecorator (decoratorOrName) { 76 | let decoratorName 77 | if (decoratorOrName instanceof OperatorDecorator) { 78 | decoratorName = decoratorOrName.name 79 | } else { 80 | decoratorName = decoratorOrName 81 | } 82 | 83 | // Delete all the operators that include decoratorName: these 84 | // were decorated on-the-fly leveraging this decorator 85 | const prefix = decoratorName + ':' 86 | const operatorNames = Array.from(this.operators.keys()) 87 | for (let i = 0; i < operatorNames.length; i++) { 88 | if (operatorNames[i].includes(prefix)) { 89 | this.operators.delete(operatorNames[i]) 90 | } 91 | } 92 | 93 | return this.decorators.delete(decoratorName) 94 | } 95 | 96 | /** 97 | * Get the Operator, or null applies decorators as needed 98 | * @param {string} name - the name of the operator including any decorators 99 | * @returns an operator or null 100 | */ 101 | get (name) { 102 | const decorators = [] 103 | let opName = name 104 | // while we don't already have this operator 105 | while (!this.operators.has(opName)) { 106 | // try splitting on the decorator symbol (:) 107 | const firstDecoratorIndex = opName.indexOf(':') 108 | if (firstDecoratorIndex > 0) { 109 | // if there is a decorator, and it's a valid decorator 110 | const decoratorName = opName.slice(0, firstDecoratorIndex) 111 | const decorator = this.decorators.get(decoratorName) 112 | if (!decorator) { 113 | debug('operatorMap::get invalid decorator', { name: decoratorName }) 114 | return null 115 | } 116 | // we're going to apply this later, use unshift since we'll apply in reverse order 117 | decorators.unshift(decorator) 118 | // continue looking for a known operator with the rest of the name 119 | opName = opName.slice(firstDecoratorIndex + 1) 120 | } else { 121 | debug('operatorMap::get no operator', { name: opName }) 122 | return null 123 | } 124 | } 125 | 126 | let op = this.operators.get(opName) 127 | // apply all the decorators 128 | for (let i = 0; i < decorators.length; i++) { 129 | op = decorators[i].decorate(op) 130 | // create an entry for the decorated operation so we don't need 131 | // to do this again 132 | this.operators.set(op.name, op) 133 | } 134 | // return the operation 135 | return op 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/operator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export default class Operator { 4 | /** 5 | * Constructor 6 | * @param {string} name - operator identifier 7 | * @param {function(factValue, jsonValue)} callback - operator evaluation method 8 | * @param {function} [factValueValidator] - optional validator for asserting the data type of the fact 9 | * @returns {Operator} - instance 10 | */ 11 | constructor (name, cb, factValueValidator) { 12 | this.name = String(name) 13 | if (!name) throw new Error('Missing operator name') 14 | if (typeof cb !== 'function') throw new Error('Missing operator callback') 15 | this.cb = cb 16 | this.factValueValidator = factValueValidator 17 | if (!this.factValueValidator) this.factValueValidator = () => true 18 | } 19 | 20 | /** 21 | * Takes the fact result and compares it to the condition 'value', using the callback 22 | * @param {mixed} factValue - fact result 23 | * @param {mixed} jsonValue - "value" property of the condition 24 | * @returns {Boolean} - whether the values pass the operator test 25 | */ 26 | evaluate (factValue, jsonValue) { 27 | return this.factValueValidator(factValue) && this.cb(factValue, jsonValue) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/rule-result.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import deepClone from 'clone' 4 | 5 | export default class RuleResult { 6 | constructor (conditions, event, priority, name) { 7 | this.conditions = deepClone(conditions) 8 | this.event = deepClone(event) 9 | this.priority = deepClone(priority) 10 | this.name = deepClone(name) 11 | this.result = null 12 | } 13 | 14 | setResult (result) { 15 | this.result = result 16 | } 17 | 18 | resolveEventParams (almanac) { 19 | if (this.event.params !== null && typeof this.event.params === 'object') { 20 | const updates = [] 21 | for (const key in this.event.params) { 22 | if (Object.prototype.hasOwnProperty.call(this.event.params, key)) { 23 | updates.push( 24 | almanac 25 | .getValue(this.event.params[key]) 26 | .then((val) => (this.event.params[key] = val)) 27 | ) 28 | } 29 | } 30 | return Promise.all(updates) 31 | } 32 | return Promise.resolve() 33 | } 34 | 35 | toJSON (stringify = true) { 36 | const props = { 37 | conditions: this.conditions.toJSON(false), 38 | event: this.event, 39 | priority: this.priority, 40 | name: this.name, 41 | result: this.result 42 | } 43 | if (stringify) { 44 | return JSON.stringify(props) 45 | } 46 | return props 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/acceptance/acceptance.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import { expect } from 'chai' 5 | import { Engine } from '../../src/index' 6 | 7 | /** 8 | * acceptance tests are intended to use features that, when used in combination, 9 | * could cause integration bugs not caught by the rest of the test suite 10 | */ 11 | describe('Acceptance', () => { 12 | let sandbox 13 | before(() => { 14 | sandbox = sinon.createSandbox() 15 | }) 16 | afterEach(() => { 17 | sandbox.restore() 18 | }) 19 | const factParam = 1 20 | const event1 = { 21 | type: 'event-1', 22 | params: { 23 | eventParam: 1 24 | } 25 | } 26 | const event2 = { 27 | type: 'event-2' 28 | } 29 | const expectedFirstRuleResult = { 30 | all: [{ 31 | fact: 'high-priority', 32 | params: { 33 | factParam 34 | }, 35 | operator: 'contains', 36 | path: '$.values', 37 | value: 2, 38 | factResult: [2], 39 | valueResult: 2, 40 | result: true 41 | }, 42 | { 43 | fact: 'low-priority', 44 | operator: 'in', 45 | value: [2], 46 | factResult: 2, 47 | valueResult: [2], 48 | result: true 49 | } 50 | ], 51 | operator: 'all', 52 | priority: 1 53 | } 54 | let successSpy 55 | let failureSpy 56 | let highPrioritySpy 57 | let lowPrioritySpy 58 | 59 | function delay (value) { 60 | return new Promise(resolve => setTimeout(() => resolve(value), 5)) 61 | } 62 | 63 | function setup (options = {}) { 64 | const engine = new Engine() 65 | highPrioritySpy = sandbox.spy() 66 | lowPrioritySpy = sandbox.spy() 67 | 68 | engine.addRule({ 69 | name: 'first', 70 | priority: 10, 71 | conditions: { 72 | all: [{ 73 | fact: 'high-priority', 74 | params: { 75 | factParam 76 | }, 77 | operator: 'contains', 78 | path: '$.values', 79 | value: options.highPriorityValue 80 | }, { 81 | fact: 'low-priority', 82 | operator: 'in', 83 | value: options.lowPriorityValue 84 | }] 85 | }, 86 | event: event1, 87 | onSuccess: async (event, almanac, ruleResults) => { 88 | expect(ruleResults.name).to.equal('first') 89 | expect(ruleResults.event).to.deep.equal(event1) 90 | expect(ruleResults.priority).to.equal(10) 91 | expect(ruleResults.conditions).to.deep.equal(expectedFirstRuleResult) 92 | 93 | return delay(almanac.addRuntimeFact('rule-created-fact', { array: options.highPriorityValue })) 94 | } 95 | }) 96 | 97 | engine.addRule({ 98 | name: 'second', 99 | priority: 1, 100 | conditions: { 101 | all: [{ 102 | fact: 'high-priority', 103 | params: { 104 | factParam 105 | }, 106 | operator: 'containsDivisibleValuesOf', 107 | path: '$.values', 108 | value: { 109 | fact: 'rule-created-fact', 110 | path: '$.array' // set by 'success' of first rule 111 | } 112 | }] 113 | }, 114 | event: event2 115 | }) 116 | 117 | engine.addOperator('containsDivisibleValuesOf', (factValue, jsonValue) => { 118 | return factValue.some(v => v % jsonValue === 0) 119 | }) 120 | 121 | engine.addFact('high-priority', async function (params, almanac) { 122 | highPrioritySpy(params) 123 | const idx = await almanac.factValue('sub-fact') 124 | return delay({ values: [idx + params.factParam] }) // { values: [baseIndex + factParam] } 125 | }, { priority: 2 }) 126 | 127 | engine.addFact('low-priority', async function (params, almanac) { 128 | lowPrioritySpy(params) 129 | const idx = await almanac.factValue('sub-fact') 130 | return delay(idx + 1) // baseIndex + 1 131 | }, { priority: 1 }) 132 | 133 | engine.addFact('sub-fact', async function (params, almanac) { 134 | const baseIndex = await almanac.factValue('baseIndex') 135 | return delay(baseIndex) 136 | }) 137 | successSpy = sandbox.spy() 138 | failureSpy = sandbox.spy() 139 | engine.on('success', successSpy) 140 | engine.on('failure', failureSpy) 141 | 142 | return engine 143 | } 144 | 145 | it('succeeds', async () => { 146 | const engine = setup({ 147 | highPriorityValue: 2, 148 | lowPriorityValue: [2] 149 | }) 150 | 151 | const { 152 | results, 153 | failureResults, 154 | events, 155 | failureEvents 156 | } = await engine.run({ baseIndex: 1 }) 157 | 158 | // results 159 | expect(results.length).to.equal(2) 160 | expect(results[0]).to.deep.equal({ 161 | conditions: { 162 | all: [ 163 | { 164 | fact: 'high-priority', 165 | factResult: [ 166 | 2 167 | ], 168 | operator: 'contains', 169 | params: { 170 | factParam: 1 171 | }, 172 | path: '$.values', 173 | result: true, 174 | value: 2, 175 | valueResult: 2 176 | }, 177 | { 178 | fact: 'low-priority', 179 | factResult: 2, 180 | operator: 'in', 181 | result: true, 182 | value: [ 183 | 2 184 | ], 185 | valueResult: [ 186 | 2 187 | ] 188 | } 189 | ], 190 | operator: 'all', 191 | priority: 1 192 | }, 193 | event: { 194 | params: { 195 | eventParam: 1 196 | }, 197 | type: 'event-1' 198 | }, 199 | name: 'first', 200 | priority: 10, 201 | result: true 202 | }) 203 | expect(results[1]).to.deep.equal({ 204 | conditions: { 205 | all: [ 206 | { 207 | fact: 'high-priority', 208 | factResult: [ 209 | 2 210 | ], 211 | valueResult: 2, 212 | operator: 'containsDivisibleValuesOf', 213 | params: { 214 | factParam: 1 215 | }, 216 | path: '$.values', 217 | result: true, 218 | value: { 219 | fact: 'rule-created-fact', 220 | path: '$.array' 221 | } 222 | } 223 | ], 224 | operator: 'all', 225 | priority: 1 226 | }, 227 | event: { 228 | type: 'event-2' 229 | }, 230 | name: 'second', 231 | priority: 1, 232 | result: true 233 | }) 234 | expect(failureResults).to.be.empty() 235 | 236 | // events 237 | expect(failureEvents.length).to.equal(0) 238 | expect(events.length).to.equal(2) 239 | expect(events[0]).to.deep.equal(event1) 240 | expect(events[1]).to.deep.equal(event2) 241 | 242 | // callbacks 243 | expect(successSpy).to.have.been.calledTwice() 244 | expect(successSpy).to.have.been.calledWith(event1) 245 | expect(successSpy).to.have.been.calledWith(event2) 246 | expect(highPrioritySpy).to.have.been.calledBefore(lowPrioritySpy) 247 | expect(failureSpy).to.not.have.been.called() 248 | }) 249 | 250 | it('fails', async () => { 251 | const engine = setup({ 252 | highPriorityValue: 2, 253 | lowPriorityValue: [3] // falsey 254 | }) 255 | 256 | const { 257 | results, 258 | failureResults, 259 | events, 260 | failureEvents 261 | } = await engine.run({ baseIndex: 1, 'rule-created-fact': '' }) 262 | 263 | expect(results.length).to.equal(0) 264 | expect(failureResults.length).to.equal(2) 265 | expect(failureResults.every(rr => rr.result === false)).to.be.true() 266 | 267 | expect(events.length).to.equal(0) 268 | expect(failureEvents.length).to.equal(2) 269 | expect(failureSpy).to.have.been.calledTwice() 270 | expect(failureSpy).to.have.been.calledWith(event1) 271 | expect(failureSpy).to.have.been.calledWith(event2) 272 | expect(highPrioritySpy).to.have.been.calledBefore(lowPrioritySpy) 273 | expect(successSpy).to.not.have.been.called() 274 | }) 275 | }) 276 | -------------------------------------------------------------------------------- /test/almanac.test.js: -------------------------------------------------------------------------------- 1 | import { Fact } from '../src/index' 2 | import Almanac from '../src/almanac' 3 | import sinon from 'sinon' 4 | 5 | describe('Almanac', () => { 6 | let almanac 7 | let factSpy 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | beforeEach(() => { 13 | factSpy = sandbox.spy() 14 | }) 15 | afterEach(() => { 16 | sandbox.restore() 17 | }) 18 | 19 | describe('properties', () => { 20 | it('has methods for managing facts', () => { 21 | almanac = new Almanac() 22 | expect(almanac).to.have.property('factValue') 23 | }) 24 | 25 | it('adds runtime facts', () => { 26 | almanac = new Almanac() 27 | almanac.addFact('modelId', 'XYZ') 28 | expect(almanac.factMap.get('modelId').value).to.equal('XYZ') 29 | }) 30 | }) 31 | 32 | describe('addFact', () => { 33 | it('supports runtime facts as key => values', () => { 34 | almanac = new Almanac() 35 | almanac.addFact('fact1', 3) 36 | return expect(almanac.factValue('fact1')).to.eventually.equal(3) 37 | }) 38 | 39 | it('supporrts runtime facts as dynamic callbacks', async () => { 40 | almanac = new Almanac() 41 | almanac.addFact('fact1', () => { 42 | factSpy() 43 | return Promise.resolve(3) 44 | }) 45 | await expect(almanac.factValue('fact1')).to.eventually.equal(3) 46 | await expect(factSpy).to.have.been.calledOnce() 47 | }) 48 | 49 | it('supports runtime fact instances', () => { 50 | const fact = new Fact('fact1', 3) 51 | almanac = new Almanac() 52 | almanac.addFact(fact) 53 | return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value) 54 | }) 55 | }) 56 | 57 | describe('addEvent() / getEvents()', () => { 58 | const event = {}; 59 | ['success', 'failure'].forEach(outcome => { 60 | it(`manages ${outcome} events`, () => { 61 | almanac = new Almanac() 62 | expect(almanac.getEvents(outcome)).to.be.empty() 63 | almanac.addEvent(event, outcome) 64 | expect(almanac.getEvents(outcome)).to.have.a.lengthOf(1) 65 | expect(almanac.getEvents(outcome)[0]).to.equal(event) 66 | }) 67 | 68 | it('getEvent() filters when outcome provided, or returns all events', () => { 69 | almanac = new Almanac() 70 | almanac.addEvent(event, 'success') 71 | almanac.addEvent(event, 'failure') 72 | expect(almanac.getEvents('success')).to.have.a.lengthOf(1) 73 | expect(almanac.getEvents('failure')).to.have.a.lengthOf(1) 74 | expect(almanac.getEvents()).to.have.a.lengthOf(2) 75 | }) 76 | }) 77 | }) 78 | 79 | describe('arguments', () => { 80 | beforeEach(() => { 81 | const fact = new Fact('foo', async (params, facts) => { 82 | if (params.userId) return params.userId 83 | return 'unknown' 84 | }) 85 | almanac = new Almanac() 86 | almanac.addFact(fact) 87 | }) 88 | 89 | it('allows parameters to be passed to the fact', async () => { 90 | return expect(almanac.factValue('foo')).to.eventually.equal('unknown') 91 | }) 92 | 93 | it('allows parameters to be passed to the fact', async () => { 94 | return expect(almanac.factValue('foo', { userId: 1 })).to.eventually.equal(1) 95 | }) 96 | 97 | it('throws an exception if it encounters an undefined fact', () => { 98 | return expect(almanac.factValue('bar')).to.be.rejectedWith(/Undefined fact: bar/) 99 | }) 100 | }) 101 | 102 | describe('addRuntimeFact', () => { 103 | it('adds a key/value pair to the factMap as a fact instance', () => { 104 | almanac = new Almanac() 105 | almanac.addRuntimeFact('factId', 'factValue') 106 | expect(almanac.factMap.get('factId').value).to.equal('factValue') 107 | }) 108 | }) 109 | 110 | describe('_addConstantFact', () => { 111 | it('adds fact instances to the factMap', () => { 112 | const fact = new Fact('factId', 'factValue') 113 | almanac = new Almanac() 114 | almanac._addConstantFact(fact) 115 | expect(almanac.factMap.get(fact.id).value).to.equal(fact.value) 116 | }) 117 | }) 118 | 119 | describe('_getFact', _ => { 120 | it('retrieves the fact object', () => { 121 | const fact = new Fact('id', 1) 122 | almanac = new Almanac() 123 | almanac.addFact(fact) 124 | expect(almanac._getFact('id')).to.equal(fact) 125 | }) 126 | }) 127 | 128 | describe('_setFactValue()', () => { 129 | function expectFactResultsCache (expected) { 130 | const promise = almanac.factResultsCache.values().next().value 131 | expect(promise).to.be.instanceof(Promise) 132 | promise.then(value => expect(value).to.equal(expected)) 133 | return promise 134 | } 135 | 136 | function setup (f = new Fact('id', 1)) { 137 | fact = f 138 | almanac = new Almanac() 139 | almanac.addFact(fact) 140 | } 141 | let fact 142 | const FACT_VALUE = 2 143 | 144 | it('updates the fact results and returns a promise', (done) => { 145 | setup() 146 | almanac._setFactValue(fact, {}, FACT_VALUE) 147 | expectFactResultsCache(FACT_VALUE).then(_ => done()).catch(done) 148 | }) 149 | 150 | it('honors facts with caching disabled', (done) => { 151 | setup(new Fact('id', 1, { cache: false })) 152 | const promise = almanac._setFactValue(fact, {}, FACT_VALUE) 153 | expect(almanac.factResultsCache.values().next().value).to.be.undefined() 154 | promise.then(value => expect(value).to.equal(FACT_VALUE)).then(_ => done()).catch(done) 155 | }) 156 | }) 157 | 158 | describe('factValue()', () => { 159 | it('allows "path" to be specified to traverse the fact data with json-path', async () => { 160 | const fact = new Fact('foo', { 161 | users: [{ 162 | name: 'George' 163 | }, { 164 | name: 'Thomas' 165 | }] 166 | }) 167 | almanac = new Almanac() 168 | almanac.addFact(fact) 169 | const result = await almanac.factValue('foo', null, '$..name') 170 | expect(result).to.deep.equal(['George', 'Thomas']) 171 | }) 172 | 173 | describe('caching', () => { 174 | function setup (factOptions) { 175 | const fact = new Fact('foo', async (params, facts) => { 176 | factSpy() 177 | return 'unknown' 178 | }, factOptions) 179 | almanac = new Almanac() 180 | almanac.addFact(fact) 181 | } 182 | 183 | it('evaluates the fact every time when fact caching is off', () => { 184 | setup({ cache: false }) 185 | almanac.factValue('foo') 186 | almanac.factValue('foo') 187 | almanac.factValue('foo') 188 | expect(factSpy).to.have.been.calledThrice() 189 | }) 190 | 191 | it('evaluates the fact once when fact caching is on', () => { 192 | setup({ cache: true }) 193 | almanac.factValue('foo') 194 | almanac.factValue('foo') 195 | almanac.factValue('foo') 196 | expect(factSpy).to.have.been.calledOnce() 197 | }) 198 | }) 199 | }) 200 | }) 201 | -------------------------------------------------------------------------------- /test/engine-all.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import engineFactory from '../src/index' 5 | 6 | async function factSenior (params, engine) { 7 | return 65 8 | } 9 | 10 | async function factChild (params, engine) { 11 | return 10 12 | } 13 | 14 | async function factAdult (params, engine) { 15 | return 30 16 | } 17 | 18 | describe('Engine: "all" conditions', () => { 19 | let engine 20 | let sandbox 21 | before(() => { 22 | sandbox = sinon.createSandbox() 23 | }) 24 | afterEach(() => { 25 | sandbox.restore() 26 | }) 27 | 28 | describe('supports a single "all" condition', () => { 29 | const event = { 30 | type: 'ageTrigger', 31 | params: { 32 | demographic: 'under50' 33 | } 34 | } 35 | const conditions = { 36 | all: [{ 37 | fact: 'age', 38 | operator: 'lessThan', 39 | value: 50 40 | }] 41 | } 42 | let eventSpy 43 | beforeEach(() => { 44 | eventSpy = sandbox.spy() 45 | const rule = factories.rule({ conditions, event }) 46 | engine = engineFactory() 47 | engine.addRule(rule) 48 | engine.on('success', eventSpy) 49 | }) 50 | 51 | it('emits when the condition is met', async () => { 52 | engine.addFact('age', factChild) 53 | await engine.run() 54 | expect(eventSpy).to.have.been.calledWith(event) 55 | }) 56 | 57 | it('does not emit when the condition fails', () => { 58 | engine.addFact('age', factSenior) 59 | engine.run() 60 | expect(eventSpy).to.not.have.been.calledWith(event) 61 | }) 62 | }) 63 | 64 | describe('supports "any" with multiple conditions', () => { 65 | const conditions = { 66 | all: [{ 67 | fact: 'age', 68 | operator: 'lessThan', 69 | value: 50 70 | }, { 71 | fact: 'age', 72 | operator: 'greaterThan', 73 | value: 21 74 | }] 75 | } 76 | const event = { 77 | type: 'ageTrigger', 78 | params: { 79 | demographic: 'adult' 80 | } 81 | } 82 | let eventSpy 83 | beforeEach(() => { 84 | eventSpy = sandbox.spy() 85 | const rule = factories.rule({ conditions, event }) 86 | engine = engineFactory() 87 | engine.addRule(rule) 88 | engine.on('success', eventSpy) 89 | }) 90 | 91 | it('emits an event when every condition is met', async () => { 92 | engine.addFact('age', factAdult) 93 | await engine.run() 94 | expect(eventSpy).to.have.been.calledWith(event) 95 | }) 96 | 97 | describe('a condition fails', () => { 98 | it('does not emit when the first condition fails', async () => { 99 | engine.addFact('age', factChild) 100 | await engine.run() 101 | expect(eventSpy).to.not.have.been.calledWith(event) 102 | }) 103 | 104 | it('does not emit when the second condition', async () => { 105 | engine.addFact('age', factSenior) 106 | await engine.run() 107 | expect(eventSpy).to.not.have.been.calledWith(event) 108 | }) 109 | }) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /test/engine-any.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import engineFactory from '../src/index' 5 | 6 | describe('Engine: "any" conditions', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | describe('supports a single "any" condition', () => { 17 | const event = { 18 | type: 'ageTrigger', 19 | params: { 20 | demographic: 'under50' 21 | } 22 | } 23 | const conditions = { 24 | any: [{ 25 | fact: 'age', 26 | operator: 'lessThan', 27 | value: 50 28 | }] 29 | } 30 | let eventSpy 31 | let ageSpy 32 | beforeEach(() => { 33 | eventSpy = sandbox.spy() 34 | ageSpy = sandbox.stub() 35 | const rule = factories.rule({ conditions, event }) 36 | engine = engineFactory() 37 | engine.addRule(rule) 38 | engine.addFact('age', ageSpy) 39 | engine.on('success', eventSpy) 40 | }) 41 | 42 | it('emits when the condition is met', async () => { 43 | ageSpy.returns(10) 44 | await engine.run() 45 | expect(eventSpy).to.have.been.calledWith(event) 46 | }) 47 | 48 | it('does not emit when the condition fails', () => { 49 | ageSpy.returns(75) 50 | engine.run() 51 | expect(eventSpy).to.not.have.been.calledWith(event) 52 | }) 53 | }) 54 | 55 | describe('supports "any" with multiple conditions', () => { 56 | const conditions = { 57 | any: [{ 58 | fact: 'age', 59 | operator: 'lessThan', 60 | value: 50 61 | }, { 62 | fact: 'segment', 63 | operator: 'equal', 64 | value: 'european' 65 | }] 66 | } 67 | const event = { 68 | type: 'ageTrigger', 69 | params: { 70 | demographic: 'under50' 71 | } 72 | } 73 | let eventSpy 74 | let ageSpy 75 | let segmentSpy 76 | beforeEach(() => { 77 | eventSpy = sandbox.spy() 78 | ageSpy = sandbox.stub() 79 | segmentSpy = sandbox.stub() 80 | const rule = factories.rule({ conditions, event }) 81 | engine = engineFactory() 82 | engine.addRule(rule) 83 | engine.addFact('segment', segmentSpy) 84 | engine.addFact('age', ageSpy) 85 | engine.on('success', eventSpy) 86 | }) 87 | 88 | it('emits an event when any condition is met', async () => { 89 | segmentSpy.returns('north-american') 90 | ageSpy.returns(25) 91 | await engine.run() 92 | expect(eventSpy).to.have.been.calledWith(event) 93 | 94 | segmentSpy.returns('european') 95 | ageSpy.returns(100) 96 | await engine.run() 97 | expect(eventSpy).to.have.been.calledWith(event) 98 | }) 99 | 100 | it('does not emit when all conditions fail', async () => { 101 | segmentSpy.returns('north-american') 102 | ageSpy.returns(100) 103 | await engine.run() 104 | expect(eventSpy).to.not.have.been.calledWith(event) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/engine-cache.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import engineFactory from '../src/index' 5 | 6 | describe('Engine: cache', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | const event = { type: 'setDrinkingFlag' } 17 | const collegeSeniorEvent = { type: 'isCollegeSenior' } 18 | const conditions = { 19 | any: [{ 20 | fact: 'age', 21 | operator: 'greaterThanInclusive', 22 | value: 21 23 | }] 24 | } 25 | 26 | let factSpy 27 | let eventSpy 28 | const ageFact = () => { 29 | factSpy() 30 | return 22 31 | } 32 | function setup (factOptions) { 33 | factSpy = sandbox.spy() 34 | eventSpy = sandbox.spy() 35 | engine = engineFactory() 36 | const determineDrinkingAge = factories.rule({ conditions, event, priority: 100 }) 37 | engine.addRule(determineDrinkingAge) 38 | const determineCollegeSenior = factories.rule({ conditions, event: collegeSeniorEvent, priority: 1 }) 39 | engine.addRule(determineCollegeSenior) 40 | const over20 = factories.rule({ conditions, event: collegeSeniorEvent, priority: 50 }) 41 | engine.addRule(over20) 42 | engine.addFact('age', ageFact, factOptions) 43 | engine.on('success', eventSpy) 44 | } 45 | 46 | it('loads facts once and caches the results for future use', async () => { 47 | setup({ cache: true }) 48 | await engine.run() 49 | expect(eventSpy).to.have.been.calledThrice() 50 | expect(factSpy).to.have.been.calledOnce() 51 | }) 52 | 53 | it('allows caching to be turned off', async () => { 54 | setup({ cache: false }) 55 | await engine.run() 56 | expect(eventSpy).to.have.been.calledThrice() 57 | expect(factSpy).to.have.been.calledThrice() 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/engine-condition.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import engineFactory from '../src/index' 5 | 6 | describe('Engine: condition', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | describe('setCondition()', () => { 17 | describe('validations', () => { 18 | beforeEach(() => { 19 | engine = engineFactory() 20 | }) 21 | it('throws an exception for invalid root conditions', () => { 22 | expect(engine.setCondition.bind(engine, 'test', { foo: true })).to.throw( 23 | /"conditions" root must contain a single instance of "all", "any", "not", or "condition"/ 24 | ) 25 | }) 26 | }) 27 | }) 28 | 29 | describe('undefined condition', () => { 30 | const sendEvent = { 31 | type: 'checkSending', 32 | params: { 33 | sendRetirementPayment: true 34 | } 35 | } 36 | 37 | const sendConditions = { 38 | all: [ 39 | { condition: 'over60' }, 40 | { 41 | fact: 'isRetired', 42 | operator: 'equal', 43 | value: true 44 | } 45 | ] 46 | } 47 | 48 | describe('allowUndefinedConditions: true', () => { 49 | let eventSpy 50 | beforeEach(() => { 51 | eventSpy = sandbox.spy() 52 | const sendRule = factories.rule({ 53 | conditions: sendConditions, 54 | event: sendEvent 55 | }) 56 | engine = engineFactory([sendRule], { allowUndefinedConditions: true }) 57 | 58 | engine.addFact('isRetired', true) 59 | engine.on('failure', eventSpy) 60 | }) 61 | 62 | it('evaluates undefined conditions as false', async () => { 63 | await engine.run() 64 | expect(eventSpy).to.have.been.called() 65 | }) 66 | }) 67 | 68 | describe('allowUndefinedConditions: false', () => { 69 | beforeEach(() => { 70 | const sendRule = factories.rule({ 71 | conditions: sendConditions, 72 | event: sendEvent 73 | }) 74 | engine = engineFactory([sendRule], { allowUndefinedConditions: false }) 75 | 76 | engine.addFact('isRetired', true) 77 | }) 78 | 79 | it('throws error during run', async () => { 80 | try { 81 | await engine.run() 82 | } catch (error) { 83 | expect(error.message).to.equal('No condition over60 exists') 84 | } 85 | }) 86 | }) 87 | }) 88 | 89 | describe('supports condition shared across multiple rules', () => { 90 | const name = 'over60' 91 | const condition = { 92 | all: [ 93 | { 94 | fact: 'age', 95 | operator: 'greaterThanInclusive', 96 | value: 60 97 | } 98 | ] 99 | } 100 | 101 | const sendEvent = { 102 | type: 'checkSending', 103 | params: { 104 | sendRetirementPayment: true 105 | } 106 | } 107 | 108 | const sendConditions = { 109 | all: [ 110 | { condition: name }, 111 | { 112 | fact: 'isRetired', 113 | operator: 'equal', 114 | value: true 115 | } 116 | ] 117 | } 118 | 119 | const outreachEvent = { 120 | type: 'triggerOutreach' 121 | } 122 | 123 | const outreachConditions = { 124 | all: [ 125 | { condition: name }, 126 | { 127 | fact: 'requestedOutreach', 128 | operator: 'equal', 129 | value: true 130 | } 131 | ] 132 | } 133 | 134 | let eventSpy 135 | let ageSpy 136 | let isRetiredSpy 137 | let requestedOutreachSpy 138 | beforeEach(() => { 139 | eventSpy = sandbox.spy() 140 | ageSpy = sandbox.stub() 141 | isRetiredSpy = sandbox.stub() 142 | requestedOutreachSpy = sandbox.stub() 143 | engine = engineFactory() 144 | 145 | const sendRule = factories.rule({ 146 | conditions: sendConditions, 147 | event: sendEvent 148 | }) 149 | engine.addRule(sendRule) 150 | 151 | const outreachRule = factories.rule({ 152 | conditions: outreachConditions, 153 | event: outreachEvent 154 | }) 155 | engine.addRule(outreachRule) 156 | 157 | engine.setCondition(name, condition) 158 | 159 | engine.addFact('age', ageSpy) 160 | engine.addFact('isRetired', isRetiredSpy) 161 | engine.addFact('requestedOutreach', requestedOutreachSpy) 162 | engine.on('success', eventSpy) 163 | }) 164 | 165 | it('emits all events when all conditions are met', async () => { 166 | ageSpy.returns(65) 167 | isRetiredSpy.returns(true) 168 | requestedOutreachSpy.returns(true) 169 | await engine.run() 170 | expect(eventSpy) 171 | .to.have.been.calledWith(sendEvent) 172 | .and.to.have.been.calledWith(outreachEvent) 173 | }) 174 | 175 | it('expands condition in rule results', async () => { 176 | ageSpy.returns(65) 177 | isRetiredSpy.returns(true) 178 | requestedOutreachSpy.returns(true) 179 | const { results } = await engine.run() 180 | const nestedCondition = { 181 | 'conditions.all[0].all[0].fact': 'age', 182 | 'conditions.all[0].all[0].operator': 'greaterThanInclusive', 183 | 'conditions.all[0].all[0].value': 60 184 | } 185 | expect(results[0]).to.nested.include(nestedCondition) 186 | expect(results[1]).to.nested.include(nestedCondition) 187 | }) 188 | }) 189 | 190 | describe('nested condition', () => { 191 | const name1 = 'over60' 192 | const condition1 = { 193 | all: [ 194 | { 195 | fact: 'age', 196 | operator: 'greaterThanInclusive', 197 | value: 60 198 | } 199 | ] 200 | } 201 | 202 | const name2 = 'earlyRetirement' 203 | const condition2 = { 204 | all: [ 205 | { not: { condition: name1 } }, 206 | { 207 | fact: 'isRetired', 208 | operator: 'equal', 209 | value: true 210 | } 211 | ] 212 | } 213 | 214 | const outreachEvent = { 215 | type: 'triggerOutreach' 216 | } 217 | 218 | const outreachConditions = { 219 | all: [ 220 | { condition: name2 }, 221 | { 222 | fact: 'requestedOutreach', 223 | operator: 'equal', 224 | value: true 225 | } 226 | ] 227 | } 228 | 229 | let eventSpy 230 | let ageSpy 231 | let isRetiredSpy 232 | let requestedOutreachSpy 233 | beforeEach(() => { 234 | eventSpy = sandbox.spy() 235 | ageSpy = sandbox.stub() 236 | isRetiredSpy = sandbox.stub() 237 | requestedOutreachSpy = sandbox.stub() 238 | engine = engineFactory() 239 | 240 | const outreachRule = factories.rule({ 241 | conditions: outreachConditions, 242 | event: outreachEvent 243 | }) 244 | engine.addRule(outreachRule) 245 | 246 | engine.setCondition(name1, condition1) 247 | 248 | engine.setCondition(name2, condition2) 249 | 250 | engine.addFact('age', ageSpy) 251 | engine.addFact('isRetired', isRetiredSpy) 252 | engine.addFact('requestedOutreach', requestedOutreachSpy) 253 | engine.on('success', eventSpy) 254 | }) 255 | 256 | it('emits all events when all conditions are met', async () => { 257 | ageSpy.returns(55) 258 | isRetiredSpy.returns(true) 259 | requestedOutreachSpy.returns(true) 260 | await engine.run() 261 | expect(eventSpy).to.have.been.calledWith(outreachEvent) 262 | }) 263 | 264 | it('expands condition in rule results', async () => { 265 | ageSpy.returns(55) 266 | isRetiredSpy.returns(true) 267 | requestedOutreachSpy.returns(true) 268 | const { results } = await engine.run() 269 | const nestedCondition = { 270 | 'conditions.all[0].all[0].not.all[0].fact': 'age', 271 | 'conditions.all[0].all[0].not.all[0].operator': 'greaterThanInclusive', 272 | 'conditions.all[0].all[0].not.all[0].value': 60, 273 | 'conditions.all[0].all[1].fact': 'isRetired', 274 | 'conditions.all[0].all[1].operator': 'equal', 275 | 'conditions.all[0].all[1].value': true 276 | } 277 | expect(results[0]).to.nested.include(nestedCondition) 278 | }) 279 | }) 280 | 281 | describe('top-level condition reference', () => { 282 | const sendEvent = { 283 | type: 'checkSending', 284 | params: { 285 | sendRetirementPayment: true 286 | } 287 | } 288 | 289 | const retiredName = 'retired' 290 | const retiredCondition = { 291 | all: [ 292 | { fact: 'isRetired', operator: 'equal', value: true } 293 | ] 294 | } 295 | 296 | const sendConditions = { 297 | condition: retiredName 298 | } 299 | 300 | let eventSpy 301 | beforeEach(() => { 302 | eventSpy = sandbox.spy() 303 | const sendRule = factories.rule({ 304 | conditions: sendConditions, 305 | event: sendEvent 306 | }) 307 | engine = engineFactory() 308 | 309 | engine.addRule(sendRule) 310 | engine.setCondition(retiredName, retiredCondition) 311 | 312 | engine.addFact('isRetired', true) 313 | engine.on('success', eventSpy) 314 | }) 315 | 316 | it('evaluates top level conditions correctly', async () => { 317 | await engine.run() 318 | expect(eventSpy).to.have.been.called() 319 | }) 320 | }) 321 | }) 322 | -------------------------------------------------------------------------------- /test/engine-controls.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: fact priority', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | const event = { type: 'adult-human-admins' } 16 | 17 | let eventSpy 18 | let ageStub 19 | let segmentStub 20 | 21 | function setup () { 22 | ageStub = sandbox.stub() 23 | segmentStub = sandbox.stub() 24 | eventSpy = sandbox.stub() 25 | engine = engineFactory() 26 | 27 | let conditions = { 28 | any: [{ 29 | fact: 'age', 30 | operator: 'greaterThanInclusive', 31 | value: 18 32 | }] 33 | } 34 | let rule = factories.rule({ conditions, event, priority: 100 }) 35 | engine.addRule(rule) 36 | 37 | conditions = { 38 | any: [{ 39 | fact: 'segment', 40 | operator: 'equal', 41 | value: 'human' 42 | }] 43 | } 44 | rule = factories.rule({ conditions, event }) 45 | engine.addRule(rule) 46 | 47 | engine.addFact('age', ageStub, { priority: 100 }) 48 | engine.addFact('segment', segmentStub, { priority: 50 }) 49 | } 50 | 51 | describe('stop()', () => { 52 | it('stops the rules from executing', async () => { 53 | setup() 54 | ageStub.returns(20) // success 55 | engine.on('success', (event) => { 56 | eventSpy() 57 | engine.stop() 58 | }) 59 | await engine.run() 60 | expect(eventSpy).to.have.been.calledOnce() 61 | expect(ageStub).to.have.been.calledOnce() 62 | expect(segmentStub).to.not.have.been.called() 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/engine-custom-properties.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory, { Fact, Rule } from '../src/index' 4 | 5 | describe('Engine: custom properties', () => { 6 | let engine 7 | const event = { type: 'generic' } 8 | 9 | describe('all conditions', () => { 10 | it('preserves custom properties set on fact', () => { 11 | engine = engineFactory() 12 | const fact = new Fact('age', 12) 13 | fact.customId = 'uuid' 14 | engine.addFact(fact) 15 | expect(engine.facts.get('age')).to.have.property('customId') 16 | expect(engine.facts.get('age').customId).to.equal(fact.customId) 17 | }) 18 | 19 | describe('conditions', () => { 20 | it('preserves custom properties set on boolean conditions', () => { 21 | engine = engineFactory() 22 | const conditions = { 23 | customId: 'uuid1', 24 | all: [{ 25 | fact: 'age', 26 | operator: 'greaterThanInclusive', 27 | value: 18 28 | }] 29 | } 30 | const rule = factories.rule({ conditions, event }) 31 | engine.addRule(rule) 32 | expect(engine.rules[0].conditions).to.have.property('customId') 33 | }) 34 | 35 | it('preserves custom properties set on regular conditions', () => { 36 | engine = engineFactory() 37 | const conditions = { 38 | all: [{ 39 | customId: 'uuid', 40 | fact: 'age', 41 | operator: 'greaterThanInclusive', 42 | value: 18 43 | }] 44 | } 45 | const rule = factories.rule({ conditions, event }) 46 | engine.addRule(rule) 47 | expect(engine.rules[0].conditions.all[0]).to.have.property('customId') 48 | expect(engine.rules[0].conditions.all[0].customId).equal('uuid') 49 | }) 50 | }) 51 | 52 | it('preserves custom properties set on regular conditions', () => { 53 | engine = engineFactory() 54 | const rule = new Rule() 55 | const ruleProperties = factories.rule() 56 | rule.setPriority(ruleProperties.priority) 57 | .setConditions(ruleProperties.conditions) 58 | .setEvent(ruleProperties.event) 59 | rule.customId = 'uuid' 60 | engine.addRule(rule) 61 | expect(engine.rules[0]).to.have.property('customId') 62 | expect(engine.rules[0].customId).equal('uuid') 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/engine-error-handling.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | 5 | describe('Engine: failure', () => { 6 | let engine 7 | 8 | const event = { type: 'generic' } 9 | const conditions = { 10 | any: [{ 11 | fact: 'age', 12 | operator: 'greaterThanInclusive', 13 | value: 21 14 | }] 15 | } 16 | beforeEach(() => { 17 | engine = engineFactory() 18 | const determineDrinkingAgeRule = factories.rule({ conditions, event }) 19 | engine.addRule(determineDrinkingAgeRule) 20 | engine.addFact('age', function (params, engine) { 21 | throw new Error('problem occurred') 22 | }) 23 | }) 24 | 25 | it('surfaces errors', () => { 26 | return expect(engine.run()).to.eventually.rejectedWith(/problem occurred/) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/engine-fact-comparison.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: fact to fact comparison', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | let eventSpy 16 | 17 | function setup (conditions) { 18 | const event = { type: 'success-event' } 19 | eventSpy = sandbox.spy() 20 | engine = engineFactory() 21 | const rule = factories.rule({ conditions, event }) 22 | engine.addRule(rule) 23 | engine.on('success', eventSpy) 24 | } 25 | 26 | context('constant facts', () => { 27 | const constantCondition = { 28 | all: [{ 29 | fact: 'height', 30 | operator: 'lessThanInclusive', 31 | value: { 32 | fact: 'width' 33 | } 34 | }] 35 | } 36 | it('allows a fact to retrieve other fact values', async () => { 37 | setup(constantCondition) 38 | await engine.run({ height: 1, width: 2 }) 39 | expect(eventSpy).to.have.been.calledOnce() 40 | 41 | sandbox.reset() 42 | 43 | await engine.run({ height: 2, width: 1 }) // negative case 44 | expect(eventSpy.callCount).to.equal(0) 45 | }) 46 | }) 47 | 48 | context('rules with parameterized conditions', () => { 49 | const paramsCondition = { 50 | all: [{ 51 | fact: 'widthMultiplier', 52 | params: { 53 | multiplier: 2 54 | }, 55 | operator: 'equal', 56 | value: { 57 | fact: 'heightMultiplier', 58 | params: { 59 | multiplier: 4 60 | } 61 | } 62 | }] 63 | } 64 | it('honors the params', async () => { 65 | setup(paramsCondition) 66 | engine.addFact('heightMultiplier', async (params, almanac) => { 67 | const height = await almanac.factValue('height') 68 | return params.multiplier * height 69 | }) 70 | engine.addFact('widthMultiplier', async (params, almanac) => { 71 | const width = await almanac.factValue('width') 72 | return params.multiplier * width 73 | }) 74 | await engine.run({ height: 5, width: 10 }) 75 | expect(eventSpy).to.have.been.calledOnce() 76 | 77 | sandbox.reset() 78 | 79 | await engine.run({ height: 5, width: 9 }) // negative case 80 | expect(eventSpy.callCount).to.equal(0) 81 | }) 82 | }) 83 | 84 | context('rules with parameterized conditions and path values', () => { 85 | const pathCondition = { 86 | all: [{ 87 | fact: 'widthMultiplier', 88 | params: { 89 | multiplier: 2 90 | }, 91 | path: '$.feet', 92 | operator: 'equal', 93 | value: { 94 | fact: 'heightMultiplier', 95 | params: { 96 | multiplier: 4 97 | }, 98 | path: '$.meters' 99 | } 100 | }] 101 | } 102 | it('honors the path', async () => { 103 | setup(pathCondition) 104 | engine.addFact('heightMultiplier', async (params, almanac) => { 105 | const height = await almanac.factValue('height') 106 | return { meters: params.multiplier * height } 107 | }) 108 | engine.addFact('widthMultiplier', async (params, almanac) => { 109 | const width = await almanac.factValue('width') 110 | return { feet: params.multiplier * width } 111 | }) 112 | await engine.run({ height: 5, width: 10 }) 113 | expect(eventSpy).to.have.been.calledOnce() 114 | 115 | sandbox.reset() 116 | 117 | await engine.run({ height: 5, width: 9 }) // negative case 118 | expect(eventSpy.callCount).to.equal(0) 119 | }) 120 | }) 121 | 122 | context('constant facts: checking valueResult and factResult', () => { 123 | const constantCondition = { 124 | all: [{ 125 | fact: 'height', 126 | operator: 'lessThanInclusive', 127 | value: { 128 | fact: 'width' 129 | } 130 | }] 131 | } 132 | it('result has the correct valueResult and factResult properties', async () => { 133 | setup(constantCondition) 134 | const result = await engine.run({ height: 1, width: 2 }) 135 | 136 | expect(result.results[0].conditions.all[0].factResult).to.equal(1) 137 | expect(result.results[0].conditions.all[0].valueResult).to.equal(2) 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /test/engine-fact-priority.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: fact priority', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | const event = { type: 'adult-human-admins' } 16 | 17 | let eventSpy 18 | let failureSpy 19 | let ageStub 20 | let segmentStub 21 | let accountTypeStub 22 | 23 | function setup (conditions) { 24 | ageStub = sandbox.stub() 25 | segmentStub = sandbox.stub() 26 | accountTypeStub = sandbox.stub() 27 | eventSpy = sandbox.stub() 28 | failureSpy = sandbox.stub() 29 | 30 | engine = engineFactory() 31 | const rule = factories.rule({ conditions, event }) 32 | engine.addRule(rule) 33 | engine.addFact('age', ageStub, { priority: 100 }) 34 | engine.addFact('segment', segmentStub, { priority: 50 }) 35 | engine.addFact('accountType', accountTypeStub, { priority: 25 }) 36 | engine.on('success', eventSpy) 37 | engine.on('failure', failureSpy) 38 | } 39 | 40 | describe('all conditions', () => { 41 | const allCondition = { 42 | all: [{ 43 | fact: 'age', 44 | operator: 'greaterThanInclusive', 45 | value: 18 46 | }, { 47 | fact: 'segment', 48 | operator: 'equal', 49 | value: 'human' 50 | }, { 51 | fact: 'accountType', 52 | operator: 'equal', 53 | value: 'admin' 54 | }] 55 | } 56 | 57 | it('stops on the first fact to fail, part 1', async () => { 58 | setup(allCondition) 59 | ageStub.returns(10) // fail 60 | await engine.run() 61 | expect(failureSpy).to.have.been.called() 62 | expect(eventSpy).to.not.have.been.called() 63 | expect(ageStub).to.have.been.calledOnce() 64 | expect(segmentStub).to.not.have.been.called() 65 | expect(accountTypeStub).to.not.have.been.called() 66 | }) 67 | 68 | it('stops on the first fact to fail, part 2', async () => { 69 | setup(allCondition) 70 | ageStub.returns(20) // pass 71 | segmentStub.returns('android') // fail 72 | await engine.run() 73 | expect(failureSpy).to.have.been.called() 74 | expect(eventSpy).to.not.have.been.called() 75 | expect(ageStub).to.have.been.calledOnce() 76 | expect(segmentStub).to.have.been.calledOnce() 77 | expect(accountTypeStub).to.not.have.been.called() 78 | }) 79 | 80 | describe('sub-conditions', () => { 81 | const allSubCondition = { 82 | all: [{ 83 | fact: 'age', 84 | operator: 'greaterThanInclusive', 85 | value: 18 86 | }, { 87 | all: [ 88 | { 89 | fact: 'segment', 90 | operator: 'equal', 91 | value: 'human' 92 | }, { 93 | fact: 'accountType', 94 | operator: 'equal', 95 | value: 'admin' 96 | } 97 | ] 98 | }] 99 | } 100 | 101 | it('stops after the first sub-condition fact fails', async () => { 102 | setup(allSubCondition) 103 | ageStub.returns(20) // pass 104 | segmentStub.returns('android') // fail 105 | await engine.run() 106 | expect(failureSpy).to.have.been.called() 107 | expect(eventSpy).to.not.have.been.called() 108 | expect(ageStub).to.have.been.calledOnce() 109 | expect(segmentStub).to.have.been.calledOnce() 110 | expect(accountTypeStub).to.not.have.been.called() 111 | }) 112 | }) 113 | }) 114 | 115 | describe('any conditions', () => { 116 | const anyCondition = { 117 | any: [{ 118 | fact: 'age', 119 | operator: 'greaterThanInclusive', 120 | value: 18 121 | }, { 122 | fact: 'segment', 123 | operator: 'equal', 124 | value: 'human' 125 | }, { 126 | fact: 'accountType', 127 | operator: 'equal', 128 | value: 'admin' 129 | }] 130 | } 131 | it('complete on the first fact to succeed, part 1', async () => { 132 | setup(anyCondition) 133 | ageStub.returns(20) // succeed 134 | await engine.run() 135 | expect(eventSpy).to.have.been.calledOnce() 136 | expect(failureSpy).to.not.have.been.called() 137 | expect(ageStub).to.have.been.calledOnce() 138 | expect(segmentStub).to.not.have.been.called() 139 | expect(accountTypeStub).to.not.have.been.called() 140 | }) 141 | 142 | it('short circuits on the first fact to fail, part 2', async () => { 143 | setup(anyCondition) 144 | ageStub.returns(10) // fail 145 | segmentStub.returns('human') // pass 146 | await engine.run() 147 | expect(eventSpy).to.have.been.calledOnce() 148 | expect(failureSpy).to.not.have.been.called() 149 | expect(ageStub).to.have.been.calledOnce() 150 | expect(segmentStub).to.have.been.calledOnce() 151 | expect(accountTypeStub).to.not.have.been.called() 152 | }) 153 | 154 | describe('sub-conditions', () => { 155 | const anySubCondition = { 156 | all: [{ 157 | fact: 'age', 158 | operator: 'greaterThanInclusive', 159 | value: 18 160 | }, { 161 | any: [ 162 | { 163 | fact: 'segment', 164 | operator: 'equal', 165 | value: 'human' 166 | }, { 167 | fact: 'accountType', 168 | operator: 'equal', 169 | value: 'admin' 170 | } 171 | ] 172 | }] 173 | } 174 | 175 | it('stops after the first sub-condition fact succeeds', async () => { 176 | setup(anySubCondition) 177 | ageStub.returns(20) // success 178 | segmentStub.returns('human') // success 179 | await engine.run() 180 | expect(failureSpy).to.not.have.been.called() 181 | expect(eventSpy).to.have.been.called() 182 | expect(ageStub).to.have.been.calledOnce() 183 | expect(segmentStub).to.have.been.calledOnce() 184 | expect(accountTypeStub).to.not.have.been.called() 185 | }) 186 | }) 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /test/engine-facts-calling-facts.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory, { Fact } from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: custom cache keys', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | const event = { type: 'early-twenties' } 16 | const conditions = { 17 | all: [{ 18 | fact: 'demographics', 19 | params: { 20 | field: 'age' 21 | }, 22 | operator: 'lessThanInclusive', 23 | value: 25 24 | }, { 25 | fact: 'demographics', 26 | params: { 27 | field: 'zipCode' 28 | }, 29 | operator: 'equal', 30 | value: 80211 31 | }] 32 | } 33 | 34 | let eventSpy 35 | let demographicDataSpy 36 | let demographicSpy 37 | beforeEach(() => { 38 | demographicSpy = sandbox.spy() 39 | demographicDataSpy = sandbox.spy() 40 | eventSpy = sandbox.spy() 41 | 42 | const demographicsDataDefinition = async (params, engine) => { 43 | demographicDataSpy() 44 | return { 45 | age: 20, 46 | zipCode: 80211 47 | } 48 | } 49 | 50 | const demographicsDefinition = async (params, engine) => { 51 | demographicSpy() 52 | const data = await engine.factValue('demographic-data') 53 | return data[params.field] 54 | } 55 | const demographicsFact = new Fact('demographics', demographicsDefinition) 56 | const demographicsDataFact = new Fact('demographic-data', demographicsDataDefinition) 57 | 58 | engine = engineFactory() 59 | const rule = factories.rule({ conditions, event }) 60 | engine.addRule(rule) 61 | engine.addFact(demographicsFact) 62 | engine.addFact(demographicsDataFact) 63 | engine.on('success', eventSpy) 64 | }) 65 | 66 | describe('1 rule', () => { 67 | it('allows a fact to retrieve other fact values', async () => { 68 | await engine.run() 69 | expect(eventSpy).to.have.been.calledOnce() 70 | expect(demographicDataSpy).to.have.been.calledOnce() 71 | expect(demographicSpy).to.have.been.calledTwice() 72 | }) 73 | }) 74 | 75 | describe('2 rules with parallel conditions', () => { 76 | it('calls the fact definition once', async () => { 77 | const conditions = { 78 | all: [{ 79 | fact: 'demographics', 80 | params: { 81 | field: 'age' 82 | }, 83 | operator: 'greaterThanInclusive', 84 | value: 20 85 | }] 86 | } 87 | const rule = factories.rule({ conditions, event }) 88 | engine.addRule(rule) 89 | 90 | await engine.run() 91 | expect(eventSpy).to.have.been.calledTwice() 92 | expect(demographicDataSpy).to.have.been.calledOnce() 93 | expect(demographicSpy).to.have.been.calledTwice() 94 | expect(demographicDataSpy).to.have.been.calledOnce() 95 | }) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/engine-failure.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: failure', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | const event = { type: 'generic' } 17 | const conditions = { 18 | any: [{ 19 | fact: 'age', 20 | operator: 'greaterThanInclusive', 21 | value: 21 22 | }] 23 | } 24 | beforeEach(() => { 25 | engine = engineFactory() 26 | const determineDrinkingAgeRule = factories.rule({ conditions, event }) 27 | engine.addRule(determineDrinkingAgeRule) 28 | engine.addFact('age', 10) 29 | }) 30 | 31 | it('emits an event on a rule failing', async () => { 32 | const failureSpy = sandbox.spy() 33 | engine.on('failure', failureSpy) 34 | await engine.run() 35 | expect(failureSpy).to.have.been.calledWith(engine.rules[0].ruleEvent) 36 | }) 37 | 38 | it('does not emit when a rule passes', async () => { 39 | const failureSpy = sandbox.spy() 40 | engine.on('failure', failureSpy) 41 | engine.addFact('age', 50) 42 | await engine.run() 43 | expect(failureSpy).to.not.have.been.calledOnce() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/engine-not.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import engineFactory from '../src/index' 5 | 6 | describe('Engine: "not" conditions', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | describe('supports a single "not" condition', () => { 17 | const event = { 18 | type: 'ageTrigger', 19 | params: { 20 | demographic: 'under50' 21 | } 22 | } 23 | const conditions = { 24 | not: { 25 | fact: 'age', 26 | operator: 'greaterThanInclusive', 27 | value: 50 28 | } 29 | } 30 | let eventSpy 31 | let ageSpy 32 | beforeEach(() => { 33 | eventSpy = sandbox.spy() 34 | ageSpy = sandbox.stub() 35 | const rule = factories.rule({ conditions, event }) 36 | engine = engineFactory() 37 | engine.addRule(rule) 38 | engine.addFact('age', ageSpy) 39 | engine.on('success', eventSpy) 40 | }) 41 | 42 | it('emits when the condition is met', async () => { 43 | ageSpy.returns(10) 44 | await engine.run() 45 | expect(eventSpy).to.have.been.calledWith(event) 46 | }) 47 | 48 | it('does not emit when the condition fails', () => { 49 | ageSpy.returns(75) 50 | engine.run() 51 | expect(eventSpy).to.not.have.been.calledWith(event) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/engine-operator-map.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { expect } from 'chai' 4 | import engineFactory, { Operator, OperatorDecorator } from '../src/index' 5 | 6 | const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => { 7 | return factValue[0] === jsonValue 8 | }) 9 | 10 | const never = new OperatorDecorator('never', () => false) 11 | 12 | describe('Engine Operator Map', () => { 13 | let engine 14 | beforeEach(() => { 15 | engine = engineFactory() 16 | engine.addOperator(startsWithLetter) 17 | engine.addOperatorDecorator(never) 18 | }) 19 | 20 | describe('undecorated operator', () => { 21 | let op 22 | beforeEach(() => { 23 | op = engine.operators.get('startsWithLetter') 24 | }) 25 | 26 | it('has the operator', () => { 27 | expect(op).not.to.be.null() 28 | }) 29 | 30 | it('the operator evaluates correctly', () => { 31 | expect(op.evaluate('test', 't')).to.be.true() 32 | }) 33 | 34 | it('after being removed the operator is null', () => { 35 | engine.operators.removeOperator(startsWithLetter) 36 | op = engine.operators.get('startsWithLetter') 37 | expect(op).to.be.null() 38 | }) 39 | }) 40 | 41 | describe('decorated operator', () => { 42 | let op 43 | beforeEach(() => { 44 | op = engine.operators.get('never:startsWithLetter') 45 | }) 46 | 47 | it('has the operator', () => { 48 | expect(op).not.to.be.null() 49 | }) 50 | 51 | it('the operator evaluates correctly', () => { 52 | expect(op.evaluate('test', 't')).to.be.false() 53 | }) 54 | 55 | it('removing the base operator removes the decorated version', () => { 56 | engine.operators.removeOperator(startsWithLetter) 57 | op = engine.operators.get('never:startsWithLetter') 58 | expect(op).to.be.null() 59 | }) 60 | 61 | it('removing the decorator removes the decorated operator', () => { 62 | engine.operators.removeOperatorDecorator(never) 63 | op = engine.operators.get('never:startsWithLetter') 64 | expect(op).to.be.null() 65 | }) 66 | }) 67 | 68 | describe('combinatorics with default operators', () => { 69 | it('combines every, some, not, and greaterThanInclusive operators', () => { 70 | const odds = [1, 3, 5, 7] 71 | const evens = [2, 4, 6, 8] 72 | 73 | // technically not:greaterThanInclusive is the same as lessThan 74 | const op = engine.operators.get('everyFact:someValue:not:greaterThanInclusive') 75 | expect(op.evaluate(odds, evens)).to.be.true() 76 | }) 77 | }) 78 | 79 | it('the swap decorator', () => { 80 | const factValue = 1 81 | const jsonValue = [1, 2, 3] 82 | 83 | const op = engine.operators.get('swap:contains') 84 | expect(op.evaluate(factValue, jsonValue)).to.be.true() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/engine-operator.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import sinon from 'sinon' 4 | import engineFactory from '../src/index' 5 | 6 | async function dictionary (params, engine) { 7 | const words = ['coffee', 'Aardvark', 'moose', 'ladder', 'antelope'] 8 | return words[params.wordIndex] 9 | } 10 | 11 | describe('Engine: operator', () => { 12 | let sandbox 13 | before(() => { 14 | sandbox = sinon.createSandbox() 15 | }) 16 | afterEach(() => { 17 | sandbox.restore() 18 | }) 19 | const event = { 20 | type: 'operatorTrigger' 21 | } 22 | const baseConditions = { 23 | any: [{ 24 | fact: 'dictionary', 25 | operator: 'startsWithLetter', 26 | value: 'a', 27 | params: { 28 | wordIndex: null 29 | } 30 | }] 31 | } 32 | let eventSpy 33 | function setup (conditions = baseConditions) { 34 | eventSpy = sandbox.spy() 35 | const engine = engineFactory() 36 | const rule = factories.rule({ conditions, event }) 37 | engine.addRule(rule) 38 | engine.addOperator('startsWithLetter', (factValue, jsonValue) => { 39 | if (!factValue.length) return false 40 | return factValue[0].toLowerCase() === jsonValue.toLowerCase() 41 | }) 42 | engine.addFact('dictionary', dictionary) 43 | engine.on('success', eventSpy) 44 | return engine 45 | } 46 | 47 | describe('evaluation', () => { 48 | it('emits when the condition is met', async () => { 49 | const conditions = Object.assign({}, baseConditions) 50 | conditions.any[0].params.wordIndex = 1 51 | const engine = setup() 52 | await engine.run() 53 | expect(eventSpy).to.have.been.calledWith(event) 54 | }) 55 | 56 | it('does not emit when the condition fails', async () => { 57 | const conditions = Object.assign({}, baseConditions) 58 | conditions.any[0].params.wordIndex = 0 59 | const engine = setup() 60 | await engine.run() 61 | expect(eventSpy).to.not.have.been.calledWith(event) 62 | }) 63 | 64 | it('throws when it encounters an unregistered operator', async () => { 65 | const conditions = Object.assign({}, baseConditions) 66 | conditions.any[0].operator = 'unknown-operator' 67 | const engine = setup() 68 | return expect(engine.run()).to.eventually.be.rejectedWith('Unknown operator: unknown-operator') 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/engine-parallel-condition-cache.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine', () => { 7 | let engine 8 | let sandbox 9 | before(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | const event = { type: 'early-twenties' } 16 | const conditions = { 17 | all: [{ 18 | fact: 'age', 19 | operator: 'lessThanInclusive', 20 | value: 25 21 | }, { 22 | fact: 'age', 23 | operator: 'greaterThanInclusive', 24 | value: 20 25 | }, { 26 | fact: 'age', 27 | operator: 'notIn', 28 | value: [21, 22] 29 | }] 30 | } 31 | 32 | let eventSpy 33 | let factSpy 34 | function setup (factOptions) { 35 | factSpy = sandbox.spy() 36 | eventSpy = sandbox.spy() 37 | 38 | const factDefinition = () => { 39 | factSpy() 40 | return 24 41 | } 42 | 43 | engine = engineFactory() 44 | const rule = factories.rule({ conditions, event }) 45 | engine.addRule(rule) 46 | engine.addFact('age', factDefinition, factOptions) 47 | engine.on('success', eventSpy) 48 | } 49 | 50 | describe('1 rule with parallel conditions', () => { 51 | it('calls the fact definition once for each condition if caching is off', async () => { 52 | setup({ cache: false }) 53 | await engine.run() 54 | expect(eventSpy).to.have.been.calledOnce() 55 | expect(factSpy).to.have.been.calledThrice() 56 | }) 57 | 58 | it('calls the fact definition once', async () => { 59 | setup() 60 | await engine.run() 61 | expect(eventSpy).to.have.been.calledOnce() 62 | expect(factSpy).to.have.been.calledOnce() 63 | }) 64 | }) 65 | 66 | describe('2 rules with parallel conditions', () => { 67 | it('calls the fact definition once', async () => { 68 | setup() 69 | const conditions = { 70 | all: [{ 71 | fact: 'age', 72 | operator: 'notIn', 73 | value: [21, 22] 74 | }] 75 | } 76 | const rule = factories.rule({ conditions, event }) 77 | engine.addRule(rule) 78 | 79 | await engine.run() 80 | expect(eventSpy).to.have.been.calledTwice() 81 | expect(factSpy).to.have.been.calledOnce() 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /test/engine-recusive-rules.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: recursive rules', () => { 7 | let engine 8 | const event = { type: 'middle-income-adult' } 9 | const nestedAnyCondition = { 10 | all: [ 11 | { 12 | fact: 'age', 13 | operator: 'lessThan', 14 | value: 65 15 | }, 16 | { 17 | fact: 'age', 18 | operator: 'greaterThan', 19 | value: 21 20 | }, 21 | { 22 | any: [ 23 | { 24 | fact: 'income', 25 | operator: 'lessThanInclusive', 26 | value: 100 27 | }, 28 | { 29 | fact: 'family-size', 30 | operator: 'lessThanInclusive', 31 | value: 3 32 | } 33 | ] 34 | } 35 | ] 36 | } 37 | 38 | let sandbox 39 | before(() => { 40 | sandbox = sinon.createSandbox() 41 | }) 42 | afterEach(() => { 43 | sandbox.restore() 44 | }) 45 | 46 | let eventSpy 47 | function setup (conditions = nestedAnyCondition) { 48 | eventSpy = sandbox.spy() 49 | 50 | engine = engineFactory() 51 | const rule = factories.rule({ conditions, event }) 52 | engine.addRule(rule) 53 | engine.on('success', eventSpy) 54 | } 55 | 56 | describe('"all" with nested "any"', () => { 57 | it('evaluates true when facts pass rules', async () => { 58 | setup() 59 | engine.addFact('age', 30) 60 | engine.addFact('income', 30) 61 | engine.addFact('family-size', 2) 62 | await engine.run() 63 | expect(eventSpy).to.have.been.calledOnce() 64 | }) 65 | 66 | it('evaluates false when facts do not pass rules', async () => { 67 | setup() 68 | engine.addFact('age', 30) 69 | engine.addFact('income', 200) 70 | engine.addFact('family-size', 8) 71 | await engine.run() 72 | expect(eventSpy).to.not.have.been.calledOnce() 73 | }) 74 | }) 75 | 76 | const nestedAllCondition = { 77 | any: [ 78 | { 79 | fact: 'age', 80 | operator: 'lessThan', 81 | value: 65 82 | }, 83 | { 84 | fact: 'age', 85 | operator: 'equal', 86 | value: 70 87 | }, 88 | { 89 | all: [ 90 | { 91 | fact: 'income', 92 | operator: 'lessThanInclusive', 93 | value: 100 94 | }, 95 | { 96 | fact: 'family-size', 97 | operator: 'lessThanInclusive', 98 | value: 3 99 | } 100 | ] 101 | } 102 | ] 103 | } 104 | 105 | describe('"any" with nested "all"', () => { 106 | it('evaluates true when facts pass rules', async () => { 107 | setup(nestedAllCondition) 108 | engine.addFact('age', 90) 109 | engine.addFact('income', 30) 110 | engine.addFact('family-size', 2) 111 | await engine.run() 112 | expect(eventSpy).to.have.been.calledOnce() 113 | }) 114 | 115 | it('evaluates false when facts do not pass rules', async () => { 116 | setup(nestedAllCondition) 117 | engine.addFact('age', 90) 118 | engine.addFact('income', 200) 119 | engine.addFact('family-size', 2) 120 | await engine.run() 121 | expect(eventSpy).to.not.have.been.calledOnce() 122 | }) 123 | }) 124 | 125 | const thriceNestedCondition = { 126 | any: [ 127 | { 128 | all: [ 129 | { 130 | any: [ 131 | { 132 | fact: 'income', 133 | operator: 'lessThanInclusive', 134 | value: 100 135 | } 136 | ] 137 | }, 138 | { 139 | fact: 'family-size', 140 | operator: 'lessThanInclusive', 141 | value: 3 142 | } 143 | ] 144 | } 145 | ] 146 | } 147 | 148 | describe('"any" with "all" within "any"', () => { 149 | it('evaluates true when facts pass rules', async () => { 150 | setup(thriceNestedCondition) 151 | engine.addFact('income', 30) 152 | engine.addFact('family-size', 1) 153 | await engine.run() 154 | expect(eventSpy).to.have.been.calledOnce() 155 | }) 156 | 157 | it('evaluates false when facts do not pass rules', async () => { 158 | setup(thriceNestedCondition) 159 | engine.addFact('income', 30) 160 | engine.addFact('family-size', 5) 161 | await engine.run() 162 | expect(eventSpy).to.not.have.been.calledOnce() 163 | }) 164 | }) 165 | 166 | const notNotCondition = { 167 | not: { 168 | not: { 169 | fact: 'age', 170 | operator: 'lessThan', 171 | value: 65 172 | } 173 | } 174 | } 175 | 176 | describe('"not" nested directly within a "not"', () => { 177 | it('evaluates true when facts pass rules', async () => { 178 | setup(notNotCondition) 179 | engine.addFact('age', 30) 180 | await engine.run() 181 | expect(eventSpy).to.have.been.calledOnce() 182 | }) 183 | 184 | it('evaluates false when facts do not pass rules', async () => { 185 | setup(notNotCondition) 186 | engine.addFact('age', 65) 187 | await engine.run() 188 | expect(eventSpy).to.not.have.been.calledOnce() 189 | }) 190 | }) 191 | 192 | const nestedNotCondition = { 193 | not: { 194 | all: [ 195 | { 196 | fact: 'age', 197 | operator: 'lessThan', 198 | value: 65 199 | }, 200 | { 201 | fact: 'age', 202 | operator: 'greaterThan', 203 | value: 21 204 | }, 205 | { 206 | not: { 207 | fact: 'income', 208 | operator: 'lessThanInclusive', 209 | value: 100 210 | } 211 | } 212 | ] 213 | } 214 | } 215 | 216 | describe('outer "not" with nested "all" and nested "not" condition', () => { 217 | it('evaluates true when facts pass rules', async () => { 218 | setup(nestedNotCondition) 219 | engine.addFact('age', 30) 220 | engine.addFact('income', 100) 221 | await engine.run() 222 | expect(eventSpy).to.have.been.calledOnce() 223 | }) 224 | 225 | it('evaluates false when facts do not pass rules', async () => { 226 | setup(nestedNotCondition) 227 | engine.addFact('age', 30) 228 | engine.addFact('income', 101) 229 | await engine.run() 230 | expect(eventSpy).to.not.have.been.calledOnce() 231 | }) 232 | }) 233 | }) 234 | -------------------------------------------------------------------------------- /test/engine-rule-priority.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import sinon from 'sinon' 5 | 6 | describe('Engine: rule priorities', () => { 7 | let engine 8 | 9 | const highPriorityEvent = { type: 'highPriorityEvent' } 10 | const midPriorityEvent = { type: 'midPriorityEvent' } 11 | const lowestPriorityEvent = { type: 'lowestPriorityEvent' } 12 | const conditions = { 13 | any: [{ 14 | fact: 'age', 15 | operator: 'greaterThanInclusive', 16 | value: 21 17 | }] 18 | } 19 | 20 | let sandbox 21 | before(() => { 22 | sandbox = sinon.createSandbox() 23 | }) 24 | afterEach(() => { 25 | sandbox.restore() 26 | }) 27 | 28 | function setup () { 29 | const factSpy = sandbox.stub().returns(22) 30 | const eventSpy = sandbox.spy() 31 | engine = engineFactory() 32 | 33 | const highPriorityRule = factories.rule({ conditions, event: midPriorityEvent, priority: 50 }) 34 | engine.addRule(highPriorityRule) 35 | 36 | const midPriorityRule = factories.rule({ conditions, event: highPriorityEvent, priority: 100 }) 37 | engine.addRule(midPriorityRule) 38 | 39 | const lowPriorityRule = factories.rule({ conditions, event: lowestPriorityEvent, priority: 1 }) 40 | engine.addRule(lowPriorityRule) 41 | 42 | engine.addFact('age', factSpy) 43 | engine.on('success', eventSpy) 44 | } 45 | 46 | it('runs the rules in order of priority', () => { 47 | setup() 48 | expect(engine.prioritizedRules).to.be.null() 49 | engine.prioritizeRules() 50 | expect(engine.prioritizedRules.length).to.equal(3) 51 | expect(engine.prioritizedRules[0][0].priority).to.equal(100) 52 | expect(engine.prioritizedRules[1][0].priority).to.equal(50) 53 | expect(engine.prioritizedRules[2][0].priority).to.equal(1) 54 | }) 55 | 56 | it('clears re-propriorizes the rules when a new Rule is added', () => { 57 | engine.prioritizeRules() 58 | expect(engine.prioritizedRules.length).to.equal(3) 59 | engine.addRule(factories.rule()) 60 | expect(engine.prioritizedRules).to.be.null() 61 | }) 62 | 63 | it('resolves all events returning promises before executing the next rule', async () => { 64 | setup() 65 | 66 | const highPrioritySpy = sandbox.spy() 67 | const midPrioritySpy = sandbox.spy() 68 | const lowPrioritySpy = sandbox.spy() 69 | 70 | engine.on(highPriorityEvent.type, () => { 71 | return new Promise(function (resolve) { 72 | setTimeout(function () { 73 | highPrioritySpy() 74 | resolve() 75 | }, 10) // wait longest 76 | }) 77 | }) 78 | engine.on(midPriorityEvent.type, () => { 79 | return new Promise(function (resolve) { 80 | setTimeout(function () { 81 | midPrioritySpy() 82 | resolve() 83 | }, 5) // wait half as much 84 | }) 85 | }) 86 | 87 | engine.on(lowestPriorityEvent.type, () => { 88 | lowPrioritySpy() // emit immediately. this event should still be triggered last 89 | }) 90 | 91 | await engine.run() 92 | 93 | expect(highPrioritySpy).to.be.calledBefore(midPrioritySpy) 94 | expect(midPrioritySpy).to.be.calledBefore(lowPrioritySpy) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/engine-run.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import Almanac from '../src/almanac' 5 | import sinon from 'sinon' 6 | 7 | describe('Engine: run', () => { 8 | let engine, rule, rule2 9 | let sandbox 10 | before(() => { 11 | sandbox = sinon.createSandbox() 12 | }) 13 | afterEach(() => { 14 | sandbox.restore() 15 | }) 16 | 17 | const condition21 = { 18 | any: [{ 19 | fact: 'age', 20 | operator: 'greaterThanInclusive', 21 | value: 21 22 | }] 23 | } 24 | const condition75 = { 25 | any: [{ 26 | fact: 'age', 27 | operator: 'greaterThanInclusive', 28 | value: 75 29 | }] 30 | } 31 | let eventSpy 32 | 33 | beforeEach(() => { 34 | eventSpy = sandbox.spy() 35 | engine = engineFactory() 36 | rule = factories.rule({ conditions: condition21, event: { type: 'generic1' } }) 37 | engine.addRule(rule) 38 | rule2 = factories.rule({ conditions: condition75, event: { type: 'generic2' } }) 39 | engine.addRule(rule2) 40 | engine.on('success', eventSpy) 41 | }) 42 | 43 | describe('independent runs', () => { 44 | it('treats each run() independently', async () => { 45 | await Promise.all([50, 10, 12, 30, 14, 15, 25].map((age) => engine.run({ age }))) 46 | expect(eventSpy).to.have.been.calledThrice() 47 | }) 48 | 49 | it('allows runtime facts to override engine facts for a single run()', async () => { 50 | engine.addFact('age', 30) 51 | 52 | await engine.run({ age: 85 }) // override 'age' with runtime fact 53 | expect(eventSpy).to.have.been.calledTwice() 54 | 55 | sandbox.reset() 56 | await engine.run() // no runtime fact; revert to age: 30 57 | expect(eventSpy).to.have.been.calledOnce() 58 | 59 | sandbox.reset() 60 | await engine.run({ age: 2 }) // override 'age' with runtime fact 61 | expect(eventSpy.callCount).to.equal(0) 62 | }) 63 | }) 64 | 65 | describe('returns', () => { 66 | it('activated events', async () => { 67 | const { events, failureEvents } = await engine.run({ age: 30 }) 68 | expect(events.length).to.equal(1) 69 | expect(events).to.deep.include(rule.event) 70 | expect(failureEvents.length).to.equal(1) 71 | expect(failureEvents).to.deep.include(rule2.event) 72 | }) 73 | 74 | it('multiple activated events', () => { 75 | return engine.run({ age: 90 }).then(results => { 76 | expect(results.events.length).to.equal(2) 77 | expect(results.events).to.deep.include(rule.event) 78 | expect(results.events).to.deep.include(rule2.event) 79 | }) 80 | }) 81 | 82 | it('does not include unactived triggers', () => { 83 | return engine.run({ age: 10 }).then(results => { 84 | expect(results.events.length).to.equal(0) 85 | }) 86 | }) 87 | 88 | it('includes the almanac', () => { 89 | return engine.run({ age: 10 }).then(results => { 90 | expect(results.almanac).to.be.an.instanceOf(Almanac) 91 | return results.almanac.factValue('age') 92 | }).then(ageFact => expect(ageFact).to.equal(10)) 93 | }) 94 | }) 95 | 96 | describe('facts updated during run', () => { 97 | beforeEach(() => { 98 | engine.on('success', (event, almanac, ruleResult) => { 99 | // Assign unique runtime facts per event 100 | almanac.addRuntimeFact(`runtime-fact-${event.type}`, ruleResult.conditions.any[0].value) 101 | }) 102 | }) 103 | 104 | it('returns an almanac with runtime facts added', () => { 105 | return engine.run({ age: 90 }).then(results => { 106 | return Promise.all([ 107 | results.almanac.factValue('runtime-fact-generic1'), 108 | results.almanac.factValue('runtime-fact-generic2') 109 | ]) 110 | }).then(promiseValues => { 111 | expect(promiseValues[0]).to.equal(21) 112 | expect(promiseValues[1]).to.equal(75) 113 | }) 114 | }) 115 | }) 116 | 117 | describe('custom alamanc', () => { 118 | class CapitalAlmanac extends Almanac { 119 | factValue (factId, params, path) { 120 | return super.factValue(factId, params, path).then(value => { 121 | if (typeof value === 'string') { 122 | return value.toUpperCase() 123 | } 124 | return value 125 | }) 126 | } 127 | } 128 | 129 | it('returns the capitalized value when using the CapitalAlamanc', () => { 130 | return engine.run({ greeting: 'hello', age: 30 }, { almanac: new CapitalAlmanac() }).then((results) => { 131 | const fact = results.almanac.factValue('greeting') 132 | return expect(fact).to.eventually.equal('HELLO') 133 | }) 134 | }) 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /test/fact.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { Fact } from '../src/index' 4 | 5 | describe('Fact', () => { 6 | function subject (id, definition, options) { 7 | return new Fact(id, definition, options) 8 | } 9 | describe('Fact::constructor', () => { 10 | it('works for constant facts', () => { 11 | const fact = subject('factId', 10) 12 | expect(fact.id).to.equal('factId') 13 | expect(fact.value).to.equal(10) 14 | }) 15 | 16 | it('works for dynamic facts', () => { 17 | const fact = subject('factId', () => 10) 18 | expect(fact.id).to.equal('factId') 19 | expect(fact.calculate()).to.equal(10) 20 | }) 21 | 22 | it('allows options to be passed', () => { 23 | const opts = { test: true, cache: false } 24 | const fact = subject('factId', 10, opts) 25 | expect(fact.options).to.eql(opts) 26 | }) 27 | 28 | describe('validations', () => { 29 | it('throws if no id provided', () => { 30 | expect(subject).to.throw(/factId required/) 31 | }) 32 | }) 33 | }) 34 | 35 | describe('Fact::types', () => { 36 | it('initializes facts with method values as dynamic', () => { 37 | const fact = subject('factId', () => {}) 38 | expect(fact.type).to.equal(Fact.DYNAMIC) 39 | expect(fact.isDynamic()).to.be.true() 40 | }) 41 | 42 | it('initializes facts with non-methods as constant', () => { 43 | const fact = subject('factId', 2) 44 | expect(fact.type).to.equal(Fact.CONSTANT) 45 | expect(fact.isConstant()).to.be.true() 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import subject from '../src/index' 4 | 5 | describe('json-business-subject', () => { 6 | it('treats each rule engine independently', () => { 7 | const engine1 = subject() 8 | const engine2 = subject() 9 | engine1.addRule(factories.rule()) 10 | engine2.addRule(factories.rule()) 11 | expect(engine1.rules.length).to.equal(1) 12 | expect(engine2.rules.length).to.equal(1) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /test/operator-decorator.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { OperatorDecorator, Operator } from '../src/index' 4 | 5 | const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => { 6 | return factValue[0] === jsonValue 7 | }) 8 | 9 | describe('OperatorDecorator', () => { 10 | describe('constructor()', () => { 11 | function subject (...args) { 12 | return new OperatorDecorator(...args) 13 | } 14 | 15 | it('adds the decorator', () => { 16 | const decorator = subject('test', () => false) 17 | expect(decorator.name).to.equal('test') 18 | expect(decorator.cb).to.an.instanceof(Function) 19 | }) 20 | 21 | it('decorator name', () => { 22 | expect(() => { 23 | subject() 24 | }).to.throw(/Missing decorator name/) 25 | }) 26 | 27 | it('decorator definition', () => { 28 | expect(() => { 29 | subject('test') 30 | }).to.throw(/Missing decorator callback/) 31 | }) 32 | }) 33 | 34 | describe('decorating', () => { 35 | const subject = new OperatorDecorator('test', () => false).decorate(startsWithLetter) 36 | it('creates a new operator with the prefixed name', () => { 37 | expect(subject.name).to.equal('test:startsWithLetter') 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /test/operator.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { Operator } from '../src/index' 4 | 5 | describe('Operator', () => { 6 | describe('constructor()', () => { 7 | function subject (...args) { 8 | return new Operator(...args) 9 | } 10 | 11 | it('adds the operator', () => { 12 | const operator = subject('startsWithLetter', (factValue, jsonValue) => { 13 | return factValue[0] === jsonValue 14 | }) 15 | expect(operator.name).to.equal('startsWithLetter') 16 | expect(operator.cb).to.an.instanceof(Function) 17 | }) 18 | 19 | it('operator name', () => { 20 | expect(() => { 21 | subject() 22 | }).to.throw(/Missing operator name/) 23 | }) 24 | 25 | it('operator definition', () => { 26 | expect(() => { 27 | subject('startsWithLetter') 28 | }).to.throw(/Missing operator callback/) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/performance.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import engineFactory from '../src/index' 4 | import perfy from 'perfy' 5 | import deepClone from 'clone' 6 | 7 | describe('Performance', () => { 8 | const baseConditions = { 9 | any: [{ 10 | fact: 'age', 11 | operator: 'lessThan', 12 | value: 50 13 | }, 14 | { 15 | fact: 'segment', 16 | operator: 'equal', 17 | value: 'european' 18 | }] 19 | } 20 | const event = { 21 | type: 'ageTrigger', 22 | params: { 23 | demographic: 'under50' 24 | } 25 | } 26 | /* 27 | * Generates an array of integers of length 'num' 28 | */ 29 | function range (num) { 30 | return Array.from(Array(num).keys()) 31 | } 32 | 33 | function setup (conditions) { 34 | const engine = engineFactory() 35 | const config = deepClone({ conditions, event }) 36 | range(1000).forEach(() => { 37 | const rule = factories.rule(config) 38 | engine.addRule(rule) 39 | }) 40 | engine.addFact('segment', 'european', { cache: true }) 41 | engine.addFact('age', 15, { cache: true }) 42 | return engine 43 | } 44 | 45 | it('performs "any" quickly', async () => { 46 | const engine = setup(baseConditions) 47 | perfy.start('any') 48 | await engine.run() 49 | const result = perfy.end('any') 50 | expect(result.time).to.be.greaterThan(0.001) 51 | expect(result.time).to.be.lessThan(0.5) 52 | }) 53 | 54 | it('performs "all" quickly', async () => { 55 | const conditions = deepClone(baseConditions) 56 | conditions.all = conditions.any 57 | delete conditions.any 58 | const engine = setup(conditions) 59 | perfy.start('all') 60 | await engine.run() 61 | const result = perfy.end('all') 62 | expect(result.time).to.be.greaterThan(0.001) // assert lower value 63 | expect(result.time).to.be.lessThan(0.5) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/rule.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Engine from '../src/index' 4 | import Rule from '../src/rule' 5 | import sinon from 'sinon' 6 | 7 | describe('Rule', () => { 8 | const rule = new Rule() 9 | const conditionBase = factories.condition({ 10 | fact: 'age', 11 | value: 50 12 | }) 13 | 14 | describe('constructor()', () => { 15 | it('can be initialized with priority, conditions, event, and name', () => { 16 | const condition = { 17 | all: [Object.assign({}, conditionBase)] 18 | } 19 | condition.operator = 'all' 20 | condition.priority = 25 21 | const opts = { 22 | priority: 50, 23 | conditions: condition, 24 | event: { 25 | type: 'awesome' 26 | }, 27 | name: 'testName' 28 | } 29 | const rule = new Rule(opts) 30 | expect(rule.priority).to.eql(opts.priority) 31 | expect(rule.conditions).to.eql(opts.conditions) 32 | expect(rule.ruleEvent).to.eql(opts.event) 33 | expect(rule.event).to.eql(opts.event) 34 | expect(rule.name).to.eql(opts.name) 35 | }) 36 | 37 | it('it can be initialized with a json string', () => { 38 | const condition = { 39 | all: [Object.assign({}, conditionBase)] 40 | } 41 | condition.operator = 'all' 42 | condition.priority = 25 43 | const opts = { 44 | priority: 50, 45 | conditions: condition, 46 | event: { 47 | type: 'awesome' 48 | }, 49 | name: 'testName' 50 | } 51 | const json = JSON.stringify(opts) 52 | const rule = new Rule(json) 53 | expect(rule.priority).to.eql(opts.priority) 54 | expect(rule.conditions).to.eql(opts.conditions) 55 | expect(rule.ruleEvent).to.eql(opts.event) 56 | expect(rule.event).to.eql(opts.event) 57 | expect(rule.name).to.eql(opts.name) 58 | }) 59 | }) 60 | 61 | describe('event emissions', () => { 62 | it('can emit', () => { 63 | const rule = new Rule() 64 | const successSpy = sinon.spy() 65 | rule.on('test', successSpy) 66 | rule.emit('test') 67 | expect(successSpy.callCount).to.equal(1) 68 | }) 69 | 70 | it('can be initialized with an onSuccess option', (done) => { 71 | const event = { type: 'test' } 72 | const onSuccess = function (e) { 73 | expect(e).to.equal(event) 74 | done() 75 | } 76 | const rule = new Rule({ onSuccess }) 77 | rule.emit('success', event) 78 | }) 79 | 80 | it('can be initialized with an onFailure option', (done) => { 81 | const event = { type: 'test' } 82 | const onFailure = function (e) { 83 | expect(e).to.equal(event) 84 | done() 85 | } 86 | const rule = new Rule({ onFailure }) 87 | rule.emit('failure', event) 88 | }) 89 | }) 90 | 91 | describe('setEvent()', () => { 92 | it('throws if no argument provided', () => { 93 | expect(() => rule.setEvent()).to.throw(/Rule: setEvent\(\) requires event object/) 94 | }) 95 | 96 | it('throws if argument is missing "type" property', () => { 97 | expect(() => rule.setEvent({})).to.throw(/Rule: setEvent\(\) requires event object with "type" property/) 98 | }) 99 | }) 100 | 101 | describe('setEvent()', () => { 102 | it('throws if no argument provided', () => { 103 | expect(() => rule.setEvent()).to.throw(/Rule: setEvent\(\) requires event object/) 104 | }) 105 | 106 | it('throws if argument is missing "type" property', () => { 107 | expect(() => rule.setEvent({})).to.throw(/Rule: setEvent\(\) requires event object with "type" property/) 108 | }) 109 | }) 110 | 111 | describe('setConditions()', () => { 112 | describe('validations', () => { 113 | it('throws an exception for invalid root conditions', () => { 114 | expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", "not", or "condition"/) 115 | }) 116 | }) 117 | }) 118 | 119 | describe('setPriority', () => { 120 | it('defaults to a priority of 1', () => { 121 | expect(rule.priority).to.equal(1) 122 | }) 123 | 124 | it('allows a priority to be set', () => { 125 | rule.setPriority(10) 126 | expect(rule.priority).to.equal(10) 127 | }) 128 | 129 | it('errors if priority is less than 0', () => { 130 | expect(rule.setPriority.bind(null, 0)).to.throw(/greater than zero/) 131 | }) 132 | }) 133 | 134 | describe('accessors', () => { 135 | it('retrieves event', () => { 136 | const event = { type: 'e', params: { a: 'b' } } 137 | rule.setEvent(event) 138 | expect(rule.getEvent()).to.deep.equal(event) 139 | }) 140 | 141 | it('retrieves priority', () => { 142 | const priority = 100 143 | rule.setPriority(priority) 144 | expect(rule.getPriority()).to.equal(priority) 145 | }) 146 | 147 | it('retrieves conditions', () => { 148 | const condition = { all: [] } 149 | rule.setConditions(condition) 150 | expect(rule.getConditions()).to.deep.equal({ 151 | all: [], 152 | operator: 'all', 153 | priority: 1 154 | }) 155 | }) 156 | }) 157 | 158 | describe('setName', () => { 159 | it('defaults to undefined', () => { 160 | expect(rule.name).to.equal(undefined) 161 | }) 162 | 163 | it('allows the name to be set', () => { 164 | rule.setName('Test Name') 165 | expect(rule.name).to.equal('Test Name') 166 | }) 167 | 168 | it('allows input of the number 0', () => { 169 | rule.setName(0) 170 | expect(rule.name).to.equal(0) 171 | }) 172 | 173 | it('allows input of an object', () => { 174 | rule.setName({ 175 | id: 123, 176 | name: 'myRule' 177 | }) 178 | expect(rule.name).to.eql({ 179 | id: 123, 180 | name: 'myRule' 181 | }) 182 | }) 183 | 184 | it('errors if name is an empty string', () => { 185 | expect(rule.setName.bind(null, '')).to.throw(/Rule "name" must be defined/) 186 | }) 187 | }) 188 | 189 | describe('priotizeConditions()', () => { 190 | const conditions = [{ 191 | fact: 'age', 192 | operator: 'greaterThanInclusive', 193 | value: 18 194 | }, { 195 | fact: 'segment', 196 | operator: 'equal', 197 | value: 'human' 198 | }, { 199 | fact: 'accountType', 200 | operator: 'equal', 201 | value: 'admin' 202 | }, { 203 | fact: 'state', 204 | operator: 'equal', 205 | value: 'admin' 206 | }] 207 | 208 | it('orders based on priority', async () => { 209 | const engine = new Engine() 210 | engine.addFact('state', async () => {}, { priority: 500 }) 211 | engine.addFact('segment', async () => {}, { priority: 50 }) 212 | engine.addFact('accountType', async () => {}, { priority: 25 }) 213 | engine.addFact('age', async () => {}, { priority: 100 }) 214 | const rule = new Rule() 215 | rule.setEngine(engine) 216 | 217 | const prioritizedConditions = rule.prioritizeConditions(conditions) 218 | expect(prioritizedConditions.length).to.equal(4) 219 | expect(prioritizedConditions[0][0].fact).to.equal('state') 220 | expect(prioritizedConditions[1][0].fact).to.equal('age') 221 | expect(prioritizedConditions[2][0].fact).to.equal('segment') 222 | expect(prioritizedConditions[3][0].fact).to.equal('accountType') 223 | }) 224 | }) 225 | 226 | describe('evaluate()', () => { 227 | function setup () { 228 | const engine = new Engine() 229 | const rule = new Rule() 230 | rule.setConditions({ 231 | all: [] 232 | }) 233 | engine.addRule(rule) 234 | 235 | return { engine, rule } 236 | } 237 | it('evalutes truthy when there are no conditions', async () => { 238 | const engineSuccessSpy = sinon.spy() 239 | const { engine } = setup() 240 | 241 | engine.on('success', engineSuccessSpy) 242 | 243 | await engine.run() 244 | 245 | expect(engineSuccessSpy).to.have.been.calledOnce() 246 | }) 247 | 248 | it('waits for all on("success") event promises to be resolved', async () => { 249 | const engineSuccessSpy = sinon.spy() 250 | const ruleSuccessSpy = sinon.spy() 251 | const engineRunSpy = sinon.spy() 252 | const { engine, rule } = setup() 253 | rule.on('success', () => { 254 | return new Promise(function (resolve) { 255 | setTimeout(function () { 256 | ruleSuccessSpy() 257 | resolve() 258 | }, 5) 259 | }) 260 | }) 261 | engine.on('success', engineSuccessSpy) 262 | 263 | await engine.run().then(() => engineRunSpy()) 264 | 265 | expect(ruleSuccessSpy).to.have.been.calledOnce() 266 | expect(engineSuccessSpy).to.have.been.calledOnce() 267 | expect(ruleSuccessSpy).to.have.been.calledBefore(engineRunSpy) 268 | expect(ruleSuccessSpy).to.have.been.calledBefore(engineSuccessSpy) 269 | }) 270 | }) 271 | 272 | describe('toJSON() and fromJSON()', () => { 273 | const priority = 50 274 | const event = { 275 | type: 'to-json!', 276 | params: { id: 1 } 277 | } 278 | const conditions = { 279 | priority: 1, 280 | all: [{ 281 | value: 10, 282 | operator: 'equals', 283 | fact: 'user', 284 | params: { 285 | foo: true 286 | }, 287 | path: '$.id' 288 | }] 289 | } 290 | const name = 'testName' 291 | let rule 292 | beforeEach(() => { 293 | rule = new Rule() 294 | rule.setConditions(conditions) 295 | rule.setPriority(priority) 296 | rule.setEvent(event) 297 | rule.setName(name) 298 | }) 299 | 300 | it('serializes itself', () => { 301 | const json = rule.toJSON(false) 302 | expect(Object.keys(json).length).to.equal(4) 303 | expect(json.conditions).to.eql(conditions) 304 | expect(json.priority).to.eql(priority) 305 | expect(json.event).to.eql(event) 306 | expect(json.name).to.eql(name) 307 | }) 308 | 309 | it('serializes itself as json', () => { 310 | const jsonString = rule.toJSON() 311 | expect(jsonString).to.be.a('string') 312 | const json = JSON.parse(jsonString) 313 | expect(Object.keys(json).length).to.equal(4) 314 | expect(json.conditions).to.eql(conditions) 315 | expect(json.priority).to.eql(priority) 316 | expect(json.event).to.eql(event) 317 | expect(json.name).to.eql(name) 318 | }) 319 | 320 | it('rehydrates itself using a JSON string', () => { 321 | const jsonString = rule.toJSON() 322 | expect(jsonString).to.be.a('string') 323 | const hydratedRule = new Rule(jsonString) 324 | expect(hydratedRule.conditions).to.eql(rule.conditions) 325 | expect(hydratedRule.priority).to.eql(rule.priority) 326 | expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent) 327 | expect(hydratedRule.event).to.eql(rule.event) 328 | expect(hydratedRule.name).to.eql(rule.name) 329 | }) 330 | 331 | it('rehydrates itself using an object from JSON.parse()', () => { 332 | const jsonString = rule.toJSON() 333 | expect(jsonString).to.be.a('string') 334 | const json = JSON.parse(jsonString) 335 | const hydratedRule = new Rule(json) 336 | expect(hydratedRule.conditions).to.eql(rule.conditions) 337 | expect(hydratedRule.priority).to.eql(rule.priority) 338 | expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent) 339 | expect(hydratedRule.event).to.eql(rule.event) 340 | expect(hydratedRule.name).to.eql(rule.name) 341 | }) 342 | }) 343 | }) 344 | -------------------------------------------------------------------------------- /test/support/bootstrap.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const chai = require('chai') 4 | const sinonChai = require('sinon-chai') 5 | const chaiAsPromised = require('chai-as-promised') 6 | const dirtyChai = require('dirty-chai') 7 | chai.use(chaiAsPromised) 8 | chai.use(sinonChai) 9 | chai.use(dirtyChai) 10 | global.expect = chai.expect 11 | global.factories = { 12 | rule: require('./rule-factory'), 13 | condition: require('./condition-factory') 14 | } 15 | -------------------------------------------------------------------------------- /test/support/condition-factory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function (options) { 4 | return { 5 | fact: options.fact || null, 6 | value: options.value || null, 7 | operator: options.operator || 'equal' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/support/example_runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # npm links the examples directory to the local copy of json-rules-engine 5 | # and runs all examples. This can be used to test a release candidate version 6 | # against the examples as an extra compatiblity test 7 | # 8 | THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 9 | 10 | cd $THIS_DIR/../.. # project root 11 | npm run build 12 | npm link 13 | cd $THIS_DIR/../../examples # examples directory 14 | npm link json-rules-engine 15 | for i in *.js; do node $i; done; 16 | -------------------------------------------------------------------------------- /test/support/rule-factory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = (options) => { 4 | options = options || {} 5 | return { 6 | name: options.name, 7 | priority: options.priority || 1, 8 | conditions: options.conditions || { 9 | all: [{ 10 | fact: 'age', 11 | operator: 'lessThan', 12 | value: 45 13 | }, 14 | { 15 | fact: 'pointBalance', 16 | operator: 'greaterThanInclusive', 17 | value: 1000 18 | }] 19 | }, 20 | event: options.event || { 21 | type: 'pointCapReached', 22 | params: { 23 | currency: 'points', 24 | pointCap: 1000 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface AlmanacOptions { 2 | allowUndefinedFacts?: boolean; 3 | pathResolver?: PathResolver; 4 | } 5 | 6 | export interface EngineOptions extends AlmanacOptions { 7 | allowUndefinedConditions?: boolean; 8 | replaceFactsInEventParams?: boolean; 9 | } 10 | 11 | export interface RunOptions { 12 | almanac?: Almanac; 13 | } 14 | 15 | export interface EngineResult { 16 | events: Event[]; 17 | failureEvents: Event[]; 18 | almanac: Almanac; 19 | results: RuleResult[]; 20 | failureResults: RuleResult[]; 21 | } 22 | 23 | export default function engineFactory( 24 | rules: Array, 25 | options?: EngineOptions 26 | ): Engine; 27 | 28 | export class Engine { 29 | constructor(rules?: Array, options?: EngineOptions); 30 | 31 | addRule(rule: RuleProperties): this; 32 | removeRule(ruleOrName: Rule | string): boolean; 33 | updateRule(rule: Rule): void; 34 | 35 | setCondition(name: string, conditions: TopLevelCondition): this; 36 | removeCondition(name: string): boolean; 37 | 38 | addOperator(operator: Operator): void; 39 | addOperator( 40 | operatorName: string, 41 | callback: OperatorEvaluator 42 | ): void; 43 | removeOperator(operator: Operator | string): boolean; 44 | 45 | addOperatorDecorator(decorator: OperatorDecorator): void; 46 | addOperatorDecorator(decoratorName: string, callback: OperatorDecoratorEvaluator): void; 47 | removeOperatorDecorator(decorator: OperatorDecorator | string): boolean; 48 | 49 | addFact(fact: Fact): this; 50 | addFact( 51 | id: string, 52 | valueCallback: DynamicFactCallback | T, 53 | options?: FactOptions 54 | ): this; 55 | removeFact(factOrId: string | Fact): boolean; 56 | getFact(factId: string): Fact; 57 | 58 | on(eventName: string, handler: EventHandler): this; 59 | 60 | run(facts?: Record, runOptions?: RunOptions): Promise; 61 | stop(): this; 62 | } 63 | 64 | export interface OperatorEvaluator { 65 | (factValue: A, compareToValue: B): boolean; 66 | } 67 | 68 | export class Operator { 69 | public name: string; 70 | constructor( 71 | name: string, 72 | evaluator: OperatorEvaluator, 73 | validator?: (factValue: A) => boolean 74 | ); 75 | } 76 | 77 | export interface OperatorDecoratorEvaluator { 78 | (factValue: A, compareToValue: B, next: OperatorEvaluator): boolean 79 | } 80 | 81 | export class OperatorDecorator { 82 | public name: string; 83 | constructor( 84 | name: string, 85 | evaluator: OperatorDecoratorEvaluator, 86 | validator?: (factValue: A) => boolean 87 | ) 88 | } 89 | 90 | export class Almanac { 91 | constructor(options?: AlmanacOptions); 92 | factValue( 93 | factId: string, 94 | params?: Record, 95 | path?: string 96 | ): Promise; 97 | addFact(fact: Fact): this; 98 | addFact( 99 | id: string, 100 | valueCallback: DynamicFactCallback | T, 101 | options?: FactOptions 102 | ): this; 103 | addRuntimeFact(factId: string, value: any): void; 104 | } 105 | 106 | export type FactOptions = { 107 | cache?: boolean; 108 | priority?: number; 109 | }; 110 | 111 | export type DynamicFactCallback = ( 112 | params: Record, 113 | almanac: Almanac 114 | ) => T; 115 | 116 | export class Fact { 117 | id: string; 118 | priority: number; 119 | options: FactOptions; 120 | value?: T; 121 | calculationMethod?: DynamicFactCallback; 122 | 123 | constructor( 124 | id: string, 125 | value: T | DynamicFactCallback, 126 | options?: FactOptions 127 | ); 128 | } 129 | 130 | export interface Event { 131 | type: string; 132 | params?: Record; 133 | } 134 | 135 | export type PathResolver = (value: object, path: string) => any; 136 | 137 | export type EventHandler = ( 138 | event: T, 139 | almanac: Almanac, 140 | ruleResult: RuleResult 141 | ) => void; 142 | 143 | export interface RuleProperties { 144 | conditions: TopLevelCondition; 145 | event: Event; 146 | name?: string; 147 | priority?: number; 148 | onSuccess?: EventHandler; 149 | onFailure?: EventHandler; 150 | } 151 | export type RuleSerializable = Pick< 152 | Required, 153 | "conditions" | "event" | "name" | "priority" 154 | >; 155 | 156 | export type RuleResultSerializable = Pick< 157 | Required, 158 | "name" | "event" | "priority" | "result"> & { 159 | conditions: TopLevelConditionResultSerializable 160 | } 161 | 162 | export interface RuleResult { 163 | name: string; 164 | conditions: TopLevelConditionResult; 165 | event?: Event; 166 | priority?: number; 167 | result: any; 168 | toJSON(): string; 169 | toJSON( 170 | stringify: T 171 | ): T extends true ? string : RuleResultSerializable; 172 | } 173 | 174 | export class Rule implements RuleProperties { 175 | constructor(ruleProps: RuleProperties | string); 176 | name: string; 177 | conditions: TopLevelCondition; 178 | /** 179 | * @deprecated Use {@link Rule.event} instead. 180 | */ 181 | ruleEvent: Event; 182 | event: Event 183 | priority: number; 184 | setConditions(conditions: TopLevelCondition): this; 185 | setEvent(event: Event): this; 186 | setPriority(priority: number): this; 187 | toJSON(): string; 188 | toJSON( 189 | stringify: T 190 | ): T extends true ? string : RuleSerializable; 191 | } 192 | 193 | interface BooleanConditionResultProperties { 194 | result?: boolean 195 | } 196 | 197 | interface ConditionResultProperties extends BooleanConditionResultProperties { 198 | factResult?: unknown 199 | valueResult?: unknown 200 | } 201 | 202 | interface ConditionProperties { 203 | fact: string; 204 | operator: string; 205 | value: { fact: string } | any; 206 | path?: string; 207 | priority?: number; 208 | params?: Record; 209 | name?: string; 210 | } 211 | 212 | type ConditionPropertiesResult = ConditionProperties & ConditionResultProperties 213 | 214 | type NestedCondition = ConditionProperties | TopLevelCondition; 215 | type NestedConditionResult = ConditionPropertiesResult | TopLevelConditionResult; 216 | type AllConditions = { 217 | all: NestedCondition[]; 218 | name?: string; 219 | priority?: number; 220 | }; 221 | type AllConditionsResult = AllConditions & { 222 | all: NestedConditionResult[] 223 | } & BooleanConditionResultProperties 224 | type AnyConditions = { 225 | any: NestedCondition[]; 226 | name?: string; 227 | priority?: number; 228 | }; 229 | type AnyConditionsResult = AnyConditions & { 230 | any: NestedConditionResult[] 231 | } & BooleanConditionResultProperties 232 | type NotConditions = { not: NestedCondition; name?: string; priority?: number }; 233 | type NotConditionsResult = NotConditions & {not: NestedConditionResult} & BooleanConditionResultProperties; 234 | type ConditionReference = { 235 | condition: string; 236 | name?: string; 237 | priority?: number; 238 | }; 239 | type ConditionReferenceResult = ConditionReference & BooleanConditionResultProperties 240 | export type TopLevelCondition = 241 | | AllConditions 242 | | AnyConditions 243 | | NotConditions 244 | | ConditionReference; 245 | export type TopLevelConditionResult = 246 | | AllConditionsResult 247 | | AnyConditionsResult 248 | | NotConditionsResult 249 | | ConditionReferenceResult 250 | export type TopLevelConditionResultSerializable = 251 | | AllConditionsResult 252 | | AnyConditionsResult 253 | | NotConditionsResult 254 | | ConditionReference 255 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from "tsd"; 2 | 3 | import rulesEngine, { 4 | Almanac, 5 | EngineResult, 6 | Engine, 7 | Event, 8 | Fact, 9 | Operator, 10 | OperatorEvaluator, 11 | OperatorDecorator, 12 | OperatorDecoratorEvaluator, 13 | PathResolver, 14 | Rule, 15 | RuleProperties, 16 | RuleResult, 17 | RuleSerializable, 18 | TopLevelConditionResult, 19 | AnyConditionsResult, 20 | AllConditionsResult, 21 | NotConditionsResult 22 | } from "../"; 23 | 24 | // setup basic fixture data 25 | const ruleProps: RuleProperties = { 26 | conditions: { 27 | all: [] 28 | }, 29 | event: { 30 | type: "message" 31 | } 32 | }; 33 | 34 | const complexRuleProps: RuleProperties = { 35 | conditions: { 36 | all: [ 37 | { 38 | any: [ 39 | { 40 | all: [] 41 | }, 42 | { 43 | fact: "foo", 44 | operator: "equal", 45 | value: "bar" 46 | } 47 | ] 48 | } 49 | ] 50 | }, 51 | event: { 52 | type: "message" 53 | } 54 | }; 55 | 56 | // path resolver 57 | const pathResolver = function(value: object, path: string): any {} 58 | expectType(pathResolver) 59 | 60 | // default export test 61 | expectType(rulesEngine([ruleProps])); 62 | const engine = rulesEngine([complexRuleProps]); 63 | 64 | // Rule tests 65 | const rule: Rule = new Rule(ruleProps); 66 | const ruleFromString: Rule = new Rule(JSON.stringify(ruleProps)); 67 | expectType(engine.addRule(rule)); 68 | expectType(engine.removeRule(ruleFromString)); 69 | expectType(engine.updateRule(ruleFromString)); 70 | 71 | expectType(rule.setConditions({ any: [] })); 72 | expectType(rule.setEvent({ type: "test" })); 73 | expectType(rule.setPriority(1)); 74 | expectType(rule.toJSON()); 75 | expectType(rule.toJSON(true)); 76 | expectType(rule.toJSON(false)); 77 | 78 | // Operator tests 79 | const operatorEvaluator: OperatorEvaluator = ( 80 | a: number, 81 | b: number 82 | ) => a === b; 83 | expectType( 84 | engine.addOperator("test", operatorEvaluator) 85 | ); 86 | const operator: Operator = new Operator( 87 | "test", 88 | operatorEvaluator, 89 | (num: number) => num > 0 90 | ); 91 | expectType(engine.addOperator(operator)); 92 | expectType(engine.removeOperator(operator)); 93 | 94 | // Operator Decorator tests 95 | const operatorDecoratorEvaluator: OperatorDecoratorEvaluator = ( 96 | a: number[], 97 | b: number, 98 | next: OperatorEvaluator 99 | ) => next(a[0], b); 100 | expectType( 101 | engine.addOperatorDecorator("first", operatorDecoratorEvaluator) 102 | ); 103 | const operatorDecorator: OperatorDecorator = new OperatorDecorator( 104 | "first", 105 | operatorDecoratorEvaluator, 106 | (a: number[]) => a.length > 0 107 | ); 108 | expectType(engine.addOperatorDecorator(operatorDecorator)); 109 | expectType(engine.removeOperatorDecorator(operatorDecorator)); 110 | 111 | // Fact tests 112 | const fact = new Fact("test-fact", 3); 113 | const dynamicFact = new Fact("test-fact", () => [42]); 114 | expectType( 115 | engine.addFact("test-fact", "value", { priority: 10 }) 116 | ); 117 | expectType(engine.addFact(fact)); 118 | expectType(engine.addFact(dynamicFact)); 119 | expectType(engine.removeFact(fact)); 120 | expectType>(engine.getFact("test")); 121 | engine.on('success', (event, almanac, ruleResult) => { 122 | expectType(event) 123 | expectType(almanac) 124 | expectType(ruleResult) 125 | }) 126 | engine.on<{ foo: Array }>('foo', (event, almanac, ruleResult) => { 127 | expectType<{ foo: Array }>(event) 128 | expectType(almanac) 129 | expectType(ruleResult) 130 | }) 131 | 132 | // Run the Engine 133 | const result = engine.run({ displayMessage: true }) 134 | expectType>(result); 135 | 136 | const topLevelConditionResult = result.then(r => r.results[0].conditions); 137 | expectType>(topLevelConditionResult) 138 | 139 | const topLevelAnyConditionsResult = topLevelConditionResult.then(r => (r as AnyConditionsResult).result); 140 | expectType>(topLevelAnyConditionsResult) 141 | 142 | const topLevelAllConditionsResult = topLevelConditionResult.then(r => (r as AllConditionsResult).result); 143 | expectType>(topLevelAllConditionsResult) 144 | 145 | const topLevelNotConditionsResult = topLevelConditionResult.then(r => (r as NotConditionsResult).result); 146 | expectType>(topLevelNotConditionsResult) 147 | 148 | // Alamanac tests 149 | const almanac: Almanac = (await engine.run()).almanac; 150 | 151 | expectType>(almanac.factValue("test-fact")); 152 | expectType(almanac.addRuntimeFact("test-fact", "some-value")); 153 | --------------------------------------------------------------------------------