├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── index.js ├── lib ├── Context.js ├── Engine.js ├── closure │ ├── Closure.js │ ├── ClosureReducer.js │ ├── ClosureRegistry.js │ ├── FunctionalClosure.js │ ├── Rule.js │ └── RuleFlow.js ├── common │ ├── action │ │ ├── error.js │ │ ├── identity.js │ │ ├── setResult.js │ │ └── undefined.js │ ├── condition │ │ ├── always.js │ │ ├── dateRange.js │ │ ├── default.js │ │ ├── equal.js │ │ ├── never.js │ │ └── random.js │ ├── index.js │ └── transformer │ │ ├── fixedValue.js │ │ └── get.js └── util.js ├── package.json ├── test ├── Engine.test.js ├── closure │ ├── ClosureReducer.test.js │ ├── ClosureRegistry.test.js │ ├── FunctionalClosure.test.js │ ├── Rule.test.js │ ├── RuleFlow.test.js │ ├── builtinTransformer.test.js │ └── dateRange.test.js ├── flows │ ├── async-actions.flow.json │ ├── chained-actions.flow.json │ ├── conditional-reducers.flow.json │ ├── date-range.flow.json │ ├── default-condition.flow.json │ ├── generic-set-rule.flow.json │ ├── index.js │ ├── nested-rules.flow.json │ ├── simple-rules.flow.json │ └── sugar-coated.flow.json └── parse │ ├── closure-library.flow.json │ └── parseClosureLibrary.test.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .nyc_output 3 | node_modules 4 | docs 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es6": true, 5 | "browser": false, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "rules": { 10 | "no-console": 1, 11 | "strict": [2, "global"], 12 | "quotes": [2, "double", "avoid-escape"], 13 | "indent": [1, "tab"], 14 | "no-unused-vars": [1, {"args": "none"}] 15 | }, 16 | "globals": { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.sublime* 4 | npm-debug* 5 | coverage 6 | .nyc_output 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | .DS_Store 3 | node_modules 4 | *.sublime* 5 | npm-debug* 6 | coverage 7 | .nyc_output 8 | .editorconfig 9 | .eslintignore 10 | .eslintrc 11 | .travis.yml 12 | .npmignore 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.org" 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | before_script: 6 | - npm install -g eslint 7 | after_success: 8 | - npm run coverage 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Tests", 11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 12 | "args": [ 13 | "-u", 14 | "tdd", 15 | "--timeout", 16 | "999999", 17 | "--colors", 18 | "${workspaceFolder}/test/**/*.test.js" 19 | ], 20 | "internalConsoleOptions": "openOnSessionStart" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Internet Systems Consortium license 2 | =================================== 3 | 4 | Copyright (c) 2017, Blue Alba 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose 7 | with or without fee is hereby granted, provided that the above copyright notice 8 | and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 12 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 14 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 15 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 16 | THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rules.JS 2 | 3 | [![Build Status](https://travis-ci.org/bluealba/rules-js.svg?branch=master)](https://travis-ci.org/bluealba/rules-js) 4 | [![npm](https://img.shields.io/npm/v/rules-js.svg)](https://npmjs.org/package/rules-js) 5 | [![npm](https://img.shields.io/npm/dt/rules-js.svg)](https://npmjs.org/package/rules-js) 6 | ![David](https://img.shields.io/david/bluealba/rules-js.svg) 7 | [![Coverage Status](https://coveralls.io/repos/github/bluealba/rules-js/badge.svg?branch=master)](https://coveralls.io/github/bluealba/rules-js?branch=master) 8 | 9 | ## Overview 10 | 11 | This is an implementation of a very lightweight pure javascript rule engine. 12 | It's very loosely inspired on drools, but keeping an extra effort in keeping complexity to the bare minimum. 13 | 14 | - [Rules.JS](#rulesjs) 15 | * [Overview](#overview) 16 | + [Install](#install) 17 | + [Usage Example](#usage-example) 18 | * [Engine](#engine) 19 | * [Fact](#fact) 20 | * [Closures](#closures) 21 | + [Provided closures](#provided-closures) 22 | - [Parameterizable closures](#parameterizable-closures) 23 | - [Parameterless closures (syntax sugar)](#parameterless-closures-syntax-sugar) 24 | + [Rules](#rules) 25 | + [Closure arrays (reduce)](#closure-arrays-reduce) 26 | + [Rules flow](#rules-flow) 27 | 28 | ### Install 29 | 30 | ``` 31 | npm install rules-js 32 | ``` 33 | 34 | 35 | ### Usage Example 36 | This very naive example on how to create an small rule engine to process online orders. This isn't meant to imitate a full business process, neither to shown the full potential of Rules.JS 37 | 38 | We start by defining a rules file in JSON. 39 | 40 | ```json 41 | { 42 | "name": "process-orders", 43 | "rules": [ 44 | { 45 | "when": "always", 46 | "then": "calculateTotalPrice" 47 | }, 48 | { 49 | "when": { "closure": "checkStockLocation", "location": "localDeposit" }, 50 | "then": [ 51 | { "closure": "calculateTaxes", "salesTax": 0.08 }, 52 | "createDispatchOrder" 53 | ] 54 | }, 55 | { 56 | "when": { "closure": "checkStockLocation", "location": "foreignDeposit" }, 57 | "then": [ 58 | "calculateShipping", 59 | "createDispatchOrder" 60 | ] 61 | }, 62 | { 63 | "when": { "closure": "checkStockLocation", "location": "none" }, 64 | "then": { "closure": "error", "message": "There is availability of such product"} 65 | } 66 | ] 67 | } 68 | ``` 69 | 70 | Now we can evaluate any order using such rules file. First we create the engine. We can do it inside a js module: 71 | 72 | ```javascript 73 | const engine = new Engine(); 74 | 75 | // We are intentionally missing something here: 76 | // We need first to define what the verbs 'checkStockLocation', 'calculateTaxes' 77 | // 'calculateShipping' and 'createDispatchOrder' actually mean 78 | 79 | const definitions = require("./process-orders.rules.json"); 80 | engine.add(definitions); 81 | 82 | module.exports = function (order) { 83 | return engine.process("process-orders", order); 84 | } 85 | ``` 86 | 87 | Then we just use that module to evaluate orders. Each order is a **fact** that 88 | will be provided to our rule engine. 89 | 90 | ```javascript 91 | const orderProcessorEngine = require("./order-processor-engine"); 92 | 93 | const order = { 94 | books: [ 95 | { name: "Good Omens", icbn: "0060853980", price: 12.25 } 96 | ], 97 | client: "mr-goodbuyer" 98 | }; 99 | 100 | //result is a Promise since rules might evaluate asynchronically. 101 | orderProcessorEngine(order).then(result => { 102 | const resultingDispatchOrder = result.fact; 103 | // handle the result in any way 104 | }) 105 | ``` 106 | 107 | Of course, we intentionally omit defining what this verbs mean: *calculateTotalPrice*, 108 | *checkStockLocation*, *calculateTaxes*, *calculateShippingAndHandling* and *createDispatchOrder*. 109 | Those reference to provided closures and are the place were implementor should provide 110 | their own business code. 111 | 112 | This is where we drift slightly away from the drools-like frameworks take on defining 113 | how a rule engine should actually be configured. We believe that's a good idea 114 | to define separately the implementation of the business actions that can be executed 115 | and the logic that tells us when and how they are triggered. 116 | 117 | There are two ways to do define the missing verbs, through functions or objects. Both 118 | of them are rather similar: 119 | 120 | 121 | ```javascript 122 | engine.closures.add("calculateTotalPrice", (fact, context) => { 123 | fact.totalPrice = fact.books.reduce((total, book) => total + book.price, 0); 124 | return fact; 125 | }); 126 | ``` 127 | 128 | or 129 | 130 | ```javascript 131 | class CalculateTotalPrize extends Closure { 132 | process(fact, context) { 133 | fact.totalPrice = fact.books.reduce((total, book) => total + book.price, 0); 134 | return fact; 135 | } 136 | } 137 | 138 | engine.closures.add("calculateTotalPrice", new CalculateTotalPrize()); 139 | ``` 140 | 141 | ## Engine 142 | This is the main entry point for Rules.js. The typically lifecycle of a rule engine implies three steps: 143 | 144 | 1. Instantiate the rule engine. This instance will be kept alive during the whole life of the application. 145 | 2. Configure the engine provided closures. 146 | 3. Configure the engine with high-level closures (rules, ruleflows). This is usually done by requiring a rule-flow definition file 147 | 4. Evaluate multiple facts using the engine and obtain a result for each one of them. 148 | 149 | ```javascript 150 | const Engine = require("rules-js"); 151 | 152 | // 1. instantiate 153 | const engine = new Engine(); 154 | 155 | // 2. configure 156 | engine.closures.add("calculateTotalPrice", (fact, context)) => { 157 | fact.totalPrice = fact.books.reduce((total, book) => total + book.price, 0); 158 | return fact; 159 | }); 160 | 161 | engine.closures.add("calculateTaxes", (fact, context)) => { 162 | fact.taxes = fact.totalPrice * context.parameters.salesTax; 163 | return fact; 164 | }, { required: ["salesTax"] }); 165 | 166 | const definitions = require("./process-orders.rules.json"); 167 | engine.closures.create(definitions); 168 | 169 | // 3. at some time later, evaluate facts using the engine 170 | module.exports = function (fact) { 171 | return engine.process("process-orders", fact); 172 | } 173 | 174 | ``` 175 | 176 | ## Fact 177 | A fact is an object that is feeded to the rule engine in order to produce a 178 | computational result. Any object can be a fact. 179 | 180 | ## Closures 181 | One of the core concepts of Rules.JS are closures. We defined **closure** as any 182 | computation bound to a certain context that can act over a **fact** and return a 183 | value. 184 | 185 | ### Provided closures 186 | 187 | In Rules.JS we have a mechanism to tie either a plain old javascript function or 188 | an object that extends the `Closure` class to a certain 189 | name. These are provided closures can be later referenced by any other piece of 190 | the rule engine (*rules*, *ruleFlows*) hence becoming the foundational stones of 191 | the library. 192 | 193 | ```javascript 194 | // a simple closure implementation function 195 | function (fact, context) { 196 | return fact.totalPrice * context.parameters.salesTax; 197 | } 198 | 199 | //the same thing implemented through a class 200 | class TaxCalculator extends Closure { 201 | process(fact, context) { 202 | return fact.totalPrice * context.parameters.salesTax; 203 | } 204 | } 205 | ``` 206 | 207 | 208 | Note that any closure will receive two parameters: 209 | ``` 210 | @param {Object} fact - the fact is the object that is current being evaluated by the closure. 211 | @param {Context} context - the fact's execution context 212 | @param {Object} context.parameters - the execution parameters 213 | @param {Engine} context.engine - the rule engine 214 | 215 | @return {Object} the result of the computation (this can be a Promise too!) 216 | ``` 217 | The main parameter is of course the **fact**, closures need to derive their result 218 | from each different fact that is provided. **context.parameters** hash is introduced 219 | to allow the reuse of closure implementations through parameterization. 220 | 221 | Closures will often enhance the current provided fact by adding extra information 222 | to it. Of course, a closure can always alter the fact. 223 | 224 | ```javascript 225 | function (fact, context) { 226 | fact.taxes = fact.totalPrice * 0.8; 227 | return fact; 228 | } 229 | ``` 230 | 231 | *Note*: It's a good idea to keep closures stateless and idempotent, however this 232 | is not a limitation imposed by Rules.JS 233 | 234 | We can register provided closures into a rule engine by invoking the following: 235 | 236 | ```javascript 237 | engine.closures.add("calculateTaxes", (fact, context) => { 238 | fact.taxes = fact.totalPrice * 0.08 239 | return fact; 240 | }); 241 | ``` 242 | 243 | Notice that in a simplest form the `add` method receive the name 244 | that we want the closure to have and the closure implementation function. 245 | 246 | We can later reference to any provided closure (actually, any *named* closure) 247 | in the JSON rule file through a json object like the following: 248 | 249 | ```json 250 | { "closure": "calculateTaxes" } 251 | ``` 252 | 253 | #### Parameterizable closures 254 | 255 | We can add parameters to our closures implementation, that way the same closure 256 | code can be reused in different contexts. We can change the former `calculateTaxes` 257 | to receive the tax percentage. 258 | 259 | ```javascript 260 | engine.closures.add("calculateTaxes", (fact, context) => { 261 | fact.taxes = fact.totalPrice * context.parameters.salesTax; 262 | return fact; 263 | }, { required: ["salesTax"] }); 264 | ``` 265 | 266 | Now every time that a closure is referenced in a rules file we will need to provide 267 | a value for the `salesTax` parameter (otherwise we will get an error while parsing 268 | it!). 269 | 270 | ```json 271 | { "closure": "calculateTaxes", "salesTax": 0.08 } 272 | ``` 273 | 274 | #### Parameterless closures (syntax sugar) 275 | When using closures that don't receive any parameters we can, instead of writing 276 | the whole closure object `{ "closure": calculateShipping" }` we can simply 277 | reference it by its name: `"calculateShipping"`. 278 | 279 | ### Rules 280 | Rules an special kind of closures that are composed by two component closures (of 281 | any kind!). One of the closures will act as a condition (the *when*), conditionating 282 | the execution of the second closure (the *then*) to the result of its evaluation. 283 | 284 | ```json 285 | { 286 | "when": { "closure": "hasStockLocally" }, 287 | "then": { "closure": "calculateTaxes", "salesTax": 0.08 } 288 | } 289 | ``` 290 | 291 | ... which is the same than writing ... 292 | 293 | ```json 294 | { 295 | "when": "hasStockLocally", 296 | "then": { "closure": "calculateTaxes", "salesTax": 0.08 } 297 | } 298 | ``` 299 | 300 | ### Closure arrays (reduce) 301 | Closures can also be expressed as an array of closures. When evaluating an 302 | array of closures Rule.JS will perform a reduction, meaning that the resulting 303 | object of each closure will become the fact of the next one. 304 | 305 | ```json 306 | [ 307 | { "closure": "calculateTaxes", "salesTax": 0.08 }, 308 | { "closure": "makeCreditCardCharge" }, 309 | { "closure": "createDispatchOrder" } 310 | ] 311 | ``` 312 | 313 | You can also mix syntaxes inside the array 314 | 315 | ```json 316 | [ 317 | { "closure": "calculateTaxes", "salesTax": 0.08 }, 318 | "makeCreditCardCharge", 319 | "createDispatchOrder" 320 | ] 321 | ``` 322 | 323 | Or even using completely different types of closures (i.e. regular provided closures, 324 | rules, nested arrays of closures) 325 | 326 | ```json 327 | [ 328 | { 329 | "when": "isTaxAccountable", 330 | "then": { "closure": "calculateTaxes", "salesTax": 0.08 } 331 | }, 332 | "makeCreditCardCharge", 333 | "createDispatchOrder" 334 | ] 335 | ``` 336 | 337 | #### Arrays as conditions 338 | 339 | You can also use closure arrays as conditions. By default they will work with "and" (`&&`) logic 340 | 341 | ```json 342 | { 343 | "when": ["isFoo", "isBar"], 344 | "then": "executeOrder66" 345 | }, 346 | ``` 347 | 348 | You can also define an "and" or "or" strategies to apply them. 349 | 350 | ```json 351 | { 352 | "when": ["isFoo", "isBar"], 353 | "conditionStrategy": "or", 354 | "then": "executeOrder66" 355 | }, 356 | ``` 357 | 358 | There is also a "last" strategy, which makes it work like a regular reducer closure array. 359 | 360 | ```json 361 | { 362 | "when": ["transformForFoo", "isFoo"], 363 | "conditionStrategy": "last", 364 | "then": "executeOrder66" 365 | }, 366 | ``` 367 | 368 | ### Rules flow 369 | A rule flow is a definition of a chain of rules that will be evaluated (and applied) 370 | in order. Typically this is the higher order construction that is registered into rules js. 371 | 372 | ```json 373 | { 374 | "name": "process-orders", 375 | "rules": [ 376 | { 377 | "when": "always", 378 | "then": "calculateTotalPrice" 379 | }, 380 | { 381 | "when": { "closure": "checkStockLocation", "location": "localDeposit" }, 382 | "then": [ 383 | { "closure": "calculateTaxes", "salesTax": 0.08 }, 384 | "createDispatchOrder" 385 | ] 386 | }, 387 | { 388 | "when": { "closure": "checkStockLocation", "location": "foreignDeposit" }, 389 | "then": [ 390 | "calculateShipping", 391 | "createDispatchOrder" 392 | ] 393 | }, 394 | { 395 | "when": { "closure": "checkStockLocation", "location": "none" }, 396 | "then": { "closure": "error", "message": "There is availability of such product"} 397 | } 398 | ] 399 | } 400 | ``` 401 | 402 | 403 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = require("./lib/Engine"); 4 | -------------------------------------------------------------------------------- /lib/Context.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = class Context { 4 | 5 | constructor(engine, parameters, rulesFired, currentRuleFlowActivated) { 6 | this.engine = engine; 7 | this.parameters = parameters || {}; 8 | this.rulesFired = rulesFired || []; 9 | this._currentRuleFlowActivated = !! currentRuleFlowActivated; 10 | } 11 | 12 | initiateFlow(ruleFlow) { 13 | this._currentRuleFlowActivated = false; 14 | } 15 | 16 | endFlow() { 17 | this._currentRuleFlowActivated = true; 18 | } 19 | 20 | get currentRuleFlowActivated() { 21 | return this._currentRuleFlowActivated 22 | } 23 | 24 | ruleFired(rule) { 25 | this.rulesFired.push(rule); 26 | this._currentRuleFlowActivated = true; 27 | } 28 | 29 | /** 30 | * Creates a new context bound to the new set of parameters 31 | * @param {Object} newParameters 32 | */ 33 | bindParameters(newParameters) { 34 | const parameters = Object.assign({}, this.parameters, newParameters); 35 | return new Context(this.engine, parameters, this.rulesFired, this._currentRuleFlowActivated); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /lib/Engine.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Context = require("./Context"), 4 | ClosureRegistry = require("./closure/ClosureRegistry"); 5 | 6 | module.exports = class Engine { 7 | 8 | constructor() { 9 | this.services = {}; 10 | this.closures = new ClosureRegistry(this); 11 | } 12 | 13 | add(definition, options) { 14 | const closureOrClosures = this.closures.parse(definition); 15 | 16 | // if I get an array, then I assume that it is an array of definitions, and add each of them 17 | if (Array.isArray(closureOrClosures)) { 18 | closureOrClosures.forEach(clos => this.closures.add(clos.name, clos, options)) 19 | } else { 20 | // non-array case 21 | this.closures.add(closureOrClosures.name, closureOrClosures, options); 22 | } 23 | } 24 | 25 | reset() { 26 | this.closures = new ClosureRegistry(this); 27 | } 28 | 29 | process(closure, fact) { 30 | if (typeof (closure) === "string") { 31 | closure = this.closures.get(closure); 32 | } 33 | 34 | const context = new Context(this); 35 | try { 36 | return Promise.resolve(closure.process(fact, context)).then(fact => { 37 | context.fact = fact; 38 | return context; 39 | }); 40 | } catch (error) { 41 | return Promise.reject(error); 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /lib/closure/Closure.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * A closure that its identified by a name. All closures expose the method 5 | * process(fact, context) that allows them to operate as predicates or 6 | * transfomers of a certain fact. 7 | * 8 | * @type {Closure} 9 | */ 10 | class Closure { 11 | 12 | constructor(name, options) { 13 | this.name = name; 14 | this.options = options || {}; 15 | } 16 | 17 | get named() { 18 | return !!this.name; 19 | } 20 | 21 | /** 22 | * Evaluates the closure against a certain fact 23 | * 24 | * @param {Object} fact a fact 25 | * @param {Context} context an execution context. 26 | * @param {Object} context.parameters the execution parameters, if any 27 | * @param {Engine} context.engine the rules engine 28 | * 29 | * @return {Object|Promise} the result or a promise of such result 30 | */ 31 | process(fact, context) { 32 | throw new Error("This is an abstract closure, how did you get to instantiate this?"); 33 | } 34 | 35 | /** 36 | * Binds this closure to a set of parameters. This will return a new Closure than 37 | * when invoked it will ALWAYS pass the given parameters as a fields inside the 38 | * context.parameters object. 39 | * 40 | * @param {String} name - the name, if specified, of the resulting bounded closure 41 | * @param {Object} parameters - the parameters to bound to the closure 42 | * @param {Engine} engine - the rules engine instance 43 | */ 44 | bind(name, parameters, engine) { 45 | // const missing = (this.options.required || []).find(required => parameters[required] === undefined) 46 | // if (missing) { 47 | // throw new Error(`Cannot instantiate provided closure '${this.name}'. Parameter ${missing} is unbounded`); 48 | // } 49 | 50 | // No need to perform any binding, there is nothing to bind 51 | if (! Object.keys(parameters).length) { 52 | return this; 53 | } 54 | 55 | // Replaces parameters that are set as closureParamters with actual closures! 56 | // TODO: do we really need this? can we do it differently? I hate expanding the options 57 | // list 58 | if (this.options.closureParameters) { 59 | this.options.closureParameters.forEach(parameter => { 60 | parameters[parameter] = engine.closures.parseOrValue(parameters[parameter]); 61 | }) 62 | } 63 | 64 | return new BoundClosure(name, this, parameters) 65 | } 66 | 67 | } 68 | 69 | Closure.closureType = true; 70 | 71 | /** 72 | * A closure bound to a certain set of parameters 73 | * 74 | * @type {BoundClosure} 75 | */ 76 | class BoundClosure extends Closure { 77 | 78 | constructor(name, closure, parameters) { 79 | super(name); 80 | this.closure = closure; 81 | this.parameters = parameters || {}; 82 | } 83 | 84 | process(fact, context) { 85 | const newContext = context.bindParameters(this.parameters); 86 | return this.closure.process(fact, newContext); 87 | } 88 | 89 | } 90 | 91 | module.exports = Closure; 92 | -------------------------------------------------------------------------------- /lib/closure/ClosureReducer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Closure = require("./Closure"), 4 | util = require("../util"); 5 | 6 | // Strategies define how we merge that every fact returns. 7 | 8 | const reduceStrategies = { 9 | and: (prev, next) => prev && next, 10 | or: (prev, next) => prev || next, 11 | // This strategy requires the previous fact, while the others require the original to process conditions 12 | last: (_, next) => next, 13 | }; 14 | 15 | /** 16 | * This is a closure composite that will reduce the fact execution through 17 | * a list of component closures. The result of each closure execution will 18 | * be used as fact for the next closure. 19 | * 20 | * @type {ClosureReducer} 21 | */ 22 | module.exports = class ClosureReducer extends Closure { 23 | constructor(name, closures, options) { 24 | super(name, options); 25 | this.closures = closures || util.raise(`Cannot build closure reducer [${name}] without closure chain`); 26 | } 27 | 28 | process(fact, context) { 29 | return this.reduce(0, fact, context); 30 | } 31 | 32 | reduce(index, fact, context) { 33 | if (this.closures.length <= index) { 34 | return fact; 35 | } 36 | 37 | return util.nowOrThen(this.closures[index].process(fact, context), newFact => { 38 | if (this.options.matchOnce && context.currentRuleFlowActivated) { 39 | return newFact; 40 | } 41 | if (this.options.strategy && this.options.strategy !== "last") { 42 | const reduceStrategy = reduceStrategies[this.options.strategy]; 43 | return this.closures.length <= index + 1 44 | ? newFact 45 | : reduceStrategy(newFact, this.reduce(index + 1, fact, context)); 46 | } 47 | return reduceStrategies.last(newFact, this.reduce(index + 1, newFact, context)); 48 | }); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /lib/closure/ClosureRegistry.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const FunctionalClosure = require("./FunctionalClosure"), 4 | ClosureReducer = require("./ClosureReducer"), 5 | RuleFlow = require("./RuleFlow"), 6 | Rule = require("./Rule"), 7 | util = require("../util"); 8 | 9 | /** 10 | * ClosureFactory is the main entry point for creating closure out of the 11 | * closure definition json. 12 | * 13 | * It also acts as a registry for named-closure implementations which need to 14 | * be provided before parsing any named closure. 15 | * 16 | * @type {ClosureRegistry} 17 | */ 18 | module.exports = class ClosureRegistry { 19 | constructor(engine) { 20 | this.engine = engine; 21 | this.namedClosures = {}; 22 | } 23 | /** 24 | * Adds a closure to the registry by name hence becoming a 25 | * NAMED closure. 26 | * 27 | * @param {String} name the name of the closure. 28 | * @param {Closure|Function} closure the Closure object or function implementation. 29 | * @param {Object} options closure registering options. 30 | * @param {Boolean} options.override this method will fail if a closure with 31 | * the same name alrady exists, unless override is set to truthy. 32 | * 33 | * @return {Closure} the closure added 34 | */ 35 | add(name, closure, options = {}) { 36 | name || util.raise("Cannot add anonymous closure"); 37 | 38 | if (closure.closureType) { 39 | options.required = options.required || closure.required; 40 | options.closureParameters = options.closureParameters || closure.closureParameters; 41 | closure = new closure(name, options); 42 | } 43 | if (typeof closure === "function") { 44 | closure = new FunctionalClosure(name, closure, options); 45 | } 46 | 47 | if (this.namedClosures[name] && !options.override) { 48 | throw new Error(`Already defined a closure with name '${name}'`); 49 | } 50 | this.namedClosures[name] = closure; 51 | return closure; 52 | } 53 | 54 | get(name) { 55 | const closure = this.namedClosures[name]; 56 | if (!closure) { 57 | throw new Error(`Unexistent named closure [${name}]`); 58 | } 59 | return closure; 60 | } 61 | 62 | /** 63 | * Creates a closure from its definition. 64 | * 65 | * If definition parameter is: 66 | * - an array then a ClosureReducer will be created and each item in the array 67 | * will be parsed as a closure. 68 | * - an object with the property `rules` then it's interpreted as a rule flow 69 | * (an special case of a ClosureReducer) 70 | * - an object has either `when` or `then` properties it is assumed to be a Rule 71 | * and it is created parsing both `when` and `then` definition as closures. 72 | * 73 | * - if it is a string a parameterless implementation for it will be looked 74 | * up in the implementations registry. 75 | * - if it is an object it will an implementation for `definition.closure` 76 | * will be looked up in the implementation registry. 77 | * 78 | * @param {Object|string|Object[]} definition the json defintion for the closure 79 | * @return {Object} a closure object (it will understand the 80 | * message process) 81 | */ 82 | parse(definition, options) { 83 | if (Array.isArray(definition)) { 84 | return this._createReducer(definition, options); //closure reducer for arrays 85 | } else if (definition.rules) { 86 | return this._createRuleFlow(definition); 87 | } else if (definition.when || definition.then) { 88 | return this._createRule(definition); 89 | } else if (definition.closureLibrary) { 90 | return this._createClosureLibrary(definition); 91 | } else { 92 | return this._createNamedClosure(definition); 93 | } 94 | } 95 | 96 | parseOrValue(definition) { 97 | // if it is exactly undefined: do nothing 98 | if (definition === undefined) { 99 | return definition 100 | } 101 | 102 | // rule out the "value" case: it is a falsy value a number, an Array, or a String which is not the name of a registered closure 103 | // in such cases, I return in fact a fixedValue closure for the given value 104 | if (!definition || (typeof definition === "number") || Array.isArray(definition) 105 | || ((typeof definition === "string") && !this.namedClosures[definition]) 106 | ) { 107 | return this.namedClosures["fixedValue"].bind(null, {value: definition}, null) // no engine needed 108 | } 109 | 110 | // it is a true definition 111 | return this.parse(definition) 112 | } 113 | 114 | _createReducer(definition, options) { 115 | const closures = definition.map(eachDefinition => this.parse(eachDefinition)); 116 | return new ClosureReducer(definition.name, closures, options); 117 | } 118 | 119 | _createRule(definition) { 120 | if (!definition.when) throw new Error(`Rule '${definition.name}' must define a valid when clause`); 121 | if (!definition.then) throw new Error(`Rule '${definition.name}' must define a valid then clause`); 122 | 123 | const condition = this.parse(definition.when, { strategy: definition.conditionStrategy || "and" }); 124 | const action = this.parse(definition.then); 125 | return new Rule(definition.name, condition, action); 126 | } 127 | 128 | _createRuleFlow(definition) { 129 | const closures = definition.rules.map(eachDefinition => this.parse(eachDefinition)); 130 | return new RuleFlow(definition.name, closures, { matchOnce: definition.matchOnce }); 131 | } 132 | 133 | _createClosureLibrary(definition) { 134 | return definition.closureLibrary.map(closureDefinition => this._createNamedClosure(closureDefinition)); 135 | } 136 | 137 | _createNamedClosure(definition) { 138 | definition = typeof definition === "string" ? { closure: definition } : definition; 139 | const closure = this.get(definition.closure); 140 | 141 | const parameters = Object.assign({}, definition); 142 | delete parameters.closure; 143 | 144 | return closure.bind(definition.name, parameters, this.engine); 145 | } 146 | }; 147 | -------------------------------------------------------------------------------- /lib/closure/FunctionalClosure.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Closure = require("./Closure"); 4 | 5 | /** 6 | * A simple closure that's implemented through a function that is defined 7 | * in beforehand. 8 | * 9 | * @type {ProvidedClosure} 10 | */ 11 | class FunctionalClosure extends Closure { 12 | 13 | /** 14 | * @param {string} name the name of the clousre 15 | * @param {Function} fn a function providing an implementation 16 | * for this closure. 17 | */ 18 | constructor(name, fn, options) { 19 | super(name, options); 20 | if (typeof(fn) !== "function") { 21 | throw new TypeError(`Implementation for provided closure '${name}' is not a function`); 22 | } 23 | this.fn = fn; 24 | } 25 | 26 | /** 27 | * Evaluates the block against a fact promise 28 | * @param {Object} fact a fact 29 | * @param {Context} context an execution context. 30 | * @param {Context} context.engine the rules engine 31 | * 32 | * @return {Object|Promise} a promise that will be resolved to some result 33 | */ 34 | process(fact, context) { 35 | return this.fn.call(this, fact, context); 36 | } 37 | 38 | } 39 | 40 | module.exports = FunctionalClosure; 41 | -------------------------------------------------------------------------------- /lib/closure/Rule.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Closure = require("./Closure"), 4 | util = require("../util"); 5 | 6 | /** 7 | * A rule is a named conditional closure. It understands the `process` message 8 | * but it will only fire its internal closure action if the provided fact matches 9 | * the associated conditional closure. 10 | * 11 | * @type {Rule} 12 | */ 13 | module.exports = class Rule extends Closure{ 14 | 15 | constructor(name, condition, action) { 16 | super(name); 17 | this.condition = condition || util.raise(`Cannot build rule [${name}] without condition closure`); 18 | this.action = action || util.raise(`Cannot build rule [${name}] without action closure`); 19 | } 20 | 21 | /** 22 | * Executes the actions associated with this rule over certain fact 23 | * @param {Object} fact a fact 24 | * @param {Context} context an execution context 25 | * @param {Context} context.engine the rules engine 26 | * 27 | * @return {Object|Promise} a promise that will be resolved to some result (typically 28 | * such result will be used as next's rule fact) 29 | */ 30 | process(fact, context) { 31 | return util.nowOrThen(this.evaluateCondition(fact, context), matches => { 32 | if (matches) { 33 | context.ruleFired(this); 34 | return this.action.process(fact, context); 35 | } 36 | return fact; 37 | }); 38 | } 39 | 40 | /** 41 | * Evaluates a condition 42 | * @param {Promise} fact a fact 43 | * @param {Context} engine an execution context 44 | * @return {Promise} a Promise that will be resolved to a truthy/falsey 45 | */ 46 | evaluateCondition(fact, context) { 47 | return this.condition.process(fact, context); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /lib/closure/RuleFlow.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ClosureReducer = require("./ClosureReducer"), 4 | util = require("../util") 5 | 6 | module.exports = class RuleFlow extends ClosureReducer { 7 | 8 | process(fact, context) { 9 | context.initiateFlow(); 10 | return util.nowOrThen(super.process(fact, context), fact => { 11 | context.endFlow(); 12 | return fact; 13 | }); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /lib/common/action/error.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | engine.closures.add("error", (fact, {parameters}) => { throw new Error(parameters.message) }, { 5 | required: ["message"] 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /lib/common/action/identity.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | engine.closures.add("identity", fact => fact); 5 | } 6 | -------------------------------------------------------------------------------- /lib/common/action/setResult.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const util = require("../../util") 4 | 5 | module.exports = engine => { 6 | const fn = (fact, context) => { 7 | const result = context.parameters.calculator.process(fact, context); 8 | return util.nowOrThen(result, value => { 9 | fact[context.parameters.field] = value; 10 | return fact; 11 | }); 12 | } 13 | 14 | engine.closures.add("setResult", fn, { 15 | required: ["field", "calculator"], 16 | closureParameters: [ "calculator" ] 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /lib/common/action/undefined.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | engine.closures.add("undefined", fact => undefined); 5 | } 6 | -------------------------------------------------------------------------------- /lib/common/condition/always.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | engine.closures.add("always", () => true); 5 | } 6 | -------------------------------------------------------------------------------- /lib/common/condition/dateRange.js: -------------------------------------------------------------------------------- 1 | 2 | "use strict"; 3 | 4 | const Closure = require("../../closure/Closure") 5 | const moment = require("moment") 6 | 7 | 8 | class DateRange extends Closure { 9 | process(fact, context) { 10 | const dateInFact = this.getDateInFact(fact, context) 11 | const dateFrom = this.getDateFromParam("dateFrom", fact, context) 12 | const dateTo = this.getDateFromParam("dateTo", fact, context) 13 | const dateBefore = this.getDateFromParam("dateBefore", fact, context) 14 | const dateAfter = this.getDateFromParam("dateAfter", fact, context) 15 | if (!dateInFact) { return false } 16 | return (!dateFrom || dateInFact.isSameOrAfter(dateFrom)) 17 | && (!dateTo || dateInFact.isSameOrBefore(dateTo)) 18 | && (!dateBefore || dateInFact.isBefore(dateBefore)) 19 | && (!dateAfter || dateInFact.isAfter(dateAfter)) 20 | } 21 | 22 | getDateInFact(fact, context) { 23 | const extractor = context.parameters.dateExtractor 24 | const extractedValue = extractor.process(fact, context) 25 | if (extractedValue) { 26 | const extractedMoment = typeof(extractedValue) === "string" ? moment(extractedValue, "YYYY-MM-DD") : moment(extractedValue) 27 | return extractedMoment 28 | } else { 29 | return null 30 | } 31 | } 32 | 33 | getDateFromParam(paramName, fact, context) { 34 | const dateSource = context.parameters[paramName] 35 | const dateAsString = dateSource ? dateSource.process(fact, context) : null 36 | return dateAsString ? moment(dateAsString, "YYYY-MM-DD") : null 37 | } 38 | 39 | } 40 | 41 | DateRange.required = [ "dateExtractor" ]; 42 | DateRange.closureParameters = [ "dateExtractor", "dateFrom", "dateTo", "dateBefore", "dateAfter" ]; 43 | 44 | 45 | module.exports = engine => { 46 | engine.closures.add("dateRange", DateRange); 47 | } 48 | 49 | -------------------------------------------------------------------------------- /lib/common/condition/default.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | //Executes only if NO other rule has been executed for this flow 5 | engine.closures.add("default", (fact, context) => ! context.currentRuleFlowActivated ); 6 | } 7 | -------------------------------------------------------------------------------- /lib/common/condition/equal.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | //Matches only if certain fact field (specified by field) equals to some 5 | //other specified value 6 | const fn = (fact, context) => { 7 | const factValue = context.parameters.field ? fact[context.parameters.field] : fact; 8 | return factValue === context.parameters.value 9 | }; 10 | 11 | engine.closures.add("equal", fn, { 12 | required: ["value"], 13 | optionalParameters: ["field"] 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /lib/common/condition/never.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | engine.closures.add("never", () => false); 5 | } 6 | -------------------------------------------------------------------------------- /lib/common/condition/random.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | engine.closures.add("random", () => Math.random() >= 0.5); 5 | } 6 | -------------------------------------------------------------------------------- /lib/common/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"), 4 | path = require("path"); 5 | 6 | const commons = [...scanModules("action"), ...scanModules("condition"), ...scanModules("transformer")]; 7 | 8 | function scanModules(folder) { 9 | const folderPath = path.join(__dirname, folder); 10 | return fs.readdirSync(folderPath).map(file => require(path.join(folderPath, file))); 11 | } 12 | 13 | module.exports = engine => { 14 | commons.forEach(registrant => registrant(engine)); 15 | } 16 | -------------------------------------------------------------------------------- /lib/common/transformer/fixedValue.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | const fn = (fact, context) => { return context.parameters.value } 5 | 6 | engine.closures.add("fixedValue", fn, { required: ["value"] }); 7 | } 8 | -------------------------------------------------------------------------------- /lib/common/transformer/get.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = engine => { 4 | const fn = (fact, context) => { return fact[context.parameters.prop] } 5 | 6 | engine.closures.add("get", fn, { required: ["prop"] }); 7 | } 8 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | 5 | raise(message) { 6 | throw new Error(message); 7 | }, 8 | 9 | nowOrThen(p, block) { 10 | if (p && p.then) { 11 | return p.then(block); 12 | } else { 13 | return block(p) 14 | } 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rules-js", 3 | "version": "1.0.0", 4 | "description": "A simple rule-engine for javascript", 5 | "main": "index.js", 6 | "author": "Claudio Fernandez ", 7 | "license": "ISC", 8 | "scripts": { 9 | "test": "eslint '.' && jest", 10 | "lint": "eslint '.'", 11 | "coverage": "jest --coverage && cat ./tests/coverage/lcov.info | coveralls" 12 | }, 13 | "dependencies": { 14 | "moment": "~2.24.0" 15 | }, 16 | "devDependencies": { 17 | "chai": "^4.1.1", 18 | "chai-as-promised": "^7.1.1", 19 | "chai-things": "^0.2.0", 20 | "coveralls": "^3.0.9", 21 | "eslint": "^6.7.2", 22 | "jest": "^24.9.0", 23 | "sinon": "^7.5.0", 24 | "sinon-chai": "^3.3.0" 25 | }, 26 | "jest": { 27 | "collectCoverage": true 28 | }, 29 | "repository": { 30 | "closure": "git", 31 | "url": "https://github.com/bluealba/rules-js" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/Engine.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const chai = require("chai"), 4 | engine = require("./flows"), 5 | chaiPromised = require("chai-as-promised"); 6 | 7 | chai.should(); 8 | chai.use(chaiPromised); 9 | 10 | function createRuleFlow(name) { 11 | engine.add(require(`./flows/${name}.flow.json`), { override: true }); 12 | } 13 | 14 | describe("Engine", () => { 15 | 16 | beforeEach(() => { 17 | engine.services.securityMasterService = { 18 | fetch(securityId) { 19 | return Promise.resolve({ id: securityId, contractSize: 2}) 20 | } 21 | } 22 | }); 23 | 24 | describe("rules that matches a certain condition should be executed", () => { 25 | beforeEach(() => { 26 | createRuleFlow("simple-rules"); 27 | }); 28 | 29 | it("should execute the rules that matches condition A (equities)", () => { 30 | const result = engine.process("simple-rules", { productType: "Equity", price: 20, quantity: 5 }); 31 | return result.should.eventually.have.property("fact").that.has.property("commissions", 1); 32 | }); 33 | 34 | it("should execute the rules that matches condition B (options)", () => { 35 | const result = engine.process("simple-rules", { productType: "Option", price: 20, quantity: 5 }); 36 | return result.should.eventually.have.property("fact").that.has.property("commissions", 1.25); 37 | }); 38 | }); 39 | 40 | describe("multiple actions can be defined for a single rules", () => { 41 | beforeEach(() => { 42 | createRuleFlow("chained-actions"); 43 | }); 44 | 45 | it("should execute all the actions for the rule, in order", () => { 46 | const result = engine.process("chained-actions", { productType: "Equity", price: 25, contracts: 5, security: { contractSize: 20 } }); 47 | return result.should.eventually.have.property("fact").that.has.property("commissions", 25); 48 | }); 49 | }); 50 | 51 | describe("async actions should be resolved before moving forward with evaluation", () => { 52 | beforeEach(() => { 53 | createRuleFlow("async-actions"); 54 | }); 55 | 56 | it("should execute all the actions for the rule, in order", () => { 57 | const result = engine.process("async-actions", { productType: "Equity", price: 25, contracts: 5, security: "IBM" }); 58 | return result.should.eventually.have.property("fact").that.has.property("commissions", 2.5); 59 | }); 60 | }); 61 | 62 | describe("nested rule flow can be defined as action of a single rule", () => { 63 | beforeEach(() => { 64 | createRuleFlow("nested-rules"); 65 | }); 66 | 67 | it("should execute the rules that matches condition B.A (call options)", () => { 68 | const result = engine.process("nested-rules", { productType: "Option", price: 20, quantity: 5, optionType: "Call" }); 69 | return result.should.eventually.have.property("fact").that.has.property("commissions", 1.1); 70 | }); 71 | 72 | it("should execute the rules that matches condition B.B (put options)", () => { 73 | const result = engine.process("nested-rules", { productType: "Option", price: 20, quantity: 5, optionType: "Put" }); 74 | return result.should.eventually.have.property("fact").that.has.property("commissions", 0.9); 75 | }); 76 | }); 77 | 78 | describe("default should be local to nested flow", () => { 79 | beforeEach(() => { 80 | createRuleFlow("default-condition"); 81 | }); 82 | 83 | it("should execute the rules that matches condition B.A (call options)", () => { 84 | const result = engine.process("default-condition", { productType: "Option", price: 20, quantity: 5, optionType: "Call" }); 85 | return result.should.eventually.have.property("fact").that.has.property("commissions", 1.1); 86 | }); 87 | 88 | it("should execute the default rule", () => { 89 | const result = engine.process("default-condition", { productType: "Option", price: 20, quantity: 5, optionType: "Other" }); 90 | return result.should.be.rejectedWith(Error, "Unrecognized optionType"); 91 | }); 92 | }); 93 | 94 | describe("a rule can have parameters that are other closures!", () => { 95 | beforeEach(() => { 96 | createRuleFlow("generic-set-rule"); 97 | }); 98 | 99 | it("inner closures are executed properly by rules", () => { 100 | const result = engine.process("generic-set-rule", { productType: "Option", price: 20, quantity: 5 }); 101 | return result.should.eventually.have.property("fact").that.has.property("commissions", 1.25); 102 | }); 103 | }); 104 | 105 | describe("a parameterless closure can be defined as a string", () => { 106 | beforeEach(() => { 107 | createRuleFlow("sugar-coated"); 108 | }); 109 | 110 | it("inner closures are executed properly by rules", () => { 111 | const result = engine.process("sugar-coated", { productType: "Equity", price: 25, contracts: 5, security: "IBM" }); 112 | return result.should.eventually.have.property("fact").that.has.property("commissions", 2.5); 113 | }); 114 | }); 115 | 116 | describe("date range defined in a JSON file filters as expected", () => { 117 | beforeEach(() => { 118 | createRuleFlow("date-range"); 119 | }); 120 | 121 | it("fact with date not matched for any rule is not affected", () => { 122 | const result = engine.process("date-range-rules", { saleDate: "2018-05-21", price: 10 }); 123 | return result.should.eventually.have.property("fact").that.has.property("price").equal(10); 124 | }); 125 | 126 | it("fact with date that matches a range is affected - 1", () => { 127 | const resultSeptember = engine.process("date-range-rules", { saleDate: "2018-09-21", price: 10 }); 128 | return resultSeptember.should.eventually.have.property("fact").that.has.property("price").equal(12) 129 | }); 130 | 131 | it("fact with date that matches a range is affected - 2", () => { 132 | const resultOctober = engine.process("date-range-rules", { saleDate: "2018-10-21", price: 10 }); 133 | return resultOctober.should.eventually.have.property("fact").that.has.property("price").equal(13) 134 | }); 135 | 136 | it("fact with date that matches a from-only-date range is affected", () => { 137 | const result2019 = engine.process("date-range-rules", { saleDate: "2019-03-21", price: 10 }); 138 | return result2019.should.eventually.have.property("fact").that.has.property("price").equal(14) 139 | }); 140 | }); 141 | 142 | describe("multiple conditions are applied via a strategy", () => { 143 | beforeEach(() => { 144 | createRuleFlow("conditional-reducers"); 145 | }); 146 | 147 | it("should use 'and' strategy as default", () => { 148 | const result1 = engine.process("conditional-reducers", { price: 10, quantity: 5 }); 149 | const result2 = engine.process("conditional-reducers", { price: 10, quantity: 8 }); 150 | return ( 151 | result1.should.eventually.have 152 | .property("fact") 153 | .with.property("price") 154 | .equal(20) && 155 | result2.should.eventually.have 156 | .property("fact") 157 | .with.property("price") 158 | .equal(10) 159 | ); 160 | }); 161 | 162 | it("should accept 'and' as conditionalStrategy and return true if both are true", () => { 163 | const result1 = engine.process("conditional-reducers", { price: 20, quantity: 10 }); 164 | const result2 = engine.process("conditional-reducers", { price: 20, quantity: 9 }); 165 | return ( 166 | result1.should.eventually.have 167 | .property("fact") 168 | .with.property("price") 169 | .equal(120) && 170 | result2.should.eventually.have 171 | .property("fact") 172 | .with.property("price") 173 | .equal(20) 174 | ); 175 | }); 176 | 177 | it("should accept 'or' as conditionalStrategy and return true if either are true", () => { 178 | const result1 = engine.process("conditional-reducers", { price: 30 }); 179 | const result2 = engine.process("conditional-reducers", { price: 40, quantity: 1 }); 180 | return ( 181 | result1.should.eventually.have 182 | .property("fact") 183 | .with.property("price") 184 | .equal(1030) && 185 | result2.should.eventually.have 186 | .property("fact") 187 | .with.property("price") 188 | .equal(1040) 189 | ); 190 | }); 191 | }); 192 | 193 | }); 194 | -------------------------------------------------------------------------------- /test/closure/ClosureReducer.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const FunctionalClosure = require("../../lib/closure/FunctionalClosure"), 3 | ClosureReducer = require("../../lib/closure/ClosureReducer"), 4 | Context = require("../../lib/Context"), 5 | chai = require("chai"), 6 | chaiPromised = require("chai-as-promised"); 7 | 8 | chai.should(); 9 | chai.use(chaiPromised); 10 | 11 | describe("ClosureReducer", () => { 12 | let reducer; 13 | 14 | const appendFoo = new FunctionalClosure("appendFoo", fact => fact + "foo"); 15 | const appendBaz = new FunctionalClosure("appendFoo", fact => fact + "baz"); 16 | const appendZoo = new FunctionalClosure("appendZoo", fact => fact + "zoo"); 17 | const raiseError = new FunctionalClosure("appendFoo", fact => { throw new Error("expected") }); 18 | 19 | beforeEach(() => { 20 | reducer = new ClosureReducer("reducer-name", [appendFoo, appendBaz, appendZoo]); 21 | }); 22 | 23 | it("should have name", () => { 24 | reducer.name.should.equal("reducer-name"); 25 | }) 26 | 27 | it("should reduce the provided fact across the whole chain of closures", () => { 28 | const result = reducer.process("bar", context()); 29 | result.should.be.equal("bar" + "foo" + "baz" + "zoo"); 30 | }) 31 | 32 | it("should propagate the error if any closure in the chain fails", () => { 33 | reducer = new ClosureReducer("reducer-name", [appendFoo, raiseError, appendZoo]); 34 | (() => reducer.process("bar", context())).should.throw 35 | }) 36 | 37 | describe("when bound to parameters", () => { 38 | const appendSuffix = new FunctionalClosure("appendSuffix", (fact, context) => fact + context.parameters.suffix); 39 | 40 | it("parameters should be propagated to all closures in the chain", () => { 41 | reducer = new ClosureReducer("reducer-name", [appendSuffix, appendSuffix, appendZoo]); 42 | reducer = reducer.bind(null, { suffix: "foo" }); 43 | 44 | const result = reducer.process("bar", context()); 45 | result.should.be.equal("bar" + "foo" + "foo" + "zoo"); 46 | }); 47 | }); 48 | 49 | it("should fail when built without closure chain", () => { 50 | (() => new ClosureReducer("reducer-name")).should.throw(); 51 | }); 52 | 53 | }); 54 | 55 | function context() { 56 | return new Context(); 57 | } 58 | -------------------------------------------------------------------------------- /test/closure/ClosureRegistry.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const FunctionalClosure = require("../../lib/closure/FunctionalClosure"), 3 | ClosureRegistry = require("../../lib/closure/ClosureRegistry"), 4 | Closure = require("../../lib/closure/Closure"), 5 | Context = require("../../lib/Context"), 6 | chai = require("chai"), 7 | chaiPromised = require("chai-as-promised"); 8 | 9 | chai.should(); 10 | chai.use(chaiPromised); 11 | 12 | describe("ClosureRegistry", () => { 13 | let registry; 14 | 15 | beforeEach(() => { 16 | registry = new ClosureRegistry(); 17 | }) 18 | 19 | describe("add (with Function)", () => { 20 | 21 | it("should fail if an closure with the given name is already defined", () => { 22 | registry.add("true", () => false); 23 | (() => registry.add("true", () => true)).should.throw(); 24 | }); 25 | 26 | it("should replace a closure if override option is provided", () => { 27 | registry.add("true", () => false); 28 | registry.add("true", () => true, { override: true }); 29 | 30 | const result = registry.get("true").process("foo", context()); 31 | result.should.be.true; 32 | }); 33 | 34 | }); 35 | 36 | describe("add (with Closure)", () => { 37 | 38 | it("should fail if an closure with the given name is already defined", () => { 39 | registry.add("true", new FunctionalClosure("true", () => false)); 40 | (() => registry.add("true", new FunctionalClosure("true", () => true))).should.throw(); 41 | }); 42 | 43 | it("should replace a closure if override option is provided", () => { 44 | registry.add("true", new FunctionalClosure("true", () => false)); 45 | registry.add("true", new FunctionalClosure("true", () => true), { override: true }); 46 | 47 | const result = registry.get("true").process("foo", context()); 48 | result.should.be.true; 49 | }); 50 | 51 | }); 52 | 53 | describe("add (with Closure class)", () => { 54 | const TrueClosure = class extends Closure { 55 | process(fact, context) { 56 | return true; 57 | } 58 | } 59 | const FalseClosure = class extends Closure { 60 | process(fact, context) { 61 | return false; 62 | } 63 | } 64 | 65 | it("should fail if an closure with the given name is already defined", () => { 66 | registry.add("true", FalseClosure); 67 | (() => registry.add("true", TrueClosure)).should.throw(); 68 | }); 69 | 70 | it("should replace a closure if override option is provided", () => { 71 | registry.add("true", FalseClosure); 72 | registry.add("true", TrueClosure, { override: true }); 73 | 74 | const result = registry.get("true").process("foo", context()); 75 | result.should.be.true; 76 | }); 77 | 78 | }); 79 | 80 | describe("parse", () => { 81 | beforeEach(() => { 82 | registry.add("isFoo", fact => fact === "foo", { override: true }); 83 | registry.add("always", fact => true, { override: true }); 84 | registry.add("appendBar", fact => fact + "bar", { override: true }); 85 | registry.add("appendSuffix", (fact, context) => fact + context.parameters.suffix, { override: true }); 86 | }) 87 | 88 | it("should return a closure if definition is a simple string", () => { 89 | const closure = registry.parse("appendBar"); 90 | const result = closure.process("foo", context()); 91 | result.should.be.equal("foobar"); 92 | }); 93 | 94 | it("should return a closure if definition contains the closure clause", () => { 95 | const closure = registry.parse({ "closure": "appendBar" }); 96 | const result = closure.process("foo", context()); 97 | result.should.be.equal("foobar"); 98 | }); 99 | 100 | it("should bind the returned closure if additional parameters are provided", () => { 101 | const closure = registry.parse({ "closure": "appendSuffix", "suffix": "baz" }); 102 | const result = closure.process("foo", context()); 103 | result.should.be.equal("foobaz"); 104 | }); 105 | 106 | it("should parse a rule if definition has when/then semantics", () => { 107 | const closure = registry.parse({ 108 | "when": "isFoo", 109 | "then": { "closure": "appendSuffix", "suffix": "baz" } 110 | }); 111 | 112 | const result = closure.process("foo", context()); 113 | result.should.be.equal("foobaz"); 114 | 115 | const result2 = closure.process("bar", context()); 116 | result2.should.be.equal("bar"); 117 | }); 118 | 119 | it("should parse a rule flow if rules field is present in definition", () => { 120 | const closure = registry.parse( 121 | { 122 | "rules": [ 123 | { 124 | "when": "isFoo", 125 | "then": { "closure": "appendSuffix", "suffix": "bar" } 126 | }, 127 | { 128 | "when": "always", 129 | "then": { "closure": "appendSuffix", "suffix": "baz" } 130 | } 131 | ] 132 | } 133 | ); 134 | 135 | const result = closure.process("foo", context()); 136 | result.should.be.equal("foobarbaz"); 137 | 138 | const result2 = closure.process("zoo", context()); 139 | result2.should.be.equal("zoobaz"); 140 | }); 141 | 142 | it("should create a closure reducer if definition is an array", () => { 143 | const closure = registry.parse( 144 | [ 145 | { 146 | "when": "isFoo", 147 | "then": { "closure": "appendSuffix", "suffix": "bar" } 148 | }, 149 | { "closure": "appendSuffix", "suffix": "baz" }, 150 | { "closure": "appendSuffix", "suffix": "boing" } 151 | ] 152 | ); 153 | 154 | const result = closure.process("foo", context()); 155 | result.should.be.equal("foo" + "bar" + "baz" + "boing"); 156 | 157 | const result2 = closure.process("zoo", context()); 158 | result2.should.be.equal("zoo" + "baz" + "boing"); 159 | }); 160 | 161 | }); 162 | 163 | 164 | }); 165 | 166 | function context() { 167 | return new Context(); 168 | } 169 | -------------------------------------------------------------------------------- /test/closure/FunctionalClosure.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const FunctionalClosure = require("../../lib/closure/FunctionalClosure"), 4 | chaiPromised = require("chai-as-promised"), 5 | chai = require("chai"), 6 | Context = require("../../lib/Context") 7 | 8 | chai.should(); 9 | chai.use(chaiPromised); 10 | 11 | describe("FunctionalClosure", () => { 12 | 13 | let closure; 14 | 15 | it("should have name", () => { 16 | closure = new FunctionalClosure("some-closure", () => true); 17 | closure.should.have.property("name").equal("some-closure"); 18 | }); 19 | 20 | it("should pass execution context to function", () => { 21 | const ctx = context(); 22 | ctx.suffix = "bar"; 23 | 24 | closure = new FunctionalClosure("some-closure", (fact, ctx) => fact + ctx.suffix); 25 | const result = closure.process("foo", ctx); 26 | result.should.equal("foobar") 27 | }); 28 | 29 | describe("with no parameters", () => { 30 | beforeEach(() => { 31 | closure = new FunctionalClosure("some-closure", (fact, context) => fact + "bar"); 32 | }); 33 | 34 | it("should execute the associated function when invoked", () => { 35 | const result = closure.process("foo", context()); 36 | result.should.equal("foobar"); 37 | }); 38 | }); 39 | 40 | describe("with required parameters", () => { 41 | beforeEach(() => { 42 | closure = new FunctionalClosure("some-closure", (fact, context) => fact + context.parameters.suffix, { required: ["suffix"] }); 43 | }); 44 | 45 | it.skip("should fail when executed unbounded", () => { 46 | (() => closure.process("foo", context())).should.eventually.throw(); 47 | }); 48 | 49 | it.skip("should fail when binding but parameter not provided", () => { 50 | (() => closure.bind(null, {})).should.throw(); 51 | }); 52 | 53 | it("should use parameter to resolve result", () => { 54 | closure = closure.bind(null, { suffix: "foo" }); 55 | const result = closure.process("foo", context()); 56 | result.should.equal("foofoo"); 57 | }); 58 | 59 | }); 60 | 61 | describe("with optional parameters", () => { 62 | beforeEach(() => { 63 | closure = new FunctionalClosure("some-closure", (fact, context) => fact + context.parameters.suffix); 64 | }); 65 | 66 | it("should work when executed unbounded", () => { 67 | const result = closure.process("foo", context()); 68 | result.should.equal("fooundefined"); 69 | }); 70 | 71 | it("should work when binding but parameter not provided", () => { 72 | closure = closure.bind(null, { }); 73 | const result = closure.process("foo", context()); 74 | result.should.equal("fooundefined"); 75 | }); 76 | 77 | it("should use parameter to resolve result", () => { 78 | closure = closure.bind(null, { suffix: "foo" }); 79 | const result = closure.process("foo", context()); 80 | result.should.equal("foofoo"); 81 | }); 82 | 83 | }); 84 | 85 | it("should fail if not built with a function", () => { 86 | (() => new FunctionalClosure("name", undefined)).should.throw(); 87 | (() => new FunctionalClosure("name", "this is not a function")).should.throw(); 88 | }); 89 | 90 | }); 91 | 92 | function context() { 93 | return new Context(); 94 | } 95 | -------------------------------------------------------------------------------- /test/closure/Rule.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const FunctionalClosure = require("../../lib/closure/FunctionalClosure"), 4 | Rule = require("../../lib/closure/Rule"), 5 | Context = require("../../lib/Context"), 6 | chai = require("chai"), 7 | chaiPromised = require("chai-as-promised"); 8 | 9 | chai.should(); 10 | chai.use(chaiPromised); 11 | 12 | const whenBar = new FunctionalClosure("whenBar", fact => fact === "bar"); 13 | const appendFoo = new FunctionalClosure("appendFoo", fact => fact + "foo"); 14 | const raiseError = new FunctionalClosure("appendFoo", fact => { throw new Error("expected") }); 15 | 16 | describe("Rule", () => { 17 | let rule; 18 | 19 | beforeEach(() => { 20 | rule = new Rule("rule-name", whenBar, appendFoo); 21 | }); 22 | 23 | it("has a name", () => { 24 | rule.should.have.property("name", "rule-name"); 25 | }); 26 | 27 | it("has action and condition closures", () => { 28 | rule.should.have.property("action").that.has.property("process").that.is.a("function"); 29 | rule.should.have.property("condition").that.has.property("process").that.is.a("function"); 30 | }); 31 | 32 | describe("when condition is met", () => { 33 | it("can be invoked and return the new fact", () => { 34 | const result = rule.process("bar", context()); 35 | result.should.equal("barfoo"); 36 | }); 37 | 38 | it("should add itself to context.rulesFired", () => { 39 | const ctx = context(); 40 | rule.process("bar", ctx); 41 | ctx.rulesFired.should.have.lengthOf(1); 42 | }); 43 | }); 44 | 45 | describe("when condition is not met", () => { 46 | it("fact remains unchanged", () => { 47 | const result = rule.process("zoo", context()); 48 | result.should.equal("zoo"); 49 | }); 50 | 51 | it("shouldn't add itself to context.rulesFired", () => { 52 | const ctx = context() 53 | rule.process("zoo", ctx); 54 | ctx.rulesFired.should.have.lengthOf(0); 55 | }); 56 | }); 57 | 58 | describe("when action raises an error", () => { 59 | beforeEach(() => { 60 | rule = new Rule("rule-name", whenBar, raiseError); 61 | }); 62 | 63 | it("error gets propagated", () => { 64 | (() => rule.process("bar", context())).should.throw(); 65 | }); 66 | }); 67 | 68 | describe("when condition raises an error", () => { 69 | beforeEach(() => { 70 | rule = new Rule("rule-name", raiseError, appendFoo); 71 | }); 72 | 73 | it("error gets propagated", () => { 74 | (() => rule.process("bar", context())).should.throw(); 75 | }); 76 | }); 77 | 78 | describe("when bound to parameters", () => { 79 | const whenData = new FunctionalClosure("whenData", (fact, context) => fact === context.parameters.data); 80 | const appendData = new FunctionalClosure("appendData", (fact, context) => fact + context.parameters.suffix); 81 | 82 | it("they should be forwarded to inner action closure", () => { 83 | rule = new Rule("rule-name", whenBar, appendData); 84 | rule = rule.bind(null, { suffix: "foo" }) 85 | const result = rule.process("bar", context()) 86 | result.should.equal("barfoo"); 87 | }); 88 | 89 | it("they should be forwarded to inner condition closure", () => { 90 | rule = new Rule("rule-name", whenData, appendFoo); 91 | rule = rule.bind(null, { data: "bar" }) 92 | const result = rule.process("bar", context()) 93 | result.should.equal("barfoo"); 94 | }); 95 | }); 96 | 97 | it("should fail when built without action", () => { 98 | (() => new Rule("rule-name", whenBar, undefined)).should.throw(); 99 | }); 100 | 101 | it("should fail when built without condition", () => { 102 | (() => new Rule("rule-name", undefined, appendFoo)).should.throw(); 103 | }); 104 | }); 105 | 106 | function context() { 107 | return new Context(); 108 | } 109 | -------------------------------------------------------------------------------- /test/closure/RuleFlow.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const FunctionalClosure = require("../../lib/closure/FunctionalClosure"), 3 | Rule = require("../../lib/closure/Rule"), 4 | RuleFlow = require("../../lib/closure/RuleFlow"), 5 | Context = require("../../lib/Context"), 6 | chai = require("chai"), 7 | sinon = require("sinon"), 8 | sinonChai = require("sinon-chai"), 9 | chaiPromised = require("chai-as-promised"); 10 | 11 | chai.should(); 12 | chai.use(chaiPromised); 13 | chai.use(sinonChai); 14 | 15 | describe("RuleFlow", () => { 16 | let flow; 17 | 18 | const noop = new Rule("rule-name", new FunctionalClosure("never", fact => false), new FunctionalClosure("appendBoo", fact => fact + "boo")); 19 | const appendFoo = new Rule("rule-name", new FunctionalClosure("always", fact => true), new FunctionalClosure("appendFoo", fact => fact + "foo")); 20 | const appendBaz = new Rule("rule-name", new FunctionalClosure("always", fact => true), new FunctionalClosure("appendBaz", fact => fact + "baz")); 21 | const appendZoo = new Rule("rule-name", new FunctionalClosure("always", fact => true), new FunctionalClosure("appendZoo", fact => fact + "zoo")); 22 | const raiseError = new FunctionalClosure("raise", fact => { throw new Error("expected") }); 23 | 24 | beforeEach(() => { 25 | flow = new RuleFlow("flow-name", [noop, appendFoo, appendBaz, appendZoo]); 26 | }); 27 | 28 | it("should have name", () => { 29 | flow.name.should.equal("flow-name"); 30 | }) 31 | 32 | it("should reduce the provided fact across the whole chain of closures", () => { 33 | const result = flow.process("bar", context()); 34 | result.should.be.equal("bar" + "foo" + "baz" + "zoo"); 35 | }) 36 | 37 | it("should short circuit if match once is activated", () => { 38 | flow.options.matchOnce = true; 39 | const result = flow.process("bar", context()); 40 | result.should.be.equal("bar" + "foo"); 41 | }) 42 | 43 | it.skip("should notify context of start / end of the flow", () => { 44 | const ctx = context(); 45 | flow.process("bar", ctx); 46 | ctx.initiateFlow.should.have.been.called.once(); 47 | ctx.endFlow.should.have.been.called.once(); 48 | }) 49 | 50 | it("should propagate the error if any closure in the chain fails", () => { 51 | flow = new RuleFlow("flow-name", [appendFoo, raiseError, appendZoo]); 52 | (() => flow.process("bar", context())).should.throw(); 53 | }) 54 | 55 | describe("when bound to parameters", () => { 56 | const appendSuffix = new FunctionalClosure("appendSuffix", (fact, context) => fact + context.parameters.suffix); 57 | 58 | it("parameters should be propagated to all closures in the chain", () => { 59 | flow = new RuleFlow("flow-name", [appendSuffix, appendSuffix, appendZoo]); 60 | flow = flow.bind(null, { suffix: "foo" }); 61 | 62 | const result = flow.process("bar", context()); 63 | result.should.be.equal("bar" + "foo" + "foo" + "zoo"); 64 | }); 65 | }); 66 | 67 | it("should fail when built without closure chain", () => { 68 | (() => new RuleFlow("flow-name")).should.throw(); 69 | }); 70 | 71 | }); 72 | 73 | function context() { 74 | const context = new Context(); 75 | sinon.spy(context, "initiateFlow"); 76 | sinon.spy(context, "endFlow"); 77 | return context; 78 | } 79 | -------------------------------------------------------------------------------- /test/closure/builtinTransformer.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const chai = require("chai") 4 | const chaiPromised = require("chai-as-promised"); 5 | 6 | const Engine = require("../../lib/Engine") 7 | const Context = require("../../lib/Context") 8 | const commmons = require("../../lib/common"); 9 | 10 | chai.should(); 11 | chai.use(chaiPromised); 12 | 13 | 14 | describe("dateRange", () => { 15 | let engine; 16 | 17 | beforeEach(() => { 18 | engine = new Engine(); 19 | commmons(engine); 20 | }) 21 | 22 | it("fixed value transformer", () => { 23 | const transformer = engine.closures.get("fixedValue").bind(null, {value: 42}, engine) 24 | const obtainedValue = transformer.process({a: 4, b: [3,5,28,4]}, context()) 25 | obtainedValue.should.equal(42) 26 | }); 27 | 28 | it("project transformer, gets number", () => { 29 | const transformer = engine.closures.get("get").bind(null, {prop: "a"}, engine) 30 | const obtainedValue = transformer.process({a: 4, b: [3,5,28,4]}, context()) 31 | obtainedValue.should.equal(4) 32 | }); 33 | 34 | it("project transformer, gets array", () => { 35 | const transformer = engine.closures.get("get").bind(null, {prop: "b"}, engine) 36 | const obtainedValue = transformer.process({a: 4, b: [3,5,28,4]}, context()) 37 | obtainedValue.should.deep.equal([3,5,28,4]) 38 | }); 39 | }); 40 | 41 | function context() { 42 | return new Context(); 43 | } 44 | -------------------------------------------------------------------------------- /test/closure/dateRange.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const chai = require("chai") 4 | const chaiPromised = require("chai-as-promised"); 5 | 6 | const moment = require("moment") 7 | 8 | const Engine = require("../../lib/Engine") 9 | const Rule = require("../../lib/closure/Rule") 10 | const Context = require("../../lib/Context") 11 | const commmons = require("../../lib/common"); 12 | 13 | chai.should(); 14 | chai.use(chaiPromised); 15 | 16 | 17 | describe("dateRange", () => { 18 | let engine; 19 | 20 | beforeEach(() => { 21 | engine = new Engine(); 22 | commmons(engine); 23 | engine.closures.add("saleDate", (fact) => fact.saleDate) 24 | engine.closures.add("priceInc", (fact) => { fact.price += 1 ; return fact }) 25 | }) 26 | 27 | describe("closure creation", () => { 28 | it("should just create", () => { 29 | const specificDateRangeClosure = 30 | engine.closures.get("dateRange").bind(null, {dateFrom: "2018-09-01", dateExtractor: "saleDate"}, engine) 31 | 32 | specificDateRangeClosure.should.not.be.null 33 | }); 34 | }); 35 | 36 | describe("do not filter if no parameter date found in fact", () => { 37 | it("no value given in fact", () => { 38 | const rule = new Rule( 39 | "incPrice", 40 | engine.closures.get("dateRange").bind(null, {dateFrom: "2018-09-01", dateExtractor: "saleDate"}, engine), 41 | engine.closures.get("priceInc") 42 | ) 43 | 44 | const fact = { price: 10 } 45 | const newFact = rule.process(fact, context()) 46 | newFact.should.not.be.null 47 | newFact.price.should.equal(10) 48 | }); 49 | }); 50 | 51 | describe("filter actions with dateFrom", () => { 52 | let rule 53 | 54 | beforeEach(() => { 55 | rule = new Rule( 56 | "incPrice", 57 | engine.closures.get("dateRange").bind(null, {dateFrom: "2018-09-01", dateExtractor: "saleDate"}, engine), 58 | engine.closures.get("priceInc") 59 | ) 60 | }) 61 | 62 | it("should process on date after dateFrom", () => { 63 | const fact = { saleDate: "2018-10-15", price: 10 } 64 | const newFact = rule.process(fact, context()) 65 | newFact.should.not.be.null 66 | newFact.price.should.equal(11) 67 | }); 68 | 69 | it("should process on date on dateFrom", () => { 70 | const fact = { saleDate: "2018-09-01", price: 10 } 71 | const newFact = rule.process(fact, context()) 72 | newFact.should.not.be.null 73 | newFact.price.should.equal(11) 74 | }); 75 | 76 | it("should not process before date on dateFrom", () => { 77 | const fact = { saleDate: "2018-07-21", price: 10 } 78 | const newFact = rule.process(fact, context()) 79 | newFact.should.not.be.null 80 | newFact.price.should.equal(10) 81 | }); 82 | 83 | }); 84 | 85 | describe("filter actions with dateTo", () => { 86 | let rule 87 | 88 | beforeEach(() => { 89 | rule = new Rule( 90 | "incPrice", 91 | engine.closures.get("dateRange").bind(null, {dateTo: "2018-09-01", dateExtractor: "saleDate"}, engine), 92 | engine.closures.get("priceInc") 93 | ) 94 | }) 95 | 96 | it("should not process on date after dateTo", () => { 97 | const fact = { saleDate: niceDate(2018, 10, 15), price: 10 } 98 | const newFact = rule.process(fact, context()) 99 | newFact.should.not.be.null 100 | newFact.price.should.equal(10) 101 | }); 102 | 103 | it("should process on date on dateTo", () => { 104 | const fact = { saleDate: niceDate(2018, 9, 1), price: 10 } 105 | const newFact = rule.process(fact, context()) 106 | newFact.should.not.be.null 107 | newFact.price.should.equal(11) 108 | }); 109 | 110 | it("should process on date before dateTo", () => { 111 | const fact = { saleDate: niceDate(2018, 7, 21), price: 10 } 112 | const newFact = rule.process(fact, context()) 113 | newFact.should.not.be.null 114 | newFact.price.should.equal(11) 115 | }); 116 | 117 | }); 118 | 119 | describe("filter actions with dateFrom and dateTo", () => { 120 | let rule 121 | 122 | beforeEach(() => { 123 | rule = new Rule( 124 | "incPrice", 125 | engine.closures.get("dateRange").bind(null, {dateFrom: "2018-09-01", dateTo: "2018-09-30", dateExtractor: "saleDate"}, engine), 126 | engine.closures.get("priceInc") 127 | ) 128 | }) 129 | 130 | it("should not process on date way after dateTo", () => { 131 | const fact = { saleDate: niceDate(2018, 12, 15), price: 10 } 132 | const newFact = rule.process(fact, context()) 133 | newFact.should.not.be.null 134 | newFact.price.should.equal(10) 135 | }); 136 | 137 | it("should not process on date just after dateTo", () => { 138 | const fact = { saleDate: niceDate(2018, 10, 1), price: 10 } 139 | const newFact = rule.process(fact, context()) 140 | newFact.should.not.be.null 141 | newFact.price.should.equal(10) 142 | }); 143 | 144 | it("should process on dateTo", () => { 145 | const fact = { saleDate: niceDate(2018, 9, 30), price: 10 } 146 | const newFact = rule.process(fact, context()) 147 | newFact.should.not.be.null 148 | newFact.price.should.equal(11) 149 | }); 150 | 151 | it("should process on date between dateFrom and dateTo", () => { 152 | const fact = { saleDate: niceDate(2018, 9, 12), price: 10 } 153 | const newFact = rule.process(fact, context()) 154 | newFact.should.not.be.null 155 | newFact.price.should.equal(11) 156 | }); 157 | 158 | it("should process on dateFrom", () => { 159 | const fact = { saleDate: niceDate(2018, 9, 1), price: 10 } 160 | const newFact = rule.process(fact, context()) 161 | newFact.should.not.be.null 162 | newFact.price.should.equal(11) 163 | }); 164 | 165 | it("should not process on date just before dateFrom", () => { 166 | const fact = { saleDate: niceDate(2018, 8, 31), price: 10 } 167 | const newFact = rule.process(fact, context()) 168 | newFact.should.not.be.null 169 | newFact.price.should.equal(10) 170 | }); 171 | 172 | it("should not process on date way before dateFrom", () => { 173 | const fact = { saleDate: niceDate(2018, 7, 21), price: 10 } 174 | const newFact = rule.process(fact, context()) 175 | newFact.should.not.be.null 176 | newFact.price.should.equal(10) 177 | }); 178 | 179 | }); 180 | 181 | describe("filter actions with dateBefore", () => { 182 | let rule 183 | 184 | beforeEach(() => { 185 | rule = new Rule( 186 | "incPrice", 187 | engine.closures.get("dateRange").bind(null, {dateBefore: "2018-09-01", dateExtractor: "saleDate"}, engine), 188 | engine.closures.get("priceInc") 189 | ) 190 | }) 191 | 192 | it("should not process on date way after dateBefore", () => { 193 | const fact = { saleDate: "2018-10-15", price: 10 } 194 | const newFact = rule.process(fact, context()) 195 | newFact.should.not.be.null 196 | newFact.price.should.equal(10) 197 | }); 198 | 199 | it("should not process on date just after dateBefore", () => { 200 | const fact = { saleDate: "2018-09-02", price: 10 } 201 | const newFact = rule.process(fact, context()) 202 | newFact.should.not.be.null 203 | newFact.price.should.equal(10) 204 | }); 205 | 206 | it("should not process on dateBefore", () => { 207 | const fact = { saleDate: "2018-09-01", price: 10 } 208 | const newFact = rule.process(fact, context()) 209 | newFact.should.not.be.null 210 | newFact.price.should.equal(10) 211 | }); 212 | 213 | it("should process on date just before dateBefore", () => { 214 | const fact = { saleDate: "2018-08-31", price: 10 } 215 | const newFact = rule.process(fact, context()) 216 | newFact.should.not.be.null 217 | newFact.price.should.equal(11) 218 | }); 219 | 220 | it("should process on date way before dateBefore", () => { 221 | const fact = { saleDate: "2018-07-21", price: 10 } 222 | const newFact = rule.process(fact, context()) 223 | newFact.should.not.be.null 224 | newFact.price.should.equal(11) 225 | }); 226 | 227 | }); 228 | 229 | describe("filter actions with dateAfter", () => { 230 | let rule 231 | 232 | beforeEach(() => { 233 | rule = new Rule( 234 | "incPrice", 235 | engine.closures.get("dateRange").bind(null, {dateAfter: "2018-09-30", dateExtractor: "saleDate"}, engine), 236 | engine.closures.get("priceInc") 237 | ) 238 | }) 239 | 240 | it("should process on date way after dateAfter", () => { 241 | const fact = { saleDate: "2018-12-15", price: 10 } 242 | const newFact = rule.process(fact, context()) 243 | newFact.should.not.be.null 244 | newFact.price.should.equal(11) 245 | }); 246 | 247 | it("should process on date just after dateAfter", () => { 248 | const fact = { saleDate: "2018-10-01", price: 10 } 249 | const newFact = rule.process(fact, context()) 250 | newFact.should.not.be.null 251 | newFact.price.should.equal(11) 252 | }); 253 | 254 | it("should not process on dateAfter", () => { 255 | const fact = { saleDate: "2018-09-30", price: 10 } 256 | const newFact = rule.process(fact, context()) 257 | newFact.should.not.be.null 258 | newFact.price.should.equal(10) 259 | }); 260 | 261 | it("should not process on date just before dateAfter", () => { 262 | const fact = { saleDate: "2018-09-29", price: 10 } 263 | const newFact = rule.process(fact, context()) 264 | newFact.should.not.be.null 265 | newFact.price.should.equal(10) 266 | }); 267 | 268 | it("should not process on date way before dateAfter", () => { 269 | const fact = { saleDate: "2018-07-21", price: 10 } 270 | const newFact = rule.process(fact, context()) 271 | newFact.should.not.be.null 272 | newFact.price.should.equal(10) 273 | }); 274 | 275 | }); 276 | 277 | }); 278 | 279 | function context() { 280 | return new Context(); 281 | } 282 | 283 | function niceDate(y,m,d) { return moment({year: y, month: (m-1), day: d}).toDate() } 284 | -------------------------------------------------------------------------------- /test/flows/async-actions.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-actions", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": { "closure": "always" }, 8 | "then": [ 9 | { "closure": "fetchSecurityData" }, 10 | { "closure": "setQuantity" }, 11 | { "closure": "setCost" } 12 | ] 13 | }, 14 | { 15 | "description": "Set commissions for Equities", 16 | "when": { "closure": "equal", "field": "productType", "value": "Equity" }, 17 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1 } 18 | }, 19 | { 20 | "description": "Set commissions for Options", 21 | "when": { "closure": "equal", "field": "productType", "value": "Option" }, 22 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1.25 } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /test/flows/chained-actions.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chained-actions", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": { "closure": "always" }, 8 | "then": [ 9 | { "closure": "setQuantity" }, 10 | { "closure": "setCost" } 11 | ] 12 | }, 13 | { 14 | "description": "Set commissions for Equities", 15 | "when": { "closure": "equal", "field": "productType", "value": "Equity" }, 16 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1 } 17 | }, 18 | { 19 | "description": "Set commissions for Options", 20 | "when": { "closure": "equal", "field": "productType", "value": "Option" }, 21 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1.25 } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test/flows/conditional-reducers.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "conditional-reducers", 3 | "rules": [ 4 | { 5 | "when": [ 6 | { "closure": "equal", "field": "price", "value": 10 }, 7 | { "closure": "equal", "field": "quantity", "value": 5 } 8 | ], 9 | "then": { "closure": "addToPrice", "amount": 10 } 10 | }, 11 | { 12 | "conditionStrategy": "and", 13 | "when": [ 14 | { "closure": "equal", "field": "price", "value": 20 }, 15 | { "closure": "equal", "field": "quantity", "value": 10 } 16 | ], 17 | "then": { "closure": "addToPrice", "amount": 100 } 18 | }, 19 | { 20 | "conditionStrategy": "or", 21 | "when": [ 22 | { "closure": "equal", "field": "price", "value": 30 }, 23 | { "closure": "equal", "field": "quantity", "value": 1 } 24 | ], 25 | "then": { "closure": "addToPrice", "amount": 1000 } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /test/flows/date-range.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "date-range-rules", 3 | 4 | "rules": [ 5 | { 6 | "description": "add 2 to price for September 2018", 7 | "when": { "closure": "dateRange", "dateFrom": "2018-09-01", "dateTo": "2018-09-30", "dateExtractor": "saleDate" }, 8 | "then": { "closure": "addToPrice", "amount": 2 } 9 | }, 10 | { 11 | "description": "add 3 to price for October 2018", 12 | "when": { "closure": "dateRange", "dateFrom": "2018-10-01", "dateTo": "2018-10-31", "dateExtractor": "saleDate" }, 13 | "then": { "closure": "addToPrice", "amount": 3 } 14 | }, 15 | { 16 | "description": "add 4 to price from November 2018 on", 17 | "when": { "closure": "dateRange", "dateFrom": "2018-11-01", "dateExtractor": "saleDate" }, 18 | "then": { "closure": "addToPrice", "amount": 4 } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/flows/default-condition.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "default-condition", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": { "closure": "always" }, 8 | "then": { "closure": "setCost" } 9 | }, 10 | { 11 | "description": "Set commissions for Equities", 12 | "when": { "closure": "equal", "field": "productType", "value": "Equity" }, 13 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1 } 14 | }, 15 | { 16 | "description": "Set commissions for Options", 17 | "when": { "closure": "equal", "field": "productType", "value": "Option" }, 18 | "then": { 19 | "name": "nested rule evaluation for options", 20 | "rules": [ 21 | { 22 | "description": "On Call options", 23 | "when": { "closure": "equal", "field": "optionType", "value": "Call" }, 24 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1.1 } 25 | }, 26 | { 27 | "description": "On Put options", 28 | "when": { "closure": "equal", "field": "optionType", "value": "Put" }, 29 | "then": { "closure": "setPercentualCommission", "percentualPoints": 0.9 } 30 | }, 31 | { 32 | "description": "Any other is an error", 33 | "when": { "closure": "default" }, 34 | "then": { "closure": "error", "message": "Unrecognized optionType" } 35 | } 36 | ] 37 | } 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /test/flows/generic-set-rule.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generic-set-rule", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": { "closure": "always" }, 8 | "then": { "closure": "setResult", "field": "cost", "calculator": "calculateCost" } 9 | }, 10 | { 11 | "description": "Set commissions for Equities", 12 | "when": { "closure": "equal", "field": "productType", "value": "Equity" }, 13 | "then": { "closure": "setResult", "field": "commissions", "calculator": { "closure": "calculatePercentualCommission", "percentualPoints": 1 }} 14 | }, 15 | { 16 | "description": "Set commissions for Options", 17 | "when": { "closure": "equal", "field": "productType", "value": "Option" }, 18 | "then": { "closure": "setResult", "field": "commissions", "calculator": { "closure": "calculatePercentualCommission", "percentualPoints": 1.25 }} 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/flows/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Engine = require("../../lib/Engine"), 4 | commmons = require("../../lib/common"); 5 | 6 | const engine = new Engine(); 7 | commmons(engine); 8 | 9 | //productTypeCondition - not really needed, generic equal can be used instead 10 | engine.closures.add("productTypeCondition", (fact, context) => { 11 | return fact.productType === context.parameters.productType; 12 | }); 13 | 14 | //setQuantity 15 | engine.closures.add("fetchSecurityData", (fact, context) => { 16 | const securityPromise = context.engine.services.securityMasterService.fetch(fact.security); 17 | return securityPromise.then(security => { 18 | fact.security = security; //replaces security with full blown object 19 | return fact; 20 | }) 21 | }) 22 | 23 | //setQuantity 24 | engine.closures.add("setQuantity", (fact, context) => { 25 | fact.quantity = fact.contracts * fact.security.contractSize; 26 | return fact; 27 | }) 28 | 29 | //setCost 30 | engine.closures.add("setCost", (fact, context) => { 31 | fact.cost = fact.price * fact.quantity; 32 | return fact; 33 | }) 34 | 35 | //calculateCost - a version of setCost that returns not a fact but a simple value 36 | engine.closures.add("calculateCost", (fact, context) => { 37 | return fact.price * fact.quantity; 38 | }); 39 | 40 | //setPercentualCommission 41 | engine.closures.add("setPercentualCommission", (fact, context) => { 42 | fact.commissions = fact.cost * context.parameters.percentualPoints / 100; 43 | return fact; 44 | }, { required: ["percentualPoints"] }) 45 | 46 | //calculateCommissions - a version of setCost that returns not a fact but a simple value 47 | engine.closures.add("calculatePercentualCommission", (fact, context) => { 48 | return fact.cost * context.parameters.percentualPoints / 100; 49 | }, { required: ["percentualPoints"] }) 50 | 51 | //addToPrice - add to fact.price the amount indicated in a parameter 52 | engine.closures.add("addToPrice", (fact, context) => { 53 | fact.price += context.parameters.amount; 54 | return fact; 55 | }, { required: ["amount"] }) 56 | 57 | //saleDate - simple field extractor 58 | engine.closures.add("saleDate", (fact, context) => { 59 | return fact.saleDate; 60 | }) 61 | 62 | module.exports = engine; 63 | -------------------------------------------------------------------------------- /test/flows/nested-rules.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nested-rules", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": { "closure": "always" }, 8 | "then": { "closure": "setCost" } 9 | }, 10 | { 11 | "description": "Set commissions for Equities", 12 | "when": { "closure": "equal", "field": "productType", "value": "Equity" }, 13 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1 } 14 | }, 15 | { 16 | "description": "Set commissions for Options", 17 | "when": { "closure": "equal", "field": "productType", "value": "Option" }, 18 | "then": { 19 | "description": "nested rule evaluation for options", 20 | "rules": [ 21 | { 22 | "description": "On Call options", 23 | "when": { "closure": "equal", "field": "optionType", "value": "Call" }, 24 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1.1 } 25 | }, 26 | { 27 | "description": "On Put options", 28 | "when": { "closure": "equal", "field": "optionType", "value": "Put" }, 29 | "then": { "closure": "setPercentualCommission", "percentualPoints": 0.9 } 30 | } 31 | ] 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /test/flows/simple-rules.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-rules", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": { "closure": "always" }, 8 | "then": { "closure": "setCost" } 9 | }, 10 | { 11 | "description": "Set commissions for Equities", 12 | "when": { "closure": "productTypeCondition", "productType": "Equity" }, 13 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1 } 14 | }, 15 | { 16 | "description": "Set commissions for Options", 17 | "when": { "closure": "productTypeCondition", "productType": "Option" }, 18 | "then": { "closure": "setPercentualCommission", "percentualPoints": 1.25 } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/flows/sugar-coated.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sugar-coated", 3 | 4 | "rules": [ 5 | { 6 | "description": "CalculateCost for all products", 7 | "when": "always", 8 | "then": [ 9 | "fetchSecurityData", 10 | "setQuantity", 11 | { "closure": "setResult", "field": "cost", "calculator": "calculateCost" } 12 | ] 13 | }, 14 | { 15 | "description": "Set commissions for Equities", 16 | "when": { "closure": "equal", "field": "productType", "value": "Equity" }, 17 | "then": { "closure": "setResult", "field": "commissions", "calculator": { "closure": "calculatePercentualCommission", "percentualPoints": 1 }} 18 | }, 19 | { 20 | "description": "Set commissions for Options", 21 | "when": { "closure": "equal", "field": "productType", "value": "Option" }, 22 | "then": { "closure": "setResult", "field": "commissions", "calculator": { "closure": "calculatePercentualCommission", "percentualPoints": 1.25 }} 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /test/parse/closure-library.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "closureLibrary": [ 3 | { "name": "level42", "closure": "fixedValue", "value": 42 }, 4 | { "name": "dawnOfANewEra", "closure": "fixedValue", "value": "2018-12-04"}, 5 | { "name": "getPrice", "closure": "get", "prop": "price"} 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /test/parse/parseClosureLibrary.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const chai = require("chai"), 4 | chaiPromised = require("chai-as-promised"); 5 | 6 | const Engine = require("../../lib/Engine") 7 | const commmons = require("../../lib/common"); 8 | 9 | 10 | chai.should(); 11 | chai.use(chaiPromised); 12 | 13 | 14 | function addDefinitionsFromFile(name, engine) { 15 | engine.add(require(`./${name}.flow.json`), { override: true }); 16 | } 17 | 18 | describe("closure library", () => { 19 | let engine; 20 | 21 | beforeEach(() => { 22 | engine = new Engine(); 23 | commmons(engine); 24 | addDefinitionsFromFile("closure-library", engine); 25 | }) 26 | 27 | it("has closures", () => { 28 | engine.closures.get("level42").should.be.ok; 29 | engine.closures.get("getPrice").should.be.ok; 30 | (() => engine.closure.get("level43")).should.throw(Error); 31 | }); 32 | 33 | it("process fixed value - numeric", () => { 34 | const result = engine.process("level42", { productType: "Option", price: 20, quantity: 5 }); 35 | return result.should.eventually.have.property("fact", 42); 36 | }); 37 | 38 | it("process fixed value - string", () => { 39 | const result = engine.process("dawnOfANewEra", { productType: "Option", price: 20, quantity: 5 }); 40 | return result.should.eventually.have.property("fact", "2018-12-04"); 41 | }); 42 | 43 | it("process getter", () => { 44 | const result = engine.process("getPrice", { productType: "Option", price: 20, quantity: 5 }); 45 | return result.should.eventually.have.property("fact", 20); 46 | }); 47 | }); 48 | --------------------------------------------------------------------------------