├── .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 | 
2 | [](https://github.com/feross/standard)
3 | [](https://github.com/cachecontrol/json-rules-engine/workflows/Node.js%20CI/badge.svg?branch=master)
4 |
5 | [](https://badge.fury.io/js/json-rules-engine)
6 | [](https://packagephobia.now.sh/result?p=json-rules-engine)
7 | [](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 |
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 |
--------------------------------------------------------------------------------