├── .eslintrc.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .prettierrc.json ├── .vscode └── launch.json ├── DESIGN_IDEAS.md ├── EXAMPLES.md ├── LICENSE.md ├── README.md ├── examples └── aws-lambda-service │ ├── .gitignore │ ├── README.md │ ├── handler.ts │ ├── lib │ ├── index.ts │ ├── transformers.ts │ └── types.ts │ ├── package.json │ ├── rules │ └── app-rules.ts │ ├── serverless.yml │ └── tsconfig.json ├── img ├── rules-machine-header.png ├── rules-machine-header.svg ├── rules-machine-logo.png └── rules-machine-logo.svg ├── jest.config.ts ├── package.json ├── scripts └── build.sh ├── src ├── __snapshots__ │ └── index.test.ts.snap ├── arrays.test.ts ├── expression-language │ ├── index.ts │ └── utils.ts ├── index.test.ts ├── index.ts ├── types.ts └── utils │ ├── errors.ts │ ├── mockDateHelper.ts │ ├── performance.ts │ └── utils.ts ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@typescript-eslint/utils').TSESLint.Linter.Config} 3 | */ 4 | const config = { 5 | root: true, 6 | env: { 7 | node: true, 8 | }, 9 | extends: ['standard-with-typescript', 'prettier'], 10 | parser: '@typescript-eslint/parser', 11 | ignorePatterns: ['node_modules/', 'dist/', 'coverage/'], 12 | parserOptions: { 13 | // sourceType: 'module', 14 | tsconfigRootDir: __dirname, 15 | project: ['./tsconfig.json'], // could be tsconfig.json too 16 | }, 17 | rules: { 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | '@typescript-eslint/consistent-type-assertions': 'off', 21 | '@typescript-eslint/strict-boolean-expressions': 'off', 22 | '@typescript-eslint/no-non-null-assertion': 'warn', 23 | '@typescript-eslint/restrict-template-expressions': [ 24 | 'warn', 25 | { 26 | allowAny: true, 27 | allowNumber: true, 28 | allowBoolean: true, 29 | allowNullish: true, 30 | allowRegExp: true, 31 | }, 32 | ], 33 | '@typescript-eslint/no-floating-promises': [ 34 | 'error', 35 | { 36 | ignoreVoid: true, 37 | ignoreIIFE: true, 38 | }, 39 | ], 40 | 41 | '@typescript-eslint/no-misused-promises': [ 42 | 'error', 43 | { 44 | checksConditionals: true, 45 | checksVoidReturn: true, 46 | }, 47 | ], 48 | }, 49 | }; 50 | 51 | module.exports = config; 52 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | # push: 4 | # branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [14.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: "yarn" 23 | - run: yarn config set workspaces-experimental true 24 | - run: yarn 25 | - run: yarn format 26 | - run: yarn test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | .DS_Store 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | 121 | # Stores VSCode versions used for testing VSCode extensions 122 | .vscode-test 123 | 124 | # yarn v2 125 | .yarn/cache 126 | .yarn/unplugged 127 | .yarn/build-state.yml 128 | .yarn/install-state.gz 129 | .pnp.* 130 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .git 2 | dist 3 | coverage 4 | node_modules 5 | yarn.lock 6 | package-lock.json 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 80, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/.bin/jest", 11 | "--runInBand" 12 | ], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "port": 9229 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /DESIGN_IDEAS.md: -------------------------------------------------------------------------------- 1 | # Exploring Various Patterns 2 | 3 | ## `Rule` Expressions 4 | 5 | A `Rule` is a string expressing a bit of logic. 6 | 7 | Typically it includes 3 parts: a left-hand side (LHS), an operator (`+`, `*`, `=`, `==`), and a right-hand side (RHS). 8 | 9 | ### Example Rules 10 | 11 | - Assignment Operator Examples 12 | - `account.balance -= 100` 13 | - `discount ??= 10` - this is modern ES2019 syntax. It sets the `discount` to 10 only if it is unset (null-ish coalescing.) 14 | - `invoice.total += cart.subtotal` 15 | - Logical Operators (Boolean Expressions) 16 | - `==` 17 | - `!=` 18 | - `>=` 19 | - `>` 20 | - etc. 21 | 22 | ## Logical AND, OR 23 | 24 | ```ts 25 | // {"and": ["price >= 25", "price <= 50"]} 26 | [ 27 | {"if": {"and": ["price >= 25", "price <= 50"]}, "then": "discount = 5"}, 28 | {"if": "price >= 100", "then": "discount = 20"}, 29 | {"return": "discount"}, 30 | ] 31 | 32 | // Possible recursive structure? 33 | [ 34 | { 35 | "if": 36 | {"or": [ 37 | "price >= 25", 38 | {"and": [ 39 | "price <= 50", 40 | "discountApplied == false" 41 | ]} 42 | ]}, 43 | "then": "discount = 5" 44 | }, 45 | {"if": "price >= 100", "then": "discount = 20"}, 46 | {"return": "discount"}, 47 | ] 48 | 49 | // "price >= 25 || price <= 50" 50 | [ 51 | {"if": "price >= 25 || price <= 50", "then": "discount = 5"}, 52 | {"if": "price >= 100", "then": "discount = 20"}, 53 | {"return": "discount"}, 54 | ] 55 | 56 | ``` 57 | 58 | ## Syntax Ideas 59 | 60 | Scenario: Determine customer discount: `if price >= 100 then discount = 20, else price >= 25, discount = 5` 61 | 62 | ### Option #1 63 | 64 | ```ts 65 | [ 66 | { if: 'price >= 25', then: 'discount = 5' }, 67 | { if: 'price >= 100', then: 'discount = 20' }, 68 | { return: 'discount' }, 69 | ]; 70 | ``` 71 | 72 | ```ts 73 | // Idea: execution could map to functional-style operators 74 | ifThen(condition, thenLogic); 75 | ifElse(condition, thenLogic, elseLogic); 76 | ``` 77 | 78 | ### Option #2 79 | 80 | - Pro: Familiar JS Syntax. 81 | - Con: More complicated & error-prone parsing 82 | 83 | ```ts 84 | ['if (price >= 25) discount = 5', 'if (price >= 100) discount = 20']; 85 | ``` 86 | 87 | ### Option 3: Reference Style: JSON-Rules-Engine (Microsoft) 88 | 89 | - IMHO, this is convoluted stylistically & difficult to write. 90 | 91 | ```ts 92 | let microsoftRule = { 93 | conditions: { 94 | all: [ 95 | { 96 | fact: 'account-information', 97 | operator: 'equal', 98 | value: 'microsoft', 99 | path: '$.company', // access the 'company' property of "account-information" 100 | }, 101 | { 102 | fact: 'account-information', 103 | operator: 'in', 104 | value: ['active', 'paid-leave'], // 'status' can be active or paid-leave 105 | path: '$.status', // access the 'status' property of "account-information" 106 | }, 107 | { 108 | fact: 'account-information', 109 | operator: 'contains', // the 'ptoDaysTaken' property (an array) must contain '2016-12-25' 110 | value: '2016-12-25', 111 | path: '$.ptoDaysTaken', // access the 'ptoDaysTaken' property of "account-information" 112 | }, 113 | ], 114 | }, 115 | }; 116 | ``` 117 | -------------------------------------------------------------------------------- /EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Rule Organization Examples 2 | 3 | ## A Layered Approach 4 | 5 | This pattern relies on `import`/`export` (or `require()`) to access rule objects. 6 | 7 | A top-level rules file (`app-hooks.ts`) can be used to organize rules from across your app. 8 | 9 | Granular rules are defined separately. They are imported by name into `app-hooks.ts`, where we'll organize them by high-level named hooks (e.g. `onUserRegister`, `getDiscountPercent`). 10 | 11 | ```ts 12 | // `./src/app-hooks.ts` 13 | import { ruleFactory } from '@elite-libs/rules-machine'; 14 | // Import plain old rule objects 15 | import { userRules } from './rules/users'; 16 | import { rewardsRules } from './rules/rewards'; 17 | 18 | export const appHooks = { 19 | onUserRegister: ruleFactory([ 20 | userRules.applyFreeTrial, 21 | userRules.applyNewUserPromotion, 22 | ]), 23 | getDiscountPercent: ruleFactory([ 24 | rewardsRules.convertRewardsToPercentDiscount, 25 | ]), 26 | }; 27 | ``` 28 | 29 | ### App Usage example 30 | 31 | Here's where we use our app-level functions (`registerUser`, `calculateCartDiscount`) which leverage our rules. 32 | 33 | ```ts 34 | // `./src/users/index.ts` 35 | import appHooks from './src/app-hooks'; 36 | 37 | const { onUserRegister, getDiscountPercent } = appHooks; 38 | 39 | export function registerUser(user) { 40 | // Call a rules function just like any other function. 41 | const updatedUser = onUserRegister(user); 42 | // Then continue on to your business logic. 43 | return api.post(updatedUser); 44 | } 45 | 46 | export function calculateCartDiscount({ user, cart }) { 47 | const percentDiscount = getDiscountPercent(user); 48 | return cart.total * percentDiscount; 49 | } 50 | ``` 51 | 52 | ### Granular Rule Definition 53 | 54 | See `userRules` and `rewardsRules` below for an idea how to layer your detailed rules. 55 | 56 | ````ts 57 | // `./src/users/rules.ts` - locate next to user module. 58 | // OR 59 | // `./src/rules/user.ts` - locate in central rules folder. 60 | export const userRules: Record = { 61 | /** 62 | * Example Input: (User object) 63 | * ```js 64 | * { 65 | * user: { 66 | * plan: 'premium', 67 | * name: 'Dan', 68 | * rewardsBalance: 0, 69 | * } 70 | * } 71 | * ``` 72 | */ 73 | applyNewUserPromotion: ['user.rewardsBalance = 500'], 74 | applyFreeTrial: ["user.subscriptionExpires = DATEISO('+1 month')"], 75 | }; 76 | 77 | // `./src/rules/rewards.ts` 78 | export const rewardsRules: Record = { 79 | /** 80 | * convertRewardsToPercentDiscount determines a user's `discountPercent`. 81 | */ 82 | convertRewardsToPercentDiscount: [ 83 | { if: 'user.rewardsBalance >= 1000', then: 'discountPercent = 0.05' }, 84 | { if: 'user.rewardsBalance >= 250', then: 'discountPercent = 0.02' }, 85 | { 86 | if: 'user.rewardsBalance >= 100', 87 | then: 'discountPercent = 0.01', 88 | else: 'discountPercent = 0', 89 | }, 90 | { return: 'discountPercent' }, 91 | ], 92 | }; 93 | ```` 94 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, elite-libs 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rules Machine 2 | 3 | 4 | 5 | 6 | [![CI Status](https://github.com/elite-libs/rules-machine/workflows/test/badge.svg)](https://github.com/elite-libs/rules-machine/actions) 7 | [![NPM version](https://img.shields.io/npm/v/@elite-libs/rules-machine.svg)](https://www.npmjs.com/package/@elite-libs/rules-machine) 8 | [![GitHub stars](https://img.shields.io/github/stars/elite-libs/rules-machine.svg?style=social)](https://github.com/elite-libs/rules-machine) 9 | 10 | ![Rules Machine](img/rules-machine-header.svg) 11 | 12 | > _Rules Against The Machine_ 🤘 13 | 14 | 16 | 17 | **Table of Content** 18 | 19 | - [What's a `Rules Machine`?](#whats-a-rules-machine) 20 | - [Goals](#goals) 21 | - [Key Terms](#key-terms) 22 | - [Finding Opportunities for Rules](#finding-opportunities-for-rules) 23 | - [Why Rules Engines?](#why-rules-engines) 24 | - [Pros](#pros) 25 | - [Cons](#cons) 26 | - [Install](#install) 27 | - [Usage](#usage) 28 | - [Examples](#examples) 29 | - [Example Rule: Apply Either $5 or $10 Discount](#example-rule-apply-either-5-or-10-discount) 30 | - [Example Rule: Apply $15 Discount if Employee, or Premium Customer](#example-rule-apply-15-discount-if-employee-or-premium-customer) 31 | - [Example Rule: Multiple Conditional, Nested Rules](#example-rule-multiple-conditional-nested-rules) 32 | - [Example Rule: Use variable between rules](#example-rule-use-variable-between-rules) 33 | - [Rules API](#rules-api) 34 | - [Array Expressions](#array-expressions) 35 | - [Builtin Operators](#builtin-operators) 36 | - [Functions](#functions) 37 | - [More Reading & Related Projects](#more-reading--related-projects) 38 | - [TODO](#todo) 39 | 40 | ## What's a `Rules Machine`? 41 | 42 | It's a fast, general purpose [`JSON Rules Engine`](https://martinfowler.com/bliki/RulesEngine.html) library for both the Browser & Node.js! 🚀 43 | 44 | ### Goals 45 | 46 | - **Share business logic** - move logic around the I/O layer, just like data. 47 | - Shared validation logic (same logic from the web form to the backend) 48 | - Push rules where they are needed: Cloud functions, CloudFlare Workers, Lambda@Edge, etc.) 49 | - **Organize complexity** - isolate complex Business Rules from App Logic and state. 50 | - Name, group and chain rules. 51 | - Don't repeat yourself: reference common rule(s) by name. (`applySalesTax`) 52 | - **Modeling workflows** - model your business logic as a series of readable steps. 53 | - Help non-dev stakeholders (QA, Product) understand critical logic. 54 | - Simply formatting JSON Rules sheds light on both hierarchy & steps. 55 | 56 | ### Key Terms 57 | 58 | `App Logic != Business Rules` 59 | 60 | - **App Logic** - applies more broadly and changes less frequently than Business Rules. 61 | - _"Throw Error if ShoppingCart total is less than zero."_ 62 | - _"Only one discount code can be applied at a time."_ 63 | 64 | - **Business Rules** - targeted & detailed, can change frequently. 65 | - Supports business goals & objectives (as they evolve) from Product, Leadership, Legal, Finance, A/B Tuning, etc. 66 | - _"Premium customers can apply 3 discounts, up to 25% off."_ 67 | - _"If we're in lock-down, double shipping estimates."_ 68 | - _"If State is NY, add NY tax."_ 69 | - _"If State is AZ and during Daylight Savings, offset an hour."_ 70 | 71 | 72 | ### Finding Opportunities for Rules 73 | 74 | Typically Business Rules are better-suited to 'rules engine' style pattern. 75 | 76 | If your Business Rules or logic changes frequently, you can get alignment benefits by moving that logic to a serializable & sharable format. Specifically, this can provide immediate benefits to mobile & native apps, as you don't have to wait for an approvals process for every change. ✨ 77 | 78 | 81 | 82 | ## Why Rules Engines? 83 | 84 | Typically App Logic & Business Rules are woven together throughout the project. This co-location of logic is usually helpful, keeping things readable in small and even mid-sized projects. 85 | 86 | This works great, until you run into one of the following challenges: 87 | 88 | 1. **Storing Rules** 89 | - A note taking app could let users create custom shortcuts, where typing "TODO" could load a template. 90 | - These "shortcuts" (JSON Rules) can be stored in a local file, synced to a database, or even broadcast over a mesh network. 91 | 2. **Unavoidable Complexity** 92 | - In many industries like healthcare, insurance, finance, etc. it's common to find 100's or 1,000s of rules run on every transaction. 93 | - Over time, "Hand-coded Rules" can distract & obscure from core App Logic. 94 | - Example: Adding a feature to a `DepositTransaction` controller shouldn't require careful reading of 2,000 lines of custom rules around currency hackery & country-code checks. 95 | - Without a strategy, code eventually sprawls as logic gets duplicated & placed arbitrarily. Projects become harder to understand, risky to modify, and adding new rules become high-stakes exercises. 96 | 3. **Tracing Errors or Miscalculations** 97 | - Complex pricing, taxes & discount policies can be fully "covered" by unit tests, yet still fail in surprising ways. 98 | - Determining how a customer's subtotal WAS calculated after the fact can be tedious & time consuming. 99 | 100 |
101 | 102 | Additional Scenarios & Details 103 | 104 | - Example: Sales tax rates and rules are defined by several layers of local government. (Mainly City, County, and State.) 105 | - Depending on the State rules, you'll need to calculate based on the Billing Address or Shipping Address. 106 | - Scenario: A California customer has expanded into Canada. Their new shipping destination seems to cause double taxation!?! 107 | - In this situation, a trace of the computations can save hours of dev work, boost Customer Support' confidence issuing a partial refund, and the data team can use the raw data to understand the scope of the issue. 108 | - Scenario: "Why did we approve a $10,000,000 loan for 'The Joker'?" 109 | - Scenario: "How did an Ultra Sports Car ($1M+) qualify for fiscal hardship rates?" 110 | 111 |
112 | 113 | 114 | 115 | ### Pros 116 | 117 | - Uses a subset of JavaScript and structured JSON object(s). 118 | - Easy to start using & experimenting with, larger implementations require more planning. 119 | - Provides a `trace`, with details on each step, what happened, and the time taken. 120 | 121 | ### Cons 122 | 123 | - Sizable projects require up-front planning & design work to properly adapt this pattern. (1,000s rules, for example.) 124 | - Possible early optimization or premature architecture decision. 125 | - Not as easy to write compared to a native language. 126 | 127 | ## Install 128 | 129 | ```bash 130 | yarn add @elite-libs/rules-machine 131 | # Or 132 | npm install @elite-libs/rules-machine 133 | ``` 134 | 135 | ## Usage 136 | 137 | ```ts 138 | import { ruleFactory } from '@elite-libs/rules-machine'; 139 | 140 | const fishRhyme = ruleFactory([ 141 | { if: 'fish == "oneFish"', then: 'fish = "twoFish"' }, 142 | { if: 'fish == "redFish"', then: 'fish = "blueFish"' }, 143 | ]); 144 | // Equivalent to: 145 | // if (fish == "oneFish") fish = "twoFish" 146 | // if (fish == "redFish") fish = "blueFish" 147 | 148 | fishyRhyme({ fish: 'oneFish' }); // {fish: 'twoFish'} 149 | ``` 150 | 151 | ## Examples 152 | 153 | ### Example Rule: Apply Either $5 or $10 Discount 154 | 155 | ```json 156 | // Using "and" object style operator 157 | [ 158 | {"if": {"and": ["price >= 25", "price <= 50"]}, "then": "discount = 5"}, 159 | {"if": "price > 50", "then": "discount = 10"}, 160 | {"return": "discount"} 161 | ] 162 | // Using inline AND operator 163 | [ 164 | {"if": "price >= 25 AND price <= 50", "then": "discount = 5"}, 165 | {"if": "price > 50", "then": "discount = 10"}, 166 | {"return": "discount"} 167 | ] 168 | ``` 169 | 170 |
171 | Show YAML 172 | 173 | ```yaml 174 | - if: { and: [price >= 25, price <= 50] } 175 | then: discount = 5 176 | - if: price > 50 177 | then: discount = 10 178 | - return: discount 179 | ``` 180 | 181 |
182 | 183 | ### Example Rule: Apply $15 Discount if Employee, or Premium Customer 184 | 185 | ```json 186 | [ 187 | { 188 | "if": "user.plan == \"premium\"", 189 | "then": "discount = 15" 190 | }, 191 | { 192 | "if": "user.employee == true", 193 | "then": "discount = 15" 194 | }, 195 | { 196 | "return": "discount" 197 | } 198 | ] 199 | ``` 200 | 201 | ### Example Rule: Multiple Conditional 202 | 203 | ```json 204 | [ 205 | { 206 | "if": "price <= 100", 207 | "then": "discount = 5" 208 | }, 209 | { 210 | "if": { 211 | "or": ["price >= 100", "user.isAdmin == true"] 212 | }, 213 | "then": "discount = 20" 214 | }, 215 | { 216 | "return": "discount" 217 | } 218 | ] 219 | ``` 220 | 221 |
222 | Show YAML 223 | 224 | ```yaml 225 | - if: price <= 100 226 | then: discount = 5 227 | - if: 228 | or: [price >= 100, user.isAdmin == true] 229 | then: discount = 20 230 | - return: discount 231 | ``` 232 | 233 |
234 | 235 | ### Example Rule: Use variable between rules 236 | 237 | ```json 238 | [ 239 | { 240 | "if": "price <= 100", 241 | "then": ["discount = 5", "user.discountApplied = true"] 242 | }, 243 | { 244 | "if": { 245 | "and": ["price >= 90", "user.discountApplied != true"] 246 | }, 247 | "then": "discount = 20" 248 | }, 249 | { 250 | "return": "discount" 251 | } 252 | ] 253 | ``` 254 | 255 |
256 | Show YAML 257 | 258 | ```yaml 259 | - if: price <= 100 260 | then: 261 | - discount = 5 262 | - user.discountApplied = true 263 | - if: 264 | and: 265 | - price >= 90 266 | - user.discountApplied != true 267 | then: discount = 20 268 | - return: discount 269 | ``` 270 | 271 |
272 | 273 | ## Rules API 274 | 275 | ### Array Expressions 276 | 277 | #### `map` 278 | 279 | ```js 280 | const doubleList = ruleFactory([ 281 | { 282 | map: 'list', 283 | run: '$item * 2', 284 | set: 'list', 285 | }, 286 | ]); 287 | doubleList({ list: [1, 2, 3, 4] }); 288 | // [2, 4, 6, 8] 289 | ``` 290 | 291 | #### `filter` 292 | 293 | ```js 294 | const multiplesOfThree = ruleFactory([ 295 | { 296 | filter: 'list', 297 | run: '$item % 3 == 0', 298 | set: 'results', 299 | }, 300 | { return: 'results' } 301 | ]); 302 | multiplesOfThree({ list: [1, 2, 3, 4] }); 303 | // [3] 304 | ``` 305 | 306 | #### `find` 307 | 308 | ```ts 309 | const getFirstMultipleOfThree = ruleFactory([ 310 | { 311 | find: 'list', 312 | run: '$item % 3 == 0', 313 | set: 'results', 314 | }, 315 | { return: 'results' } 316 | ]); 317 | getFirstMultipleOfThree({list: [1, 2, 3, 4]}) 318 | // 3 319 | getFirstMultipleOfThree({list: [9, 3, 4]}) 320 | // 9 321 | getFirstMultipleOfThree({list: [99]}) 322 | // undefined 323 | ``` 324 | 325 | #### `every` 326 | 327 | ```ts 328 | const isEveryNumberMultipleOfThree = ruleFactory([ 329 | { 330 | every: 'list', 331 | run: '$item % 3 == 0', 332 | set: 'results', 333 | }, 334 | { return: 'results' } 335 | ]); 336 | isEveryNumberMultipleOfThree({list: [3, 6, 9]}) 337 | // true 338 | isEveryNumberMultipleOfThree({list: [3, 6, 9, 10]}) 339 | // false 340 | ``` 341 | 342 | #### `some` 343 | 344 | ```ts 345 | const hasEvenNumbers = ruleFactory([ 346 | { 347 | some: 'list', 348 | run: '2 % $item == 0', 349 | set: 'results', 350 | }, 351 | { return: 'results' } 352 | ]); 353 | hasEvenNumbers({list: [2, 4]}) 354 | // true 355 | hasEvenNumbers({list: [2, 4, 5]}) 356 | // true 357 | hasEvenNumbers({list: [5]}) 358 | // false 359 | ``` 360 | 361 | #### `if/then` 362 | 363 | ```ts 364 | const calculateDiscount = ruleFactory([ 365 | {"if": {"and": ["price >= 25", "price <= 50"]}, "then": "discount = 5"}, 366 | {"if": "price > 50", "then": "discount = 10"}, 367 | {"return": "discount"} 368 | ]); 369 | calculateDiscount({price: 40, discount: 0}) 370 | // 5 371 | calculateDiscount({price: 60, discount: 0}) 372 | // 10 373 | ``` 374 | 375 | #### `and/or` 376 | 377 | ```ts 378 | const isScoreValid = ruleFactory({ 379 | "if": {"and": ["score > 0", "score <= 100"]}, 380 | "then": "valid = true", 381 | "else": "valid = false", 382 | }) 383 | isScoreValid({score: 10}) 384 | // { score: 10, valid: true }} 385 | isScoreValid({score: -10}) 386 | // { score: 10, valid: false }} 387 | isScoreValid({score: 101}) 388 | // { score: 10, valid: false }} 389 | ``` 390 | 391 | #### `try/catch` 392 | 393 | Execute string rule from `try`. Handle errors in the `catch` expression. 394 | 395 | ```js 396 | [ 397 | { 398 | try: 'THROW "error"', 399 | catch: 'status = "Failure"', 400 | }, 401 | { return: 'status' }, // returns "Failure" 402 | ] 403 | ``` 404 | 405 | #### `return` 406 | 407 | Ends rule execution, returning the specified value. 408 | 409 | ```js 410 | [ 411 | { return: '"blue"' }, // returns "blue" 412 | { return: '"green"' }, // is not executed 413 | ] 414 | ``` 415 | 416 | ### Builtin Operators 417 | 418 | 1. `!=` 419 | 1. `=` - equality check. 420 | 1. `==` - equality check. 421 | 1. `<` 422 | 1. `<=` 423 | 1. `<>` 424 | 1. `>` 425 | 1. `>=` 426 | 1. `%` - `10 % 2` => `0` (tip: odd/even check) 427 | 1. `*` - `42 * 10` => `420` 428 | 1. `+` - `42 + 10` => `52` 429 | 1. `-` 430 | 1. `/` 431 | 1. `^` 432 | 1. `~=` 433 | 1. `AND` - this does not short circuit if the first operand is false, but the object form does. 434 | 1. `OR` - this does not short circuit if the first operand is true, but the object form does. 435 | 436 | ### Functions 437 | 438 | #### Extended Methods 439 | 440 | 1. `REMOVE_VALUES(matches, input)` - will remove all values matching the item(s) in the 1st argument from the 2nd argument array. (XOR operation.) 441 | 1. `FILTER_VALUES(matches, input)` - will ONLY INCLUDE values that are in the 1st & 2nd arguments. (Intersection operation.) 442 | 1. `CONTAINS(42, [41, 42, 43])` => `true` 443 | 444 | #### Utility Functions 445 | 446 | 1. IF() - `IF(7 > 5, 8, 10)` => `8` 447 | 1. GET() - `GET('users[2].name', users)` => `Mary` 448 | 449 | 452 | 453 | #### Math Functions: Core 454 | 455 | 1. AVERAGE() - `AVERAGE([10, 20, 30])` => `20` 456 | 1. CEIL() - `CEIL(0.1)` => `1` 457 | 1. FLOOR() - `FLOOR(1.9)` => `1` 458 | 1. ROUND() - `FLOOR(0.6)` => `1` 459 | 1. TRUNC() - `TRUNC(1.9)` => `1` 460 | 1. SUM() - `SUM([1,2,3])` => `6` 461 | 1. ADD() - `ADD(2, 3)` => `5` 462 | 1. SUB() - `SUB(2, 3)` => `-1` 463 | 1. DIV() - `DIV(9, 3)` => `3` 464 | 1. MUL() - `MUL(3, 3)` => `9` 465 | 1. NEG() - `NEG(ADD(1, 2))` => `-3` 466 | 1. NOT() - `NOT(ISPRIME(7))` => `false` 467 | 1. ISNAN() - `ISNAN('hai')` => `true` 468 | 1. ISPRIME() - `ISPRIME(7)` => `true` 469 | 1. MOD() - `MOD(10, 2)` => `0` 470 | 1. GCD() - `GCD(9, 3)` => `3` 471 | 472 | #### Array Functions 473 | 474 | 1. SLICE() - `SLICE(1, 3, [1, 42, 69, 54])` => `[42, 69]` 475 | 1. LENGTH() - `LENGTH([42, 69, 54])` => `3` 476 | 1. SORT() - `SORT([2,2,1])` => `[1, 2, 2]` 477 | 1. FILTER() - `FILTER(isEven, [1,2,3,4,5,6])` => `[2, 4, 6]` 478 | 1. INDEX() - `INDEX([42, 69, 54], 0)` => `42` 479 | 1. MAP() - `MAP("NOT", [FALSE, TRUE, FALSE])` => `[true, false, true]` 480 | 1. MIN() - `MIN([42, 69, 54])` => `42` 481 | 1. MAX() - `MAX([42, 69, 54])` => `69` 482 | 1. HEAD() - `HEAD([42, 69, 54])` => `42` 483 | 1. LAST() - `LAST([42, 69, 54])` => `54` 484 | 1. TAIL() - `TAIL([42, 69, 54])` => `[69, 54]` 485 | 1. TAKE() - `TAKE(2, [42, 69, 54])` => `[42, 69]` 486 | 1. TAKEWHILE() - `TAKEWHILE(isEven, [0,2,4,5,6,7,8])` => `[0, 2, 4]` 487 | 1. DROP() - `DROP(2, [1, 42, 69, 54])` => `[69, 54]` 488 | 1. DROPWHILE() - `DROPWHILE(isEven, [0,2,4,5,6,7,8])` => `[5,6,7,8]` 489 | 1. REDUCE() - `REDUCE("ADD", 0, [1, 2, 3])` => `6` 490 | 1. REVERSE() - `REVERSE([1,2,2])` => `[2, 2, 1]` 491 | 1. CHARARRAY() - `CHARARRAY("abc")` => `['a', 'b', 'c']` 492 | 1. CONCAT() - `CONCAT([42, 69], [54])` => `[42, 69, 54]` 493 | 1. CONS() - `CONS(2, [3, 4])` => `[2, 3, 4]` 494 | 1. JOIN() - `JOIN(",", ["a", "b"])` => `a,b` 495 | 1. RANGE() - `RANGE(0, 5)` => `[0, 1, 2, 3, 4]` 496 | 1. UNZIPDICT() - `UNZIPDICT([["a", 1], ["b", 5]])` => `{a: 1, b: 5}` 497 | 1. ZIP() - `ZIP([1, 3], [2, 4])` => `[[1, 2], [3, 4]]` 498 | 499 | #### Object Functions 500 | 501 | 1. DICT() - `DICT(["a", "b"], [1, 4])` => `{a: 1, b: 4}` 502 | 1. KEYS() - `KEYS(DICT(["a", "b"], [1, 4]))` => `['a', 'b']` 503 | 1. VALUES() - `VALUES(DICT(["a", "b"], [1, 4]))` => `[1, 4]` 504 | 1. UNZIP() - `UNZIP([[1, 2], [3, 4]])` => `[[1, 3], [2, 4]]` 505 | 1. CONTAINS() - `CONTAINS("x", {x: 1})` => `true` 506 | 1. COUNT_KEYS() - `COUNT_KEYS({x: 1})` => `1` 507 | 1. OMIT() - `OMIT("x", {x: 1})` => `{}` 508 | 509 | #### String Functions 510 | 511 | 1. LOWER() - `LOWER('HELLO')` => `hello` 512 | 1. UPPER() - `UPPER('hello')` => `HELLO` 513 | 1. SPLIT() - `SPLIT(',', 'a,b')` => `['a', 'b']` 514 | 1. CHAR() - `CHAR(65)` => `A` 515 | 1. CODE() - `CODE('A')` => `65` 516 | 1. BIN2DEC() - `BIN2DEC('101010')` => `42` 517 | 1. DEC2BIN() - `DEC2BIN(42)` => `101010` 518 | 1. DEC2HEX() - `DEC2HEX('42')` => `2a` 519 | 1. DEC2STR() - `DEC2STR('42')` => `42` 520 | 1. HEX2DEC() - `HEX2DEC("F")` => `15` 521 | 1. STR2DEC() - `STR2DEC('42')` => `42` 522 | 1. STRING_CONTAINS() - `STRING_CONTAINS("lo wo", "hello world")` => `true`, note: this function does not currently accept regular expressions 523 | 1. STRING_ENDS_WITH() - `STRING_ENDS_WITH("rld", "hello world")` => `true`, note: this function does not currently accept regular expressions 524 | 1. STRING_STARTS_WITH() - `STRING_STARTS_WITH("hell", "hello world")` => `true`, note: this function does not currently accept regular expressions 525 | 526 | #### Math Functions: Advanced 527 | 528 | 1. SQRT() 529 | 1. CUBEROOT() 530 | 1. SIGN() - `SIGN(-42)` => `-1` 531 | 1. ABS() - `ABS(-42)` => `42` 532 | 1. ACOS() 533 | 1. ACOSH() 534 | 1. ASIN() 535 | 1. ASINH() 536 | 1. ATAN() 537 | 1. ATAN2() 538 | 1. ATANH() 539 | 1. COS() 540 | 1. COSH() 541 | 1. DEGREES() 542 | 1. RADIANS() 543 | 1. SIN() 544 | 1. SINH() 545 | 1. TAN() 546 | 1. TANH() 547 | 1. EXP() 548 | 1. LN() 549 | 1. LOG() 550 | 1. LOG2() 551 | 552 | #### Utility 553 | 554 | 1. THROW() - Will throw an error. Expects a string. Cannot be (ab)used for flow control **_yet_**. 555 | `THROW("my error") => PARSER FAIL: Error: my error` 556 | 557 | ## More Reading & Related Projects 558 | 559 | - [Should I use a Rules Engine?](https://martinfowler.com/bliki/RulesEngine.html) 560 | - [JSON Rules Engine](https://www.npmjs.com/package/json-rules-engine). 561 | - GitHub Actions YAML conditional syntax. 562 | 563 | ## TODO 564 | 565 | - [ ] [Web app to test & build rules.](https://github.com/elite-libs/rules-machine/issues/29) 566 | - [ ] [Design async data injection mechanism](https://github.com/elite-libs/rules-machine/issues/28) 567 | - [x] ~~Return result by default, make trace and metadata opt-in via options.~~ 568 | - [x] Add arithmetic & function support to expression parser. 569 | - Over 80 builtin functions supported. 570 | - [x] Publish modules for CJS, ESM, AMD, UMD. 571 | - [x] misc: Structured Type validation. 572 | - [x] security: NEVER use `eval`/`Function('...')` parsing. 573 | - [x] misc: Simplify TS, making `Rule[]` the sole recursive type. 574 | - [x] misc: Use reduced JS syntax, scope. 575 | - [x] misc: Use single object for input and output. (Doesn't mutate input.) 576 | - [x] misc: Add support for multiple boolean expressions. (see: `{"and": []}` `{"or": []}`). 577 | - [x] misc: Rules are serializable, and can be shared. 578 | - [x] rule type: `{"try": "rules", "catch": {"return": "error"}}` 579 | - [ ] rule type: `{"run": Rule[] | Rule | "ruleSetName"}` 580 | - [ ] rule type: `{"log": "rule/value expression"}` 581 | - [ ] rule type: `{"set": "newVar = value"}` 582 | - [x] Disallow input keys that can cause weirdness: `undefined`, `valueOf`, `toString`, `__proto__`, `constructor`. 583 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | .DS_Store 11 | *.lock 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | .esbuild 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | .cache 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/README.md: -------------------------------------------------------------------------------- 1 | # Rules Machine Lambda Service 2 | 3 | Example of a Lambda service that uses the Rules Machine library. 4 | 5 | To keep this example simple, rules are hard-coded in the [`/rules/app-rules.ts`](/rules/app-rules.ts) file. 6 | 7 | ## Usage 8 | 9 | ### Deployment 10 | 11 | In order to deploy the example, you need to run the following command: 12 | 13 | ```bash 14 | serverless deploy 15 | ``` 16 | 17 | After running deploy, you should see output similar to: 18 | 19 | ```bash 20 | Deploying rules-machine-service to stage dev (us-east-1) 21 | 22 | ✔ Service deployed to stack rules-machine-service-dev (112s) 23 | 24 | functions: 25 | rules: rules-machine-service-dev-rules (806 B) 26 | ``` 27 | 28 | ### Invocation 29 | 30 | After successful deployment, you can invoke the deployed function by using the following command: 31 | 32 | ```bash 33 | serverless invoke --function rules 34 | ``` 35 | 36 | ### Local development 37 | 38 | You can invoke your function locally by using the following command: 39 | 40 | ```bash 41 | serverless invoke local --function rules 42 | ``` 43 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/handler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/quotes */ 2 | import { 3 | Handler, 4 | APIGatewayProxyResult, 5 | APIGatewayProxyEventV2WithRequestContext, 6 | Context, 7 | } from 'aws-lambda'; 8 | import { rulesMachineFactory } from './lib'; 9 | import { RulesCallback } from './lib/types'; 10 | import appRules from './rules/app-rules'; 11 | 12 | const rulesMachine: Record = 13 | rulesMachineFactory(appRules); 14 | 15 | const ruleNames = Object.keys(rulesMachine); 16 | 17 | export const rules: Handler< 18 | APIGatewayProxyEventV2WithRequestContext, 19 | APIGatewayProxyResult 20 | > = async (event) => { 21 | const { body, pathParameters, rawPath } = event; 22 | if (rawPath.length <= 1) return helpInfo(); 23 | if (!checkPayload(body)) return { statusCode: 400, body: 'Invalid body' }; 24 | 25 | const ruleName = pathParameters?.namedRule; 26 | if (!ruleName || !ruleNames.includes(ruleName)) { 27 | return { 28 | body: `Invalid rule name: ${ruleName}. Valid rule names are:
\n/${ruleNames.join( 29 | ', /', 30 | )}`, 31 | statusCode: 400, 32 | headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' }, 33 | }; 34 | } 35 | 36 | let result: unknown; 37 | try { 38 | const input = body ? JSON.parse(body) : {}; 39 | result = rulesMachine[ruleName](input); 40 | console.log('input', input); 41 | console.log('result', result); 42 | } catch (error) { 43 | console.error(error); 44 | return { 45 | body: `Error running rule: ${ruleName}. Error: ${error.message}`, 46 | statusCode: 500, 47 | }; 48 | } 49 | 50 | return { 51 | body: JSON.stringify(result), 52 | statusCode: 200, 53 | headers: { 54 | 'x-rule-name': ruleName, 55 | 'content-type': 'application/json', 56 | }, 57 | }; 58 | }; 59 | 60 | const helpInfo = () => ({ 61 | body: ` 62 | 63 | Example Rules Engine Service 64 | 65 | 66 |

Welcome to a Rules Service

67 |

Try POST to the following endpoints:
\n/${Object.keys( 68 | rulesMachine, 69 | ).join('
\n/')} 70 |

71 | 72 | `, 73 | statusCode: 200, 74 | headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' }, 75 | }); 76 | 77 | const checkPayload = (body: unknown) => { 78 | if (!body) throw Error('No body provided'); 79 | if (typeof body !== 'string') throw Error('Body is not a string'); 80 | if (body.length > 10_000) throw Error('Body exceeded 10,000 characters'); 81 | return true; 82 | }; 83 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/lib/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable no-param-reassign */ 3 | import { ruleFactory } from '@elite-libs/rules-machine'; 4 | import { mapValues, flow } from 'lodash'; 5 | import { tap } from 'lodash/fp'; 6 | import { inputAdapter, outputAdapter } from './transformers'; 7 | import { RuleMapping, RulesCallback } from './types'; 8 | 9 | /** 10 | * 11 | * ### Examples 12 | * 13 | * To run `rules` named `search` rule set: 14 | * 15 | * ```ts 16 | * import appRules from './rules/app-rules'; 17 | * 18 | * export const rulesMachine: Record = rulesMachineFactory(appRules); 19 | * 20 | * const result = rulesMachine.getDiscount<{ price: number }>(input); 21 | * result.price; // TS sees the type you annotated above! 22 | * ``` 23 | * 24 | * ### Overview 25 | * 26 | * `rulesMachineFactory` converts a dictionary of labeled rules into an object with named functions corresponding with their rules. 27 | * 28 | * The `RuleMapping` and `rulesEngineFactory` help enhance the base Rules Engine functionality with pre & post data transformations. 29 | * 30 | * > Note: Lodash methods `get` and `set` used for reading & writing to structured data. 31 | * 32 | * - Each `RuleMapping` features: 33 | * - An `inputMap` to help utilize complex business domain objects, while keeping your Rules logic readable with flat objects. 34 | * - The `outputMap` is similar to `inputMap` except it copies values from the rules output back to the `input` object. (Mutates it in-place.) 35 | * 36 | */ 37 | export function rulesMachineFactory(ruleSet: Record) { 38 | return mapValues(ruleSet, convertRuleMapping); 39 | } 40 | 41 | function convertRuleMapping({ 42 | inputMap, 43 | outputMap, 44 | rules, 45 | }: RuleMapping): RulesCallback { 46 | return ( 47 | input: object, 48 | skipDataMapping = false, 49 | ): TOutput => { 50 | const processInputArgs = (inputArgs: object) => 51 | skipDataMapping ? inputArgs : inputAdapter(inputMap, inputArgs); 52 | const applyOutputUpdates = (result: any) => { 53 | if (!skipDataMapping && outputMap) 54 | outputAdapter(outputMap, result, input); 55 | }; 56 | 57 | return flow( 58 | processInputArgs, 59 | // @ts-expect-error 60 | ruleFactory(rules), 61 | tap(applyOutputUpdates), 62 | )(input) as unknown as TOutput; 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/lib/transformers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { get, set, isString, isBoolean, isObjectLike, isArray } from 'lodash'; 3 | import { RuleMapping, FieldKeyMapping, FieldPath } from './types'; 4 | 5 | /** 6 | * 7 | * ### Extracting the first element from an array of objects. 8 | * 9 | * ```ts 10 | * const input = { traceId: 123, users: [{name: 'Mary'}, {name: 'John'}] }; 11 | * const inputMap = { firstUser: 'users[0]', trace: 'traceId' }; 12 | * inputAdapter(inputMap, input); 13 | * //-> { firstUser: { name: 'Mary' }, trace: 123 } 14 | * ``` 15 | * 16 | * 17 | * ### Unwrapping nested date: 18 | * 19 | * ```ts 20 | * const input = { wrapper: { id: 123 } }; 21 | * const inputMap = { extractedId: 'wrapper.id' }; 22 | * 23 | * inputAdapter(inputMap, input); 24 | * //-> { extractedId: 123 } 25 | * ``` 26 | * 27 | * @param inputMap defines the keys to return, and the values to lookup in the input object. 28 | * @param input 29 | * @returns 30 | */ 31 | export function inputAdapter( 32 | inputMap: FieldKeyMapping, 33 | input: TInput, 34 | ): FieldKeyMapping { 35 | if (isArray(input)) input.map((key: string) => get(input, key)); 36 | 37 | return Object.fromEntries( 38 | Object.entries(inputMap).map( 39 | ([key, value]) => [ 40 | key, 41 | isString(value) 42 | ? get(input, value) 43 | : isBoolean(value) 44 | ? get(input, key) 45 | : isObjectLike(value) 46 | ? inputAdapter(value, input) 47 | : undefined, 48 | ], 49 | ), 50 | ); 51 | } 52 | 53 | /** 54 | * This method will mutate the `targetOutput` object. 55 | * 56 | * Allows for 'saving' changes back to the **ORIGINAL** rules input. 57 | * 58 | * @param keyMapping A map of input keys to their key paths from the input object. 59 | * @param inputSource The resulting output from running a set of rule(s). 60 | * @param targetOutput The object to merge the key map values into. 61 | * @returns 62 | */ 63 | export function outputAdapter< 64 | TInput extends Record, 65 | TOutput extends object, 66 | >( 67 | keyMapping: RuleMapping['outputMap'], 68 | inputSource: TInput, 69 | targetOutput: TOutput, 70 | ) { 71 | if (typeof keyMapping === 'string') keyMapping = { [keyMapping]: true }; 72 | return Object.entries(keyMapping!).reduce((output, [toKey, fromKey]) => { 73 | set( 74 | output, 75 | toKey, 76 | fromKey === true 77 | ? inputSource 78 | : typeof fromKey === 'string' 79 | ? get(inputSource, fromKey) 80 | : undefined, 81 | ); 82 | return output; 83 | }, targetOutput); 84 | } 85 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from '@elite-libs/rules-machine'; 2 | 3 | export type FieldPath = string | true; 4 | export interface FieldKeyMapping { 5 | [key: string]: FieldPath | FieldKeyMapping; 6 | } 7 | 8 | /** 9 | * An associated set of rules & their needed input values. 10 | */ 11 | export interface RuleMapping { 12 | /** 13 | * A object, array or string describing a set of logical `Rule`'s 14 | */ 15 | rules: Readonly; 16 | /** 17 | * A map of input keys to their key paths from the input object. 18 | */ 19 | inputMap?: FieldKeyMapping; 20 | /** 21 | * Values merged to the output of the rules. 22 | * 23 | * When outputMap is an object, the keys are the source value path and the values are the input value's keys. 24 | * ``` 25 | * { 26 | * fromKey: 'toKey.path.string[0]' 27 | * } 28 | * ``` 29 | * 30 | * When outputMap is a string, it's treated as a destination path for the entire rules output. 31 | */ 32 | outputMap?: string | FieldKeyMapping; 33 | } 34 | 35 | export type RulesCallback = ( 36 | input: Readonly, 37 | skipDataMapping?: boolean, 38 | ) => TOutput; 39 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rules-machine-service", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@types/aws-lambda": "^8.10.102", 14 | "esbuild": "^0.15.5", 15 | "serverless": "^3.22.0", 16 | "serverless-esbuild": "^1.32.8", 17 | "serverless-offline": "^9.2.6" 18 | }, 19 | "dependencies": { 20 | "@elite-libs/rules-machine": "^1.4.7", 21 | "aws-lambda": "^1.0.7" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/rules/app-rules.ts: -------------------------------------------------------------------------------- 1 | import { RuleMapping } from '../lib/types'; 2 | 3 | const appRules: Readonly> = { 4 | getDiscount: { 5 | rules: [ 6 | { if: { and: ['price >= 25', 'price <= 50'] }, then: 'discount = 5' }, 7 | { if: 'price >= 100', then: 'discount = 20' }, 8 | { return: 'discount' }, 9 | ], 10 | }, 11 | }; 12 | 13 | export default appRules; 14 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/serverless.yml: -------------------------------------------------------------------------------- 1 | # TODO: change to your service name: 2 | service: rules-machine-service 3 | useDotEnv: true 4 | 5 | frameworkVersion: "3" 6 | provider: 7 | name: aws 8 | deploymentMethod: direct 9 | runtime: nodejs16.x 10 | stage: ${opt:stage, env:STAGE_NAME, 'beta'} 11 | region: us-west-1 12 | architecture: arm64 13 | memorySize: 512 14 | timeout: 10 15 | 16 | plugins: 17 | - serverless-esbuild 18 | - serverless-offline 19 | 20 | functions: 21 | rules: 22 | handler: handler.rules 23 | events: 24 | - httpApi: 25 | path: /{namedRule} 26 | method: post 27 | # cors: true 28 | - httpApi: "*" 29 | -------------------------------------------------------------------------------- /examples/aws-lambda-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "allowJs": true, 8 | "allowSyntheticDefaultImports": true, 9 | "alwaysStrict": true, 10 | "baseUrl": ".", 11 | "declaration": true, 12 | "declarationMap": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "resolveJsonModule": true, 17 | "rootDir": "./", 18 | "skipLibCheck": true, 19 | "sourceMap": true, 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "noErrorTruncation": true, 23 | "useUnknownInCatchVariables": false 24 | }, 25 | "include": ["**/*.ts", "./.*.js"], 26 | "exclude": ["**/node_modules", "__snapshots__", "**/dist"] 27 | } 28 | -------------------------------------------------------------------------------- /img/rules-machine-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elite-libs/rules-machine/a8a27307b40dcdac8ee52ddd23cd02f2edd08fb0/img/rules-machine-header.png -------------------------------------------------------------------------------- /img/rules-machine-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /img/rules-machine-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elite-libs/rules-machine/a8a27307b40dcdac8ee52ddd23cd02f2edd08fb0/img/rules-machine-logo.png -------------------------------------------------------------------------------- /img/rules-machine-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | // Sync object 4 | const config: Config.InitialOptions = { 5 | rootDir: '.', 6 | preset: 'ts-jest', 7 | verbose: true, 8 | collectCoverage: true, 9 | // testPathIgnorePatterns: ["node_modules", "dist"], 10 | testPathIgnorePatterns: ['dist'], 11 | resetMocks: true, 12 | resetModules: true, 13 | globals: { 14 | 'ts-jest': { 15 | useESM: true, 16 | }, 17 | }, 18 | // testEnvironment: 'jsdom', 19 | testEnvironment: 'node', 20 | transform: { 21 | // "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 22 | // "/src/jestFileTransformer.js", 23 | }, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elite-libs/rules-machine", 3 | "version": "1.6.0", 4 | "description": "📐 A fast serializable logical Rules Engine.", 5 | "type": "commonjs", 6 | "homepage": "https://github.com/elite-libs/rules-machine", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/elite-libs/rules-machine.git" 10 | }, 11 | "source": "./src/index.ts", 12 | "main": "./dist/index.cjs", 13 | "module": "./dist/index.mjs", 14 | "browser": "./dist/index.global.js", 15 | "types": "./dist/index.d.ts", 16 | "private": false, 17 | "packageManager": "yarn@1.22.17", 18 | "engineStrict": true, 19 | "engines": { 20 | "yarn": ">=1.22.17", 21 | "node": ">=12.0.0" 22 | }, 23 | "scripts": { 24 | "build": "npx rimraf ./dist/* && ./scripts/build.sh", 25 | "prepublishOnly": "NODE_ENV=production yarn run build && jest", 26 | "release:npm": "npm publish --access public --registry https://registry.npmjs.org/", 27 | "release:github": "npm publish --access public --registry https://npm.pkg.github.com/", 28 | "test": "yarn run build && jest", 29 | "test:coverage": "yarn run build && jest --coverage", 30 | "lint": "eslint .", 31 | "format": "prettier --write '**/*.{js,ts,tsx,css,scss}'" 32 | }, 33 | "keywords": [ 34 | "elite-libs", 35 | "elitelibs", 36 | "Rules Engine", 37 | "JSON Rules Engine", 38 | "JSON Rules", 39 | "JSON", 40 | "JSON-based", 41 | "JSON-based Rules Engine", 42 | "JSON-based Rules", 43 | "JSON-based Rules Engine", 44 | "YAML Rules Engine" 45 | ], 46 | "devDependencies": { 47 | "@types/debug": "^4.1.7", 48 | "@types/jest": "26.x.x", 49 | "@types/lodash": "^4.14.178", 50 | "@types/node": "^14.0.0", 51 | "@typescript-eslint/eslint-plugin": "^5.42.0", 52 | "@typescript-eslint/parser": "^5.42.0", 53 | "@typescript-eslint/utils": "^5.42.0", 54 | "eslint": "^8.26.0", 55 | "eslint-config-prettier": "^8.5.0", 56 | "eslint-config-standard-with-typescript": "^23.0.0", 57 | "eslint-plugin-import": "^2.26.0", 58 | "eslint-plugin-n": "^15.4.0", 59 | "eslint-plugin-node": "^11.1.0", 60 | "eslint-plugin-promise": "^6.1.1", 61 | "jest": "26.x.x", 62 | "prettier": "^2.7.1", 63 | "ts-jest": "26.x.x", 64 | "ts-node": "^10.9.1", 65 | "tsup": "^6.4.0", 66 | "typescript": "4.8.4" 67 | }, 68 | "dependencies": { 69 | "debug": "^4.3.3", 70 | "expressionparser": "^1.1.5", 71 | "lodash": "^4.17.21", 72 | "ms": "^2.1.3" 73 | }, 74 | "author": { 75 | "name": "@justsml", 76 | "url": "https://danlevy.net" 77 | }, 78 | "license": "BSD-3-Clause", 79 | "files": [ 80 | "dist", 81 | "*.md" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | yarn tsup 3 | 4 | # If "type": "commonjs" - then we need to adjust the .js ext to .cjs 5 | cp dist/index.js dist/index.cjs 6 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Functions can use CONTAINS array function 1`] = ` 4 | Array [ 5 | Object { 6 | "operation": "begin", 7 | "rule": undefined, 8 | }, 9 | Object { 10 | "key": "cities", 11 | "operation": "key.lookup", 12 | "rule": undefined, 13 | "stepCount": 1, 14 | "stepRow": 0, 15 | "value": Array [ 16 | "Denver", 17 | "London", 18 | "LA", 19 | ], 20 | }, 21 | Object { 22 | "lhs": "hasLondon", 23 | "operation": "evalRule", 24 | "previous": undefined, 25 | "result": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"hasLondon\\":true}", 26 | "rule": "hasLondon = CONTAINS(\\"London\\", cities)", 27 | "stepCount": 1, 28 | "stepRow": 0, 29 | "value": true, 30 | }, 31 | Object { 32 | "currentState": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"hasLondon\\":true}", 33 | "operation": "ruleString", 34 | "result": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"hasLondon\\":true}", 35 | "rule": "hasLondon = CONTAINS(\\"London\\", cities)", 36 | "stepCount": 1, 37 | "stepRow": 0, 38 | }, 39 | Object { 40 | "currentState": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"hasLondon\\":true}", 41 | "lastValue": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"hasLondon\\":true}", 42 | "operation": "complete", 43 | "rule": undefined, 44 | "stepCount": 1, 45 | "stepRow": 1, 46 | }, 47 | ] 48 | `; 49 | 50 | exports[`Custom Functions can use functional array helpers 1`] = ` 51 | Array [ 52 | Object { 53 | "operation": "begin", 54 | "rule": undefined, 55 | }, 56 | Object { 57 | "key": "cities", 58 | "operation": "key.lookup", 59 | "rule": undefined, 60 | "stepCount": 1, 61 | "stepRow": 0, 62 | "value": Array [ 63 | "Denver", 64 | "London", 65 | "LA", 66 | ], 67 | }, 68 | Object { 69 | "lhs": "availableCities", 70 | "operation": "evalRule", 71 | "previous": undefined, 72 | "result": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"availableCities\\":[\\"Denver\\",\\"LA\\"]}", 73 | "rule": "availableCities = FILTER_VALUES([\\"London\\", \\"Milan\\"], cities)", 74 | "stepCount": 1, 75 | "stepRow": 0, 76 | "value": Array [ 77 | "Denver", 78 | "LA", 79 | ], 80 | }, 81 | Object { 82 | "currentState": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"availableCities\\":[\\"Denver\\",\\"LA\\"]}", 83 | "operation": "ruleString", 84 | "result": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"availableCities\\":[\\"Denver\\",\\"LA\\"]}", 85 | "rule": "availableCities = FILTER_VALUES([\\"London\\", \\"Milan\\"], cities)", 86 | "stepCount": 1, 87 | "stepRow": 0, 88 | }, 89 | Object { 90 | "key": "availableCities", 91 | "operation": "key.lookup", 92 | "rule": undefined, 93 | "stepCount": 2, 94 | "stepRow": 1, 95 | "value": Array [ 96 | "Denver", 97 | "LA", 98 | ], 99 | }, 100 | Object { 101 | "operation": "expression", 102 | "result": Array [ 103 | "Denver", 104 | "LA", 105 | ], 106 | "rule": "availableCities", 107 | "stepCount": 2, 108 | "stepRow": 1, 109 | }, 110 | Object { 111 | "currentState": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"availableCities\\":[\\"Denver\\",\\"LA\\"]}", 112 | "operation": "return", 113 | "result": "[\\"Denver\\",\\"LA\\"]", 114 | "rule": "availableCities", 115 | "stepCount": 2, 116 | "stepRow": 1, 117 | }, 118 | Object { 119 | "currentState": "{\\"cities\\":[\\"Denver\\",\\"London\\",\\"LA\\"],\\"onlyUSA\\":true,\\"availableCities\\":[\\"Denver\\",\\"LA\\"]}", 120 | "lastValue": "[\\"Denver\\",\\"LA\\"]", 121 | "operation": "complete", 122 | "rule": undefined, 123 | "stepCount": 2, 124 | "stepRow": 1, 125 | }, 126 | ] 127 | `; 128 | 129 | exports[`Custom Functions can use functions as expressions 1`] = `"2020-01-20T00:10:00.000Z"`; 130 | 131 | exports[`Edge cases should not lose input when conditional does match 1`] = ` 132 | Array [ 133 | Object { 134 | "operation": "begin", 135 | "rule": undefined, 136 | }, 137 | Object { 138 | "key": "input", 139 | "operation": "key.lookup", 140 | "rule": undefined, 141 | "stepCount": 1, 142 | "stepRow": 0, 143 | "value": 42, 144 | }, 145 | Object { 146 | "operation": "expression", 147 | "result": true, 148 | "rule": "input == 42", 149 | "stepCount": 1, 150 | "stepRow": 0, 151 | }, 152 | Object { 153 | "currentState": "{\\"input\\":42}", 154 | "operation": "if", 155 | "result": true, 156 | "rule": "input == 42", 157 | "stepCount": 1, 158 | "stepRow": 0, 159 | }, 160 | Object { 161 | "currentState": "{\\"input\\":42}", 162 | "operation": "if.then", 163 | "rule": "doSet = 9999", 164 | "stepCount": 1, 165 | "stepRow": 0, 166 | }, 167 | Object { 168 | "lhs": "doSet", 169 | "operation": "evalRule", 170 | "previous": undefined, 171 | "result": "{\\"input\\":42,\\"doSet\\":9999}", 172 | "rule": "doSet = 9999", 173 | "stepCount": 2, 174 | "stepRow": 0, 175 | "value": 9999, 176 | }, 177 | Object { 178 | "currentState": "{\\"input\\":42,\\"doSet\\":9999}", 179 | "operation": "ruleString", 180 | "result": "{\\"input\\":42,\\"doSet\\":9999}", 181 | "rule": "doSet = 9999", 182 | "stepCount": 2, 183 | "stepRow": 0, 184 | }, 185 | Object { 186 | "currentState": "{\\"input\\":42,\\"doSet\\":9999}", 187 | "lastValue": "{\\"input\\":42,\\"doSet\\":9999}", 188 | "operation": "complete", 189 | "rule": undefined, 190 | "stepCount": 2, 191 | "stepRow": 1, 192 | }, 193 | ] 194 | `; 195 | 196 | exports[`Edge cases should not lose input when conditional does not match 1`] = ` 197 | Array [ 198 | Object { 199 | "operation": "begin", 200 | "rule": undefined, 201 | }, 202 | Object { 203 | "key": "undefinedKey", 204 | "operation": "key.lookup", 205 | "rule": undefined, 206 | "stepCount": 1, 207 | "stepRow": 0, 208 | "value": undefined, 209 | }, 210 | Object { 211 | "operation": "expression", 212 | "result": false, 213 | "rule": "undefinedKey == false", 214 | "stepCount": 1, 215 | "stepRow": 0, 216 | }, 217 | Object { 218 | "currentState": "{\\"input\\":42}", 219 | "operation": "if", 220 | "result": false, 221 | "rule": "undefinedKey == false", 222 | "stepCount": 1, 223 | "stepRow": 0, 224 | }, 225 | Object { 226 | "currentState": "{\\"input\\":42}", 227 | "lastValue": "{\\"input\\":42}", 228 | "operation": "complete", 229 | "rule": undefined, 230 | "stepCount": 1, 231 | "stepRow": 1, 232 | }, 233 | ] 234 | `; 235 | 236 | exports[`Logical and/or can process 'and' rules 1`] = ` 237 | Array [ 238 | Object { 239 | "operation": "begin", 240 | "rule": undefined, 241 | }, 242 | Object { 243 | "key": "price", 244 | "operation": "key.lookup", 245 | "rule": undefined, 246 | "stepCount": 1, 247 | "stepRow": 0, 248 | "value": 35, 249 | }, 250 | Object { 251 | "operation": "expression", 252 | "result": true, 253 | "rule": "price >= 25", 254 | "stepCount": 1, 255 | "stepRow": 0, 256 | }, 257 | Object { 258 | "key": "price", 259 | "operation": "key.lookup", 260 | "rule": undefined, 261 | "stepCount": 2, 262 | "stepRow": 0, 263 | "value": 35, 264 | }, 265 | Object { 266 | "operation": "expression", 267 | "result": true, 268 | "rule": "price <= 50", 269 | "stepCount": 2, 270 | "stepRow": 0, 271 | }, 272 | Object { 273 | "currentState": "{\\"price\\":35}", 274 | "operation": "if.and", 275 | "result": true, 276 | "rule": Array [ 277 | "price >= 25", 278 | "price <= 50", 279 | ], 280 | "stepCount": 2, 281 | "stepRow": 0, 282 | }, 283 | Object { 284 | "currentState": "{\\"price\\":35}", 285 | "operation": "if.then", 286 | "rule": "discount = 5", 287 | "stepCount": 2, 288 | "stepRow": 0, 289 | }, 290 | Object { 291 | "lhs": "discount", 292 | "operation": "evalRule", 293 | "previous": undefined, 294 | "result": "{\\"price\\":35,\\"discount\\":5}", 295 | "rule": "discount = 5", 296 | "stepCount": 3, 297 | "stepRow": 0, 298 | "value": 5, 299 | }, 300 | Object { 301 | "currentState": "{\\"price\\":35,\\"discount\\":5}", 302 | "operation": "ruleString", 303 | "result": "{\\"price\\":35,\\"discount\\":5}", 304 | "rule": "discount = 5", 305 | "stepCount": 3, 306 | "stepRow": 0, 307 | }, 308 | Object { 309 | "key": "price", 310 | "operation": "key.lookup", 311 | "rule": undefined, 312 | "stepCount": 4, 313 | "stepRow": 1, 314 | "value": 35, 315 | }, 316 | Object { 317 | "operation": "expression", 318 | "result": false, 319 | "rule": "price >= 100", 320 | "stepCount": 4, 321 | "stepRow": 1, 322 | }, 323 | Object { 324 | "currentState": "{\\"price\\":35,\\"discount\\":5}", 325 | "operation": "if", 326 | "result": false, 327 | "rule": "price >= 100", 328 | "stepCount": 4, 329 | "stepRow": 1, 330 | }, 331 | Object { 332 | "key": "discount", 333 | "operation": "key.lookup", 334 | "rule": undefined, 335 | "stepCount": 5, 336 | "stepRow": 2, 337 | "value": 5, 338 | }, 339 | Object { 340 | "operation": "expression", 341 | "result": 5, 342 | "rule": "discount", 343 | "stepCount": 5, 344 | "stepRow": 2, 345 | }, 346 | Object { 347 | "currentState": "{\\"price\\":35,\\"discount\\":5}", 348 | "operation": "return", 349 | "result": 5, 350 | "rule": "discount", 351 | "stepCount": 5, 352 | "stepRow": 2, 353 | }, 354 | Object { 355 | "currentState": "{\\"price\\":35,\\"discount\\":5}", 356 | "lastValue": 5, 357 | "operation": "complete", 358 | "rule": undefined, 359 | "stepCount": 5, 360 | "stepRow": 2, 361 | }, 362 | ] 363 | `; 364 | 365 | exports[`Logical and/or can process 'or' rules 1`] = ` 366 | Array [ 367 | Object { 368 | "operation": "begin", 369 | "rule": undefined, 370 | }, 371 | Object { 372 | "key": "price", 373 | "operation": "key.lookup", 374 | "rule": undefined, 375 | "stepCount": 1, 376 | "stepRow": 0, 377 | "value": 35, 378 | }, 379 | Object { 380 | "operation": "expression", 381 | "result": true, 382 | "rule": "price <= 100", 383 | "stepCount": 1, 384 | "stepRow": 0, 385 | }, 386 | Object { 387 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true}}", 388 | "operation": "if", 389 | "result": true, 390 | "rule": "price <= 100", 391 | "stepCount": 1, 392 | "stepRow": 0, 393 | }, 394 | Object { 395 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true}}", 396 | "operation": "if.then", 397 | "rule": "discount = 5", 398 | "stepCount": 1, 399 | "stepRow": 0, 400 | }, 401 | Object { 402 | "lhs": "discount", 403 | "operation": "evalRule", 404 | "previous": undefined, 405 | "result": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":5}", 406 | "rule": "discount = 5", 407 | "stepCount": 2, 408 | "stepRow": 0, 409 | "value": 5, 410 | }, 411 | Object { 412 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":5}", 413 | "operation": "ruleString", 414 | "result": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":5}", 415 | "rule": "discount = 5", 416 | "stepCount": 2, 417 | "stepRow": 0, 418 | }, 419 | Object { 420 | "key": "price", 421 | "operation": "key.lookup", 422 | "rule": undefined, 423 | "stepCount": 3, 424 | "stepRow": 1, 425 | "value": 35, 426 | }, 427 | Object { 428 | "operation": "expression", 429 | "result": false, 430 | "rule": "price >= 100", 431 | "stepCount": 3, 432 | "stepRow": 1, 433 | }, 434 | Object { 435 | "key": "user.isAdmin", 436 | "operation": "key.lookup", 437 | "rule": undefined, 438 | "stepCount": 4, 439 | "stepRow": 1, 440 | "value": true, 441 | }, 442 | Object { 443 | "operation": "expression", 444 | "result": true, 445 | "rule": "user.isAdmin == true", 446 | "stepCount": 4, 447 | "stepRow": 1, 448 | }, 449 | Object { 450 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":5}", 451 | "operation": "if.or", 452 | "result": true, 453 | "rule": Array [ 454 | "price >= 100", 455 | "user.isAdmin == true", 456 | ], 457 | "stepCount": 4, 458 | "stepRow": 1, 459 | }, 460 | Object { 461 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":5}", 462 | "operation": "if.then", 463 | "rule": "discount = 20", 464 | "stepCount": 4, 465 | "stepRow": 1, 466 | }, 467 | Object { 468 | "lhs": "discount", 469 | "operation": "evalRule", 470 | "previous": 5, 471 | "result": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":20}", 472 | "rule": "discount = 20", 473 | "stepCount": 5, 474 | "stepRow": 1, 475 | "value": 20, 476 | }, 477 | Object { 478 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":20}", 479 | "operation": "ruleString", 480 | "result": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":20}", 481 | "rule": "discount = 20", 482 | "stepCount": 5, 483 | "stepRow": 1, 484 | }, 485 | Object { 486 | "key": "discount", 487 | "operation": "key.lookup", 488 | "rule": undefined, 489 | "stepCount": 6, 490 | "stepRow": 2, 491 | "value": 20, 492 | }, 493 | Object { 494 | "operation": "expression", 495 | "result": 20, 496 | "rule": "discount", 497 | "stepCount": 6, 498 | "stepRow": 2, 499 | }, 500 | Object { 501 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":20}", 502 | "operation": "return", 503 | "result": 20, 504 | "rule": "discount", 505 | "stepCount": 6, 506 | "stepRow": 2, 507 | }, 508 | Object { 509 | "currentState": "{\\"price\\":35,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":20}", 510 | "lastValue": 20, 511 | "operation": "complete", 512 | "rule": undefined, 513 | "stepCount": 6, 514 | "stepRow": 2, 515 | }, 516 | ] 517 | `; 518 | 519 | exports[`Logical and/or can process nested logical rule arrays 1`] = ` 520 | Array [ 521 | Object { 522 | "operation": "begin", 523 | "rule": undefined, 524 | }, 525 | Object { 526 | "key": "price", 527 | "operation": "key.lookup", 528 | "rule": undefined, 529 | "stepCount": 1, 530 | "stepRow": 0, 531 | "value": 90, 532 | }, 533 | Object { 534 | "operation": "expression", 535 | "result": true, 536 | "rule": "price <= 100", 537 | "stepCount": 1, 538 | "stepRow": 0, 539 | }, 540 | Object { 541 | "currentState": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true}}", 542 | "operation": "if", 543 | "result": true, 544 | "rule": "price <= 100", 545 | "stepCount": 1, 546 | "stepRow": 0, 547 | }, 548 | Object { 549 | "currentState": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true}}", 550 | "operation": "if.then", 551 | "rule": Array [ 552 | "discount = 5", 553 | "user.discountApplied = true", 554 | ], 555 | "stepCount": 1, 556 | "stepRow": 0, 557 | }, 558 | Object { 559 | "lhs": "discount", 560 | "operation": "evalRule", 561 | "previous": undefined, 562 | "result": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true},\\"discount\\":5}", 563 | "rule": "discount = 5", 564 | "stepCount": 2, 565 | "stepRow": 0, 566 | "value": 5, 567 | }, 568 | Object { 569 | "lhs": "user.discountApplied", 570 | "operation": "evalRule", 571 | "previous": undefined, 572 | "result": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5}", 573 | "rule": "user.discountApplied = true", 574 | "stepCount": 3, 575 | "stepRow": 0, 576 | "value": true, 577 | }, 578 | Object { 579 | "currentState": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5}", 580 | "operation": "ruleString[]", 581 | "result": "[{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5},{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5}]", 582 | "rule": Array [ 583 | "discount = 5", 584 | "user.discountApplied = true", 585 | ], 586 | "stepCount": 3, 587 | "stepRow": 0, 588 | }, 589 | Object { 590 | "key": "price", 591 | "operation": "key.lookup", 592 | "rule": undefined, 593 | "stepCount": 4, 594 | "stepRow": 1, 595 | "value": 90, 596 | }, 597 | Object { 598 | "operation": "expression", 599 | "result": true, 600 | "rule": "price >= 90", 601 | "stepCount": 4, 602 | "stepRow": 1, 603 | }, 604 | Object { 605 | "key": "user.discountApplied", 606 | "operation": "key.lookup", 607 | "rule": undefined, 608 | "stepCount": 5, 609 | "stepRow": 1, 610 | "value": true, 611 | }, 612 | Object { 613 | "operation": "expression", 614 | "result": false, 615 | "rule": "user.discountApplied != true", 616 | "stepCount": 5, 617 | "stepRow": 1, 618 | }, 619 | Object { 620 | "currentState": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5}", 621 | "operation": "if.and", 622 | "result": false, 623 | "rule": Array [ 624 | "price >= 90", 625 | "user.discountApplied != true", 626 | ], 627 | "stepCount": 5, 628 | "stepRow": 1, 629 | }, 630 | Object { 631 | "key": "discount", 632 | "operation": "key.lookup", 633 | "rule": undefined, 634 | "stepCount": 6, 635 | "stepRow": 2, 636 | "value": 5, 637 | }, 638 | Object { 639 | "operation": "expression", 640 | "result": 5, 641 | "rule": "discount", 642 | "stepCount": 6, 643 | "stepRow": 2, 644 | }, 645 | Object { 646 | "currentState": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5}", 647 | "operation": "return", 648 | "result": 5, 649 | "rule": "discount", 650 | "stepCount": 6, 651 | "stepRow": 2, 652 | }, 653 | Object { 654 | "currentState": "{\\"price\\":90,\\"user\\":{\\"isAdmin\\":true,\\"discountApplied\\":true},\\"discount\\":5}", 655 | "lastValue": 5, 656 | "operation": "complete", 657 | "rule": undefined, 658 | "stepCount": 6, 659 | "stepRow": 2, 660 | }, 661 | ] 662 | `; 663 | 664 | exports[`Logical if/then/else can process 'then' rules 1`] = ` 665 | Array [ 666 | Object { 667 | "operation": "begin", 668 | "rule": undefined, 669 | }, 670 | Object { 671 | "key": "price", 672 | "operation": "key.lookup", 673 | "rule": undefined, 674 | "stepCount": 1, 675 | "stepRow": 0, 676 | "value": 100, 677 | }, 678 | Object { 679 | "operation": "expression", 680 | "result": true, 681 | "rule": "price >= 25", 682 | "stepCount": 1, 683 | "stepRow": 0, 684 | }, 685 | Object { 686 | "currentState": "{\\"price\\":100}", 687 | "operation": "if", 688 | "result": true, 689 | "rule": "price >= 25", 690 | "stepCount": 1, 691 | "stepRow": 0, 692 | }, 693 | Object { 694 | "currentState": "{\\"price\\":100}", 695 | "operation": "if.then", 696 | "rule": "discount = 5", 697 | "stepCount": 1, 698 | "stepRow": 0, 699 | }, 700 | Object { 701 | "lhs": "discount", 702 | "operation": "evalRule", 703 | "previous": undefined, 704 | "result": "{\\"price\\":100,\\"discount\\":5}", 705 | "rule": "discount = 5", 706 | "stepCount": 2, 707 | "stepRow": 0, 708 | "value": 5, 709 | }, 710 | Object { 711 | "currentState": "{\\"price\\":100,\\"discount\\":5}", 712 | "operation": "ruleString", 713 | "result": "{\\"price\\":100,\\"discount\\":5}", 714 | "rule": "discount = 5", 715 | "stepCount": 2, 716 | "stepRow": 0, 717 | }, 718 | Object { 719 | "key": "price", 720 | "operation": "key.lookup", 721 | "rule": undefined, 722 | "stepCount": 3, 723 | "stepRow": 1, 724 | "value": 100, 725 | }, 726 | Object { 727 | "operation": "expression", 728 | "result": true, 729 | "rule": "price >= 100", 730 | "stepCount": 3, 731 | "stepRow": 1, 732 | }, 733 | Object { 734 | "currentState": "{\\"price\\":100,\\"discount\\":5}", 735 | "operation": "if", 736 | "result": true, 737 | "rule": "price >= 100", 738 | "stepCount": 3, 739 | "stepRow": 1, 740 | }, 741 | Object { 742 | "currentState": "{\\"price\\":100,\\"discount\\":5}", 743 | "operation": "if.then", 744 | "rule": "discount = 20", 745 | "stepCount": 3, 746 | "stepRow": 1, 747 | }, 748 | Object { 749 | "lhs": "discount", 750 | "operation": "evalRule", 751 | "previous": 5, 752 | "result": "{\\"price\\":100,\\"discount\\":20}", 753 | "rule": "discount = 20", 754 | "stepCount": 4, 755 | "stepRow": 1, 756 | "value": 20, 757 | }, 758 | Object { 759 | "currentState": "{\\"price\\":100,\\"discount\\":20}", 760 | "operation": "ruleString", 761 | "result": "{\\"price\\":100,\\"discount\\":20}", 762 | "rule": "discount = 20", 763 | "stepCount": 4, 764 | "stepRow": 1, 765 | }, 766 | Object { 767 | "key": "discount", 768 | "operation": "key.lookup", 769 | "rule": undefined, 770 | "stepCount": 5, 771 | "stepRow": 2, 772 | "value": 20, 773 | }, 774 | Object { 775 | "operation": "expression", 776 | "result": 20, 777 | "rule": "discount", 778 | "stepCount": 5, 779 | "stepRow": 2, 780 | }, 781 | Object { 782 | "currentState": "{\\"price\\":100,\\"discount\\":20}", 783 | "operation": "return", 784 | "result": 20, 785 | "rule": "discount", 786 | "stepCount": 5, 787 | "stepRow": 2, 788 | }, 789 | Object { 790 | "currentState": "{\\"price\\":100,\\"discount\\":20}", 791 | "lastValue": 20, 792 | "operation": "complete", 793 | "rule": undefined, 794 | "stepCount": 5, 795 | "stepRow": 2, 796 | }, 797 | ] 798 | `; 799 | 800 | exports[`Logical if/then/else can process omitted 'else' rules 1`] = ` 801 | Array [ 802 | Object { 803 | "operation": "begin", 804 | "rule": undefined, 805 | }, 806 | Object { 807 | "key": "price", 808 | "operation": "key.lookup", 809 | "rule": undefined, 810 | "stepCount": 1, 811 | "stepRow": 0, 812 | "value": 10, 813 | }, 814 | Object { 815 | "operation": "expression", 816 | "result": false, 817 | "rule": "price >= 100", 818 | "stepCount": 1, 819 | "stepRow": 0, 820 | }, 821 | Object { 822 | "currentState": "{\\"price\\":10}", 823 | "operation": "if", 824 | "result": false, 825 | "rule": "price >= 100", 826 | "stepCount": 1, 827 | "stepRow": 0, 828 | }, 829 | Object { 830 | "key": "discount", 831 | "operation": "key.lookup", 832 | "rule": undefined, 833 | "stepCount": 2, 834 | "stepRow": 1, 835 | "value": undefined, 836 | }, 837 | Object { 838 | "operation": "expression", 839 | "result": undefined, 840 | "rule": "discount", 841 | "stepCount": 2, 842 | "stepRow": 1, 843 | }, 844 | Object { 845 | "currentState": "{\\"price\\":10}", 846 | "operation": "return", 847 | "result": undefined, 848 | "rule": "discount", 849 | "stepCount": 2, 850 | "stepRow": 1, 851 | }, 852 | Object { 853 | "currentState": "{\\"price\\":10}", 854 | "lastValue": undefined, 855 | "operation": "complete", 856 | "rule": undefined, 857 | "stepCount": 2, 858 | "stepRow": 1, 859 | }, 860 | ] 861 | `; 862 | 863 | exports[`Logical if/then/else can process series of if/then rules 1`] = ` 864 | Array [ 865 | Object { 866 | "operation": "begin", 867 | "rule": undefined, 868 | }, 869 | Object { 870 | "key": "user.plan", 871 | "operation": "key.lookup", 872 | "rule": undefined, 873 | "stepCount": 1, 874 | "stepRow": 0, 875 | "value": "premium", 876 | }, 877 | Object { 878 | "operation": "expression", 879 | "result": true, 880 | "rule": "user.plan == \\"premium\\"", 881 | "stepCount": 1, 882 | "stepRow": 0, 883 | }, 884 | Object { 885 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100}}", 886 | "operation": "if", 887 | "result": true, 888 | "rule": "user.plan == \\"premium\\"", 889 | "stepCount": 1, 890 | "stepRow": 0, 891 | }, 892 | Object { 893 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100}}", 894 | "operation": "if.then", 895 | "rule": "discount = 15", 896 | "stepCount": 1, 897 | "stepRow": 0, 898 | }, 899 | Object { 900 | "lhs": "discount", 901 | "operation": "evalRule", 902 | "previous": undefined, 903 | "result": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 904 | "rule": "discount = 15", 905 | "stepCount": 2, 906 | "stepRow": 0, 907 | "value": 15, 908 | }, 909 | Object { 910 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 911 | "operation": "ruleString", 912 | "result": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 913 | "rule": "discount = 15", 914 | "stepCount": 2, 915 | "stepRow": 0, 916 | }, 917 | Object { 918 | "key": "user.employee", 919 | "operation": "key.lookup", 920 | "rule": undefined, 921 | "stepCount": 3, 922 | "stepRow": 1, 923 | "value": true, 924 | }, 925 | Object { 926 | "operation": "expression", 927 | "result": true, 928 | "rule": "user.employee == true", 929 | "stepCount": 3, 930 | "stepRow": 1, 931 | }, 932 | Object { 933 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 934 | "operation": "if", 935 | "result": true, 936 | "rule": "user.employee == true", 937 | "stepCount": 3, 938 | "stepRow": 1, 939 | }, 940 | Object { 941 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 942 | "operation": "if.then", 943 | "rule": "discount = 15", 944 | "stepCount": 3, 945 | "stepRow": 1, 946 | }, 947 | Object { 948 | "lhs": "discount", 949 | "operation": "evalRule", 950 | "previous": 15, 951 | "result": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 952 | "rule": "discount = 15", 953 | "stepCount": 4, 954 | "stepRow": 1, 955 | "value": 15, 956 | }, 957 | Object { 958 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 959 | "operation": "ruleString", 960 | "result": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 961 | "rule": "discount = 15", 962 | "stepCount": 4, 963 | "stepRow": 1, 964 | }, 965 | Object { 966 | "key": "discount", 967 | "operation": "key.lookup", 968 | "rule": undefined, 969 | "stepCount": 5, 970 | "stepRow": 2, 971 | "value": 15, 972 | }, 973 | Object { 974 | "operation": "expression", 975 | "result": 15, 976 | "rule": "discount", 977 | "stepCount": 5, 978 | "stepRow": 2, 979 | }, 980 | Object { 981 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 982 | "operation": "return", 983 | "result": 15, 984 | "rule": "discount", 985 | "stepCount": 5, 986 | "stepRow": 2, 987 | }, 988 | Object { 989 | "currentState": "{\\"user\\":{\\"plan\\":\\"premium\\",\\"employee\\":true},\\"config\\":{\\"maxDiscount\\":10},\\"shoppingCart\\":{\\"discount\\":1,\\"total\\":100},\\"discount\\":15}", 990 | "lastValue": 15, 991 | "operation": "complete", 992 | "rule": undefined, 993 | "stepCount": 5, 994 | "stepRow": 2, 995 | }, 996 | ] 997 | `; 998 | 999 | exports[`Nested Rule Structures can process complex rule expressions 1`] = ` 1000 | Array [ 1001 | Object { 1002 | "operation": "begin", 1003 | "rule": undefined, 1004 | }, 1005 | Object { 1006 | "key": "price", 1007 | "operation": "key.lookup", 1008 | "rule": undefined, 1009 | "stepCount": 1, 1010 | "stepRow": 0, 1011 | "value": 100, 1012 | }, 1013 | Object { 1014 | "operation": "expression", 1015 | "result": true, 1016 | "rule": "price >= 25", 1017 | "stepCount": 1, 1018 | "stepRow": 0, 1019 | }, 1020 | Object { 1021 | "currentState": "{\\"price\\":100}", 1022 | "operation": "if", 1023 | "result": true, 1024 | "rule": "price >= 25", 1025 | "stepCount": 1, 1026 | "stepRow": 0, 1027 | }, 1028 | Object { 1029 | "currentState": "{\\"price\\":100}", 1030 | "operation": "if.then", 1031 | "rule": "discount = 5 * 2", 1032 | "stepCount": 1, 1033 | "stepRow": 0, 1034 | }, 1035 | Object { 1036 | "lhs": "discount", 1037 | "operation": "evalRule", 1038 | "previous": undefined, 1039 | "result": "{\\"price\\":100,\\"discount\\":10}", 1040 | "rule": "discount = 5 * 2", 1041 | "stepCount": 2, 1042 | "stepRow": 0, 1043 | "value": 10, 1044 | }, 1045 | Object { 1046 | "currentState": "{\\"price\\":100,\\"discount\\":10}", 1047 | "operation": "ruleString", 1048 | "result": "{\\"price\\":100,\\"discount\\":10}", 1049 | "rule": "discount = 5 * 2", 1050 | "stepCount": 2, 1051 | "stepRow": 0, 1052 | }, 1053 | Object { 1054 | "key": "price", 1055 | "operation": "key.lookup", 1056 | "rule": undefined, 1057 | "stepCount": 3, 1058 | "stepRow": 1, 1059 | "value": 100, 1060 | }, 1061 | Object { 1062 | "operation": "expression", 1063 | "result": true, 1064 | "rule": "price >= 100", 1065 | "stepCount": 3, 1066 | "stepRow": 1, 1067 | }, 1068 | Object { 1069 | "currentState": "{\\"price\\":100,\\"discount\\":10}", 1070 | "operation": "if", 1071 | "result": true, 1072 | "rule": "price >= 100", 1073 | "stepCount": 3, 1074 | "stepRow": 1, 1075 | }, 1076 | Object { 1077 | "currentState": "{\\"price\\":100,\\"discount\\":10}", 1078 | "operation": "if.then", 1079 | "rule": "discount = 20 * 4", 1080 | "stepCount": 3, 1081 | "stepRow": 1, 1082 | }, 1083 | Object { 1084 | "lhs": "discount", 1085 | "operation": "evalRule", 1086 | "previous": 10, 1087 | "result": "{\\"price\\":100,\\"discount\\":80}", 1088 | "rule": "discount = 20 * 4", 1089 | "stepCount": 4, 1090 | "stepRow": 1, 1091 | "value": 80, 1092 | }, 1093 | Object { 1094 | "currentState": "{\\"price\\":100,\\"discount\\":80}", 1095 | "operation": "ruleString", 1096 | "result": "{\\"price\\":100,\\"discount\\":80}", 1097 | "rule": "discount = 20 * 4", 1098 | "stepCount": 4, 1099 | "stepRow": 1, 1100 | }, 1101 | Object { 1102 | "key": "discount", 1103 | "operation": "key.lookup", 1104 | "rule": undefined, 1105 | "stepCount": 5, 1106 | "stepRow": 2, 1107 | "value": 80, 1108 | }, 1109 | Object { 1110 | "operation": "expression", 1111 | "result": 80, 1112 | "rule": "discount", 1113 | "stepCount": 5, 1114 | "stepRow": 2, 1115 | }, 1116 | Object { 1117 | "currentState": "{\\"price\\":100,\\"discount\\":80}", 1118 | "operation": "return", 1119 | "result": 80, 1120 | "rule": "discount", 1121 | "stepCount": 5, 1122 | "stepRow": 2, 1123 | }, 1124 | Object { 1125 | "currentState": "{\\"price\\":100,\\"discount\\":80}", 1126 | "lastValue": 80, 1127 | "operation": "complete", 1128 | "rule": undefined, 1129 | "stepCount": 5, 1130 | "stepRow": 2, 1131 | }, 1132 | ] 1133 | `; 1134 | -------------------------------------------------------------------------------- /src/arrays.test.ts: -------------------------------------------------------------------------------- 1 | import { ruleFactory } from './index'; 2 | 3 | describe('Array Operators', () => { 4 | describe('map', () => { 5 | it('can do complex transforms', () => { 6 | const rules = [ 7 | 'idList = [12, 34, 56]', 8 | { 9 | map: 'idList', 10 | run: [ 11 | { 12 | if: 'GET($item, userScore) < 0', 13 | then: 'userScore = PUT($item, 0, userScore)', 14 | }, 15 | 'scoreById = PUT($item, GET($item, userScore), scoreById)', 16 | ], 17 | }, 18 | { return: 'scoreById' }, 19 | ]; 20 | const userScore = { 12: 99.9, 34: 100.1, 56: -42.0 }; 21 | const scoreById = {}; 22 | expect(ruleFactory(rules)({ userScore, scoreById })).toEqual({ 23 | 12: 99.9, 24 | 34: 100.1, 25 | 56: 0, 26 | }); 27 | }); 28 | 29 | it('should set mapped array', () => { 30 | const rules = [ 31 | { 32 | map: 'itemList', 33 | run: '$item.id', 34 | set: 'idList', 35 | }, 36 | { return: 'idList' }, 37 | ]; 38 | const itemList = [{ id: 12 }, { id: 34 }, { id: 56 }]; 39 | expect(ruleFactory(rules)({ itemList })).toEqual([12, 34, 56]); 40 | }); 41 | 42 | it('should throw on invalid array', () => { 43 | const rules = [ 44 | { 45 | map: 'notAnArray', 46 | run: '$item.id', 47 | }, 48 | { return: 'idList' }, 49 | ]; 50 | expect(() => ruleFactory(rules)()).toThrow(); 51 | }); 52 | }); 53 | 54 | describe('filter', () => { 55 | it('can .filter()', () => { 56 | const multiplesOfThree = ruleFactory([ 57 | { 58 | filter: 'list', 59 | run: '$item % 3 == 0', 60 | set: 'results', 61 | }, 62 | { return: 'results' }, 63 | ]); 64 | expect(multiplesOfThree({ list: [1, 2, 3, 4] })).toEqual([3]); 65 | }); 66 | }); 67 | 68 | describe('find', () => { 69 | it('can .find()', () => { 70 | const getFirstMultipleOfThree = ruleFactory([ 71 | { 72 | find: 'list', 73 | run: '$item % 3 == 0', 74 | set: 'results', 75 | }, 76 | { return: 'results' }, 77 | ]); 78 | expect(getFirstMultipleOfThree({ list: [1, 2, 3, 4] })).toEqual(3); 79 | expect(getFirstMultipleOfThree({ list: [2, 3, 4] })).toEqual(3); 80 | expect(getFirstMultipleOfThree({ list: [4] })).toBeUndefined(); 81 | }); 82 | }); 83 | 84 | describe('every', () => { 85 | it('can handle .every()', () => { 86 | const isEveryNumberMultipleOfThree = ruleFactory([ 87 | { 88 | every: 'list', 89 | run: '$item % 3 == 0', 90 | set: 'results', 91 | }, 92 | { return: 'results' }, 93 | ]); 94 | expect(isEveryNumberMultipleOfThree({ list: [3, 6, 9] })).toEqual(true); 95 | expect(isEveryNumberMultipleOfThree({ list: [3, 6, 9, 10] })).toEqual( 96 | false, 97 | ); 98 | }); 99 | }); 100 | 101 | describe('some', () => { 102 | it('can handle .some()', () => { 103 | const hasEvenNumbers = ruleFactory([ 104 | { 105 | some: 'list', 106 | run: '2 % $item == 0', 107 | set: 'results', 108 | }, 109 | { return: 'results' }, 110 | ]); 111 | expect(hasEvenNumbers({ list: [2, 4] })).toEqual(true); 112 | expect(hasEvenNumbers({ list: [2, 4, 5] })).toEqual(true); 113 | expect(hasEvenNumbers({ list: [5] })).toEqual(false); 114 | }); 115 | }); 116 | 117 | describe('input validation', () => { 118 | it('should throw on restricted input fields', () => { 119 | // valueOf is a restricted field 120 | expect(() => ruleFactory(['$index'])({ valueOf: () => 420 })).toThrow(); 121 | // $index is a restricted field 122 | expect(() => 123 | ruleFactory(['$index'])({ $item: {}, $index: 99 }), 124 | ).toThrow(); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/expression-language/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 2 | import { 3 | ArgumentsArray, 4 | ExpressionArray, 5 | ExpressionParserOptions, 6 | ExpressionThunk, 7 | ExpressionValue, 8 | InfixOps, 9 | isArgumentsArray, 10 | TermDelegate, 11 | TermType, 12 | TermTyper, 13 | } from 'expressionparser/dist/ExpressionParser.js'; 14 | import get from 'lodash/get.js'; 15 | import { UserError } from '../utils/errors'; 16 | import { toArray } from '../utils/utils'; 17 | import { 18 | array, 19 | char, 20 | containsValues, 21 | countObjectKeys, 22 | dateParser, 23 | evalArray, 24 | evalBool, 25 | evalString, 26 | filterValues, 27 | iterable, 28 | num, 29 | obj, 30 | objectContainsValues, 31 | omitProperties, 32 | string, 33 | unpackArgs, 34 | } from './utils'; 35 | 36 | const hasOwnProperty = (obj: object, key: string) => 37 | Object.prototype.hasOwnProperty.call(obj, key); 38 | export interface FunctionOps { 39 | [op: string]: (...args: ExpressionThunk[]) => ExpressionValue; 40 | } 41 | 42 | export const assignmentOperators = ['=', '+=', '-=', '*=', '/=', '??=']; 43 | /* 44 | TODO: Look into additional modifier operators: 45 | 46 | - `**=` 47 | - `%=` 48 | - `||=` 49 | */ 50 | 51 | const getInfixOps = (termDelegate: TermDelegate): InfixOps => ({ 52 | '+': (a, b) => num(a()) + num(b()), 53 | '-': (a, b) => num(a()) - num(b()), 54 | '*': (a, b) => num(a()) * num(b()), 55 | '/': (a, b) => num(a()) / num(b()), 56 | ',': (a, b): ArgumentsArray => { 57 | const aVal = a(); 58 | const aArr: ExpressionArray = isArgumentsArray(aVal) 59 | ? aVal 60 | : [() => aVal]; 61 | const args: ExpressionArray = aArr.concat([b]); 62 | args.isArgumentsArray = true; 63 | return args as ArgumentsArray; 64 | }, 65 | '%': (a, b) => num(a()) % num(b()), 66 | '=': (_, b) => b(), 67 | '+=': (a, b) => num(a()) + num(b()), 68 | '-=': (a, b) => num(a()) - num(b()), 69 | '*=': (a, b) => num(a()) * num(b()), 70 | '/=': (a, b) => num(a()) / num(b()), 71 | '??=': (a, b) => a() ?? b(), 72 | '==': (a, b) => a() === b(), 73 | '!=': (a, b) => a() !== b(), 74 | '<>': (a, b) => a() !== b(), 75 | '~=': (a, b) => Math.abs(num(a()) - num(b())) < Number.EPSILON, 76 | '>': (a, b) => a() > b(), 77 | '<': (a, b) => a() < b(), 78 | '>=': (a, b) => a() >= b(), 79 | '<=': (a, b) => a() <= b(), 80 | AND: (a, b) => a() && b(), 81 | OR: (a, b) => a() || b(), 82 | '^': (a, b) => Math.pow(num(a()), num(b())), 83 | }); 84 | 85 | type Callable = (...args: ExpressionArray) => ExpressionValue; 86 | 87 | type TermSetterFunction = (keyPath: string, value: ExpressionValue) => any; 88 | 89 | export const ruleExpressionLanguage = function ( 90 | termDelegate: TermDelegate, 91 | termTypeDelegate?: TermTyper, 92 | termSetter?: TermSetterFunction, 93 | ): ExpressionParserOptions { 94 | const infixOps = getInfixOps(termDelegate); 95 | // const infixOps = moduleMethodTracer(getInfixOps(termDelegate), console.log); 96 | 97 | const call = (name: string): Callable => { 98 | const upperName = name.toUpperCase(); 99 | if (hasOwnProperty(prefixOps, upperName)) { 100 | return (...args) => { 101 | args.isArgumentsArray = true; 102 | return prefixOps[upperName](() => args); 103 | }; 104 | } else if (hasOwnProperty(infixOps, upperName)) { 105 | return (...args) => infixOps[upperName](args[0], args[1]); 106 | } else { 107 | throw new Error(`Unknown function: ${name}`); 108 | } 109 | }; 110 | 111 | const prefixOps: FunctionOps = { 112 | NEG: (arg) => -num(arg()), 113 | ADD: (a, b) => num(a()) + num(b()), 114 | SUB: (a, b) => num(a()) - num(b()), 115 | MUL: (a, b) => num(a()) * num(b()), 116 | DIV: (a, b) => num(a()) / num(b()), 117 | MOD: (a, b) => num(a()) % num(b()), 118 | ISPRIME: (arg) => { 119 | const val = num(arg()); 120 | for (let i = 2, s = Math.sqrt(val); i <= s; i++) 121 | if (val % i === 0) return false; 122 | 123 | return val !== 1; 124 | }, 125 | GCD: (arg1, arg2) => { 126 | let a = num(arg1()); 127 | let b = num(arg2()); 128 | a = Math.abs(a); 129 | b = Math.abs(b); 130 | if (b > a) { 131 | const temp = a; 132 | a = b; 133 | b = temp; 134 | } 135 | while (true) { 136 | if (b === 0) return a; 137 | a %= b; 138 | if (a === 0) return b; 139 | b %= a; 140 | } 141 | }, 142 | DATE: dateParser, 143 | DATEISO: (arg) => { 144 | const dateArg = arg(); 145 | if (typeof dateArg === 'string' || typeof dateArg === 'number') 146 | return new Date(dateParser(dateArg)).toISOString(); 147 | 148 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 149 | return `UnknownDate(${dateArg?.valueOf()})`; 150 | }, 151 | NOT: (arg) => !arg(), 152 | '!': (arg) => !arg(), 153 | ABS: (arg) => Math.abs(num(arg())), 154 | ACOS: (arg) => Math.acos(num(arg())), 155 | ACOSH: (arg) => Math.acosh(num(arg())), 156 | ASIN: (arg) => Math.asin(num(arg())), 157 | ASINH: (arg) => Math.asinh(num(arg())), 158 | ATAN: (arg) => Math.atan(num(arg())), 159 | 160 | ATAN2: (arg1, arg2) => Math.atan2(num(arg1()), num(arg2())), 161 | 162 | ATANH: (arg) => Math.atanh(num(arg())), 163 | CUBEROOT: (arg) => Math.cbrt(num(arg())), 164 | CEIL: (arg) => Math.ceil(num(arg())), 165 | 166 | COS: (arg) => Math.cos(num(arg())), 167 | COSH: (arg) => Math.cos(num(arg())), 168 | EXP: (arg) => Math.exp(num(arg())), 169 | FLOOR: (arg) => Math.floor(num(arg())), 170 | LN: (arg) => Math.log(num(arg())), 171 | LOG: (arg) => Math.log10(num(arg())), 172 | LOG2: (arg) => Math.log2(num(arg())), 173 | SIN: (arg) => Math.sin(num(arg())), 174 | SINH: (arg) => Math.sinh(num(arg())), 175 | SQRT: (arg) => Math.sqrt(num(arg())), 176 | TAN: (arg) => Math.tan(num(arg())), 177 | TANH: (arg) => Math.tanh(num(arg())), 178 | ROUND: (arg) => Math.round(num(arg())), 179 | SIGN: (arg) => Math.sign(num(arg())), 180 | TRUNC: (arg) => Math.trunc(num(arg())), 181 | 182 | IF: (arg1, arg2, arg3) => { 183 | const condition = arg1; 184 | const thenStatement = arg2; 185 | const elseStatement = arg3; 186 | 187 | if (condition()) return thenStatement(); 188 | else return elseStatement(); 189 | }, 190 | 191 | AVERAGE: (arg) => { 192 | const arr = evalArray(arg()); 193 | 194 | const sum = arr.reduce( 195 | (prev: number, curr: ExpressionValue): number => prev + num(curr), 196 | 0, 197 | ); 198 | return num(sum) / arr.length; 199 | }, 200 | 201 | SUM: (arg) => 202 | evalArray(arg(), num).reduce( 203 | (prev: number, curr: ExpressionValue) => prev + num(curr), 204 | 0, 205 | ), 206 | CHAR: (arg) => String.fromCharCode(num(arg())), 207 | CODE: (arg) => char(arg()).charCodeAt(0), 208 | 209 | DEC2BIN: (arg) => Number.parseInt(string(arg())).toString(2), 210 | DEC2HEX: (arg) => Number.parseInt(string(arg())).toString(16), 211 | DEC2STR: (arg) => Number.parseInt(string(arg())).toString(10), 212 | BIN2DEC: (arg) => Number.parseInt(string(arg()), 2), 213 | HEX2DEC: (arg) => Number.parseInt(string(arg()), 16), 214 | STR2DEC: (arg) => Number.parseInt(string(arg()), 10), 215 | DEGREES: (arg) => (num(arg()) * 180) / Math.PI, 216 | RADIANS: (arg) => (num(arg()) * Math.PI) / 180, 217 | 218 | MIN: (arg) => 219 | evalArray(arg()).reduce( 220 | (prev: number, curr: ExpressionValue) => Math.min(prev, num(curr)), 221 | Number.POSITIVE_INFINITY, 222 | ), 223 | MAX: (arg) => 224 | evalArray(arg()).reduce( 225 | (prev: number, curr: ExpressionValue) => Math.max(prev, num(curr)), 226 | Number.NEGATIVE_INFINITY, 227 | ), 228 | SORT: (arg) => { 229 | const arr = array(arg()).slice(); 230 | arr.sort((a, b) => num(a) - num(b)); 231 | return arr; 232 | }, 233 | REVERSE: (arg) => { 234 | const arr = array(arg()).slice(); 235 | arr.reverse(); 236 | return arr; 237 | }, 238 | INDEX: (arg1, arg2) => iterable(arg1())[num(arg2())], 239 | LENGTH: (arg) => { 240 | return iterable(arg()).length; 241 | }, 242 | JOIN: (arg1, arg2) => evalArray(arg2()).join(string(arg1())), 243 | STRING: (arg) => evalArray(arg()).join(''), 244 | STRING_CONTAINS: (arg1, arg2) => string(arg2()).includes(string(arg1())), 245 | STRING_ENDS_WITH: (arg1, arg2) => string(arg2()).endsWith(string(arg1())), 246 | STRING_STARTS_WITH: (arg1, arg2) => 247 | string(arg2()).startsWith(string(arg1())), 248 | SPLIT: (arg1, arg2) => string(arg2()).split(string(arg1())), 249 | 250 | CHARARRAY: (arg) => { 251 | const str = string(arg()); 252 | return str.split(''); 253 | }, 254 | ARRAY: (arg) => array(arg()), 255 | ISNAN: (arg) => isNaN(num(arg())), 256 | MAP: (arg1, arg2) => { 257 | const func = arg1(); 258 | const arr = evalArray(arg2()); 259 | return arr.map((val: ExpressionValue) => { 260 | if (typeof func === 'function') return () => func(val); 261 | else return call(string(func))(() => val); 262 | }); 263 | }, 264 | REDUCE: (arg1, arg2, arg3) => { 265 | const func = arg1(); 266 | const start = arg2(); 267 | const arr = evalArray(arg3()); 268 | return arr.reduce((prev: ExpressionValue, curr: ExpressionValue) => { 269 | const args: ExpressionArray = [() => prev, () => curr]; 270 | if (typeof func === 'function') return func(...args); 271 | else return call(string(func))(...args); 272 | }, start); 273 | }, 274 | RANGE: (arg1, arg2) => { 275 | const start = num(arg1()); 276 | const limit = num(arg2()); 277 | const result = []; 278 | for (let i = start; i < limit; i++) result.push(i); 279 | 280 | return result; 281 | }, 282 | UPPER: (arg) => string(arg()).toUpperCase(), 283 | LOWER: (arg) => string(arg()).toLowerCase(), 284 | 285 | ZIP: (arg1, arg2) => { 286 | const arr1 = evalArray(arg1()); 287 | const arr2 = evalArray(arg2()); 288 | 289 | if (arr1.length !== arr2.length) 290 | throw new Error('ZIP: Arrays are of different lengths'); 291 | else return arr1.map((v1: ExpressionValue, i: number) => [v1, arr2[i]]); 292 | }, 293 | UNZIP: (arg1) => { 294 | const inputArr = evalArray(arg1()); 295 | const arr1 = inputArr.map((item: ExpressionValue) => array(item)[0]); 296 | const arr2 = inputArr.map((item: ExpressionValue) => array(item)[1]); 297 | return [arr1, arr2]; 298 | }, 299 | TAKE: (arg1, arg2) => { 300 | const n = num(arg1()); 301 | const arr = evalArray(arg2()); 302 | return arr.slice(0, n); 303 | }, 304 | DROP: (arg1, arg2) => { 305 | const n = num(arg1()); 306 | const arr = evalArray(arg2()); 307 | return arr.slice(n); 308 | }, 309 | SLICE: (arg1, arg2, arg3) => { 310 | const start = num(arg1()); 311 | const limit = num(arg2()); 312 | const arr = evalArray(arg3()); 313 | return arr.slice(start, limit); 314 | }, 315 | CONCAT: (arg1, arg2) => { 316 | const arr1 = array(arg1()); 317 | const arr2 = array(arg2()); 318 | return arr1.concat(arr2); 319 | }, 320 | HEAD: (arg1) => { 321 | const arr = array(arg1()); 322 | return arr[0]; 323 | }, 324 | TAIL: (arg1) => { 325 | const arr = array(arg1()); 326 | return arr.slice(1); 327 | }, 328 | LAST: (arg1) => { 329 | const arr = array(arg1()); 330 | return arr[arr.length - 1]; 331 | }, 332 | CONS: (arg1, arg2) => { 333 | const head = arg1(); 334 | const arr = array(arg2()); 335 | return [head].concat(arr); 336 | }, 337 | FILTER: (arg1, arg2) => { 338 | const func = arg1(); 339 | const arr = evalArray(arg2()); 340 | const result: ExpressionArray = []; 341 | arr.forEach((val: ExpressionValue) => { 342 | let isSatisfied; 343 | if (typeof func === 'function') isSatisfied = evalBool(func(val)); 344 | else isSatisfied = evalBool(call(string(func))(() => val)); 345 | 346 | if (isSatisfied) result.push(val); 347 | }); 348 | 349 | return result; 350 | }, 351 | TAKEWHILE: (arg1, arg2) => { 352 | const func = arg1(); 353 | const arr = evalArray(arg2()); 354 | 355 | const satisfaction = (val: ExpressionValue) => { 356 | let isSatisfied; 357 | if (typeof func === 'function') isSatisfied = evalBool(func(val)); 358 | else isSatisfied = evalBool(call(string(func))(() => val)); 359 | 360 | return isSatisfied; 361 | }; 362 | 363 | let i = 0; 364 | while (satisfaction(arr[i]) && i < arr.length) i++; 365 | 366 | return arr.slice(0, i); 367 | }, 368 | DROPWHILE: (arg1, arg2) => { 369 | const func = arg1(); 370 | const arr = evalArray(arg2()); 371 | 372 | const satisfaction = (val: ExpressionValue) => { 373 | let isSatisfied; 374 | if (typeof func === 'function') isSatisfied = evalBool(func(val)); 375 | else isSatisfied = evalBool(call(string(func))(() => val)); 376 | 377 | return isSatisfied; 378 | }; 379 | 380 | let i = 0; 381 | while (satisfaction(arr[i]) && i < arr.length) i++; 382 | 383 | return arr.slice(i); 384 | }, 385 | /** 386 | * CONTAINS will return true if any value(s) from the 1st argument occur in the 2nd argument. 387 | * 388 | * ```js 389 | * CONTAINS([1 ,3], [1, 2, 3, 4, 5]) 390 | * //-> true 391 | * 392 | * CONTAINS(99, [1, 2, 3, 4, 5]) 393 | * //-> false 394 | * ``` 395 | * 396 | * @param {*} arg1 397 | * @param {*} arg2 398 | */ 399 | CONTAINS: containsValues, 400 | INCLUDES: containsValues, 401 | OBJECT_CONTAINS: objectContainsValues, 402 | COUNT_KEYS: countObjectKeys, 403 | OMIT: omitProperties, 404 | /** 405 | * REMOVE_VALUES will remove all values matching the item(s) in the 1st argument from the 2nd argument array. 406 | * 407 | * ```js 408 | * REMOVE_VALUES([1 ,3], [1, 2, 3, 4, 5]) 409 | * //-> [2, 4, 5] 410 | * 411 | * REMOVE_VALUES(1, [1, 2, 3, 4, 5]) 412 | * //-> [2, 3, 4, 5] 413 | * ``` 414 | * 415 | * @param {*} arg1 416 | * @param {*} arg2 417 | */ 418 | REMOVE_VALUES: (arg1, arg2) => { 419 | const removeValues = toArray(arg1()); 420 | const data = evalArray(arg2()); 421 | 422 | return data.filter((val: ExpressionValue) => !removeValues.includes(val)); 423 | }, 424 | FILTER_VALUES: filterValues, 425 | INCLUDES_VALUES: filterValues, 426 | GET: (arg1, arg2) => { 427 | const rawKey = arg1(); 428 | const key = typeof rawKey === 'string' ? rawKey : num(rawKey); 429 | const inputObj = obj(arg2()); 430 | return get(inputObj, key); 431 | }, 432 | PUT: (arg1, arg2, arg3) => { 433 | const rawKey = arg1(); 434 | const key = typeof rawKey === 'string' ? rawKey : num(rawKey); 435 | const value = arg2(); 436 | const inputObj = obj(arg3()); 437 | return Object.assign({}, inputObj, { [key]: value }); 438 | }, 439 | DICT: (arg1, arg2) => { 440 | const arr1 = evalArray(arg1()); 441 | const arr2 = evalArray(arg2()); 442 | const result: { [key: string]: ExpressionValue } = {}; 443 | 444 | arr1.forEach((v1: ExpressionValue, i: number) => { 445 | const key = string(v1); 446 | result[key] = arr2[i]; 447 | }); 448 | 449 | return result; 450 | }, 451 | UNZIPDICT: (arg1) => { 452 | const arr = evalArray(arg1()); 453 | const result: { [key: string]: ExpressionValue } = {}; 454 | 455 | arr.forEach((item: ExpressionValue) => { 456 | const kvPair = array(item); 457 | if (kvPair.length !== 2) 458 | throw new Error('UNZIPDICT: Expected sub-array of length 2'); 459 | 460 | const [key, value] = kvPair; 461 | 462 | try { 463 | result[evalString(key)] = value; 464 | } catch (err) { 465 | throw new Error(`UNZIPDICT keys; ${err.message}`); 466 | } 467 | }); 468 | 469 | return result; 470 | }, 471 | KEYS: (arg1) => { 472 | const inputObj = obj(arg1()); 473 | return Object.keys(inputObj).sort(); 474 | }, 475 | VALUES: (arg1) => { 476 | const inputObj = obj(arg1()); 477 | return Object.keys(inputObj) 478 | .sort() 479 | .map((key) => inputObj[key]); 480 | }, 481 | THROW: (arg1) => { 482 | throw new UserError(string(arg1())); 483 | }, 484 | }; 485 | 486 | // Ensure arguments are unpacked accordingly 487 | // Except for the ARRAY constructor 488 | Object.keys(prefixOps).forEach((key) => { 489 | if (key !== 'ARRAY') { 490 | // @ts-expect-error 491 | prefixOps[key] = unpackArgs(prefixOps[key]); 492 | } 493 | }); 494 | 495 | return { 496 | ESCAPE_CHAR: '\\', 497 | INFIX_OPS: infixOps, 498 | PREFIX_OPS: prefixOps, 499 | PRECEDENCE: [ 500 | Object.keys(prefixOps), 501 | ['^'], 502 | ['*', '/', '%', 'MOD'], 503 | ['+', '-'], 504 | ['<', '>', '<=', '>='], 505 | ['=', '!=', '<>', '~='], 506 | ['AND', 'OR'], 507 | [','], 508 | ], 509 | LITERAL_OPEN: '"', 510 | LITERAL_CLOSE: '"', 511 | GROUP_OPEN: '(', 512 | GROUP_CLOSE: ')', 513 | SEPARATOR: ' ', 514 | SYMBOLS: [ 515 | '^', 516 | '*', 517 | '/', 518 | '%', 519 | '+', 520 | '-', 521 | '<', 522 | '>', 523 | '=', 524 | '!', 525 | ',', 526 | '"', 527 | '(', 528 | ')', 529 | '[', 530 | ']', 531 | '~', 532 | '?', 533 | ], 534 | AMBIGUOUS: { 535 | '-': 'NEG', 536 | }, 537 | SURROUNDING: { 538 | ARRAY: { 539 | OPEN: '[', 540 | CLOSE: ']', 541 | }, 542 | }, 543 | // @ts-expect-error 544 | termDelegate: function (term: string) { 545 | const numVal = parseFloat(term); 546 | if (Number.isNaN(numVal)) { 547 | switch (term.toUpperCase()) { 548 | case 'E': 549 | return Math.E; 550 | case 'LN2': 551 | return Math.LN2; 552 | case 'LN10': 553 | return Math.LN10; 554 | case 'LOG2E': 555 | return Math.LOG2E; 556 | case 'LOG10E': 557 | return Math.LOG10E; 558 | case 'PI': 559 | return Math.PI; 560 | case 'SQRTHALF': 561 | return Math.SQRT1_2; 562 | case 'SQRT2': 563 | return Math.SQRT2; 564 | case 'FALSE': 565 | return false; 566 | case 'TRUE': 567 | return true; 568 | case 'EMPTY': 569 | return []; 570 | case 'EMPTYDICT': 571 | return {}; 572 | case 'INFINITY': 573 | return Number.POSITIVE_INFINITY; 574 | case 'EPSILON': 575 | return Number.EPSILON; 576 | case 'UNDEFINED': 577 | return undefined; 578 | default: 579 | return termDelegate(term); 580 | } 581 | } else { 582 | return numVal; 583 | } 584 | }, 585 | 586 | termTyper: function (term: string): TermType { 587 | const numVal = parseFloat(term); 588 | 589 | if (Number.isNaN(numVal)) { 590 | switch (term.toUpperCase()) { 591 | case 'E': 592 | return 'number'; 593 | case 'LN2': 594 | return 'number'; 595 | case 'LN10': 596 | return 'number'; 597 | case 'LOG2E': 598 | return 'number'; 599 | case 'LOG10E': 600 | return 'number'; 601 | case 'PI': 602 | return 'number'; 603 | case 'SQRTHALF': 604 | return 'number'; 605 | case 'SQRT2': 606 | return 'number'; 607 | case 'FALSE': 608 | return 'boolean'; 609 | case 'TRUE': 610 | return 'boolean'; 611 | case 'EMPTY': 612 | return 'array'; 613 | case 'INFINITY': 614 | return 'number'; 615 | case 'EPSILON': 616 | return 'number'; 617 | default: 618 | return termTypeDelegate ? termTypeDelegate(term) : 'unknown'; 619 | } 620 | } else { 621 | return 'number'; 622 | } 623 | }, 624 | 625 | isCaseInsensitive: true, 626 | 627 | descriptions: [], 628 | }; 629 | }; 630 | -------------------------------------------------------------------------------- /src/expression-language/utils.ts: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject.js'; 2 | import omit from 'lodash/omit.js'; 3 | import ms from 'ms'; 4 | import { 5 | Delegate, 6 | ExpressionThunk, 7 | ExpressionValue, 8 | isArgumentsArray, 9 | } from 'expressionparser/dist/ExpressionParser.js'; 10 | import { toArray } from '../utils/utils'; 11 | import { UserError } from '../utils/errors'; 12 | 13 | export const unpackArgs = (f: Delegate) => (expr: ExpressionThunk) => { 14 | const result = expr(); 15 | 16 | if (!isArgumentsArray(result)) { 17 | if (f.length > 1) { 18 | throw new UserError( 19 | `Too few arguments. Expected ${f.length}, found 1 (${JSON.stringify( 20 | result, 21 | )})`, 22 | ); 23 | } 24 | return f(() => result); 25 | } else if (result.length === f.length || f.length === 0) { 26 | return f.apply(null, result); 27 | } else { 28 | throw new UserError(`Incorrect number of arguments. Expected ${f.length}`); 29 | } 30 | }; 31 | export const num = (result: ExpressionValue) => { 32 | if (typeof result !== 'number') { 33 | throw new UserError( 34 | `Expected number, found: ${typeof result} ${JSON.stringify(result)}`, 35 | ); 36 | } 37 | 38 | return result; 39 | }; 40 | export const array = (result: ExpressionValue) => { 41 | if (!Array.isArray(result)) { 42 | throw new UserError( 43 | `Expected array, found: ${typeof result} ${JSON.stringify(result)}`, 44 | ); 45 | } 46 | 47 | result = unpackArray([...result]); 48 | if (isArgumentsArray(result)) 49 | throw new UserError('Expected array, found: arguments'); 50 | 51 | return result; 52 | }; 53 | const unpackArray = ( 54 | thunks: TInput | ExpressionThunk[], 55 | ) => thunks.map((thunk) => (typeof thunk === 'function' ? thunk() : thunk)); 56 | const bool = (value: ExpressionValue) => { 57 | if (typeof value !== 'boolean') { 58 | throw new UserError( 59 | `Expected boolean, found: ${typeof value} ${JSON.stringify(value)}`, 60 | ); 61 | } 62 | 63 | return value; 64 | }; 65 | export const evalBool = (value: ExpressionValue): boolean => { 66 | let result; 67 | 68 | while (typeof value === 'function' && value.length === 0) value = value(); 69 | if (!result) result = value; 70 | 71 | return bool(result); 72 | }; 73 | export const evalString = (value: ExpressionValue) => { 74 | let result; 75 | if (typeof value === 'function' && value.length === 0) result = value(); 76 | else result = value; 77 | 78 | return string(result); 79 | }; 80 | 81 | export const evalArray = ( 82 | arr: ExpressionValue, 83 | typeCheck?: (value: ExpressionValue) => ExpressionValue, 84 | ) => { 85 | return toArray(arr).map((value) => { 86 | let result; 87 | if (typeof value === 'function' && value.length === 0) result = value(); 88 | else result = value; 89 | 90 | if (typeCheck) { 91 | try { 92 | result = typeCheck(result); 93 | } catch (err) { 94 | throw new UserError(`In array; ${err.message}`); 95 | } 96 | } 97 | 98 | return result; 99 | }); 100 | }; 101 | export const obj = (obj: ExpressionValue) => { 102 | if (typeof obj !== 'object' || obj === null) { 103 | throw new UserError( 104 | `Expected object, found: ${typeof obj} ${JSON.stringify(obj)}`, 105 | ); 106 | } else if (Array.isArray(obj)) { 107 | throw new UserError('Expected object, found array'); 108 | } 109 | 110 | return obj; 111 | }; 112 | 113 | /** 114 | * 115 | * FILTER_VALUES will ONLY INCLUDE values that are in the 1st argument. 116 | * 117 | * ```js 118 | * FILTER_VALUES([1 ,3], [1, 2, 3, 4, 5]) 119 | * //-> [2, 4, 5] 120 | * 121 | * FILTER_VALUES(1, [1, 2, 3, 4, 5]) 122 | * //-> [2, 3, 4, 5] 123 | * ``` 124 | */ 125 | export const filterValues = (arg1: ExpressionThunk, arg2: ExpressionThunk) => { 126 | const includeValues = toArray(arg1()); 127 | const data = toArray(arg2()); 128 | return data.filter((val) => !includeValues.includes(val)); 129 | }; 130 | export const containsValues = ( 131 | arg1: ExpressionThunk, 132 | arg2: ExpressionThunk, 133 | ) => { 134 | const matches = toArray(arg1()); 135 | const data = evalArray(arg2()); 136 | return data.some((val) => matches.includes(val)); 137 | }; 138 | export const objectContainsValues = ( 139 | arg1: ExpressionThunk, 140 | arg2: ExpressionThunk, 141 | ) => { 142 | const matches = toArray(arg1()); 143 | const data = arg2(); 144 | return Object.keys(data).some((val) => matches.includes(val)); 145 | }; 146 | export const countObjectKeys = (arg1: ExpressionThunk) => { 147 | return Object.keys(arg1()).length; 148 | }; 149 | export const omitProperties = ( 150 | arg1: ExpressionThunk, 151 | arg2: ExpressionThunk, 152 | ) => { 153 | const matches = toArray(arg1()) as []; 154 | const data = arg2(); 155 | if (!isObject(data)) { 156 | throw new UserError( 157 | `OMIT expects object for second argument, ${typeof data} ${JSON.stringify( 158 | data, 159 | )}`, 160 | ); 161 | } 162 | return omit(data, matches); 163 | }; 164 | export const iterable = (result: ExpressionValue) => { 165 | if (!Array.isArray(result) && typeof result !== 'string') { 166 | throw new UserError( 167 | `Expected array or string, found: ${typeof result} ${JSON.stringify( 168 | result, 169 | )}`, 170 | ); 171 | } 172 | 173 | return result; 174 | }; 175 | 176 | export const string = (result: ExpressionValue) => { 177 | if (typeof result !== 'string') { 178 | throw new UserError( 179 | `Expected string, found: ${typeof result} ${JSON.stringify(result)}`, 180 | ); 181 | } 182 | 183 | return result; 184 | }; 185 | export const char = (result: ExpressionValue) => { 186 | if (typeof result !== 'string' || result.length !== 1) { 187 | throw new UserError( 188 | `Expected char, found: ${typeof result} ${JSON.stringify(result)}`, 189 | ); 190 | } 191 | 192 | return result; 193 | }; 194 | 195 | export const dateParser = ( 196 | arg: ExpressionThunk | string | number, 197 | ): number | string => { 198 | const dateArg = typeof arg === 'function' ? arg() : arg; 199 | 200 | if (typeof dateArg === 'string' && dateArg.length < 6) { 201 | // possible date duration expression 202 | const duration = ms(dateArg); 203 | const d = new Date(Date.now() + duration); 204 | // console.info(`DATE: ${dateArg} (${duration}ms) => ${d}`); 205 | return d.getTime(); 206 | } 207 | if (typeof dateArg === 'string' || typeof dateArg === 'number') { 208 | const d = new Date(dateArg); 209 | return d.getTime(); 210 | } 211 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 212 | return `UnknownDate(${dateArg?.toString()})`; 213 | }; 214 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { ruleFactory } from './index'; 2 | import mockDateHelper from './utils/mockDateHelper'; 3 | 4 | const omitRuntime = ({ runTime, startTime, ...keys }: any) => keys; 5 | 6 | describe('Assignment Operators', () => { 7 | test('should be able to assign a value to a variable', () => { 8 | const rulesFn = ruleFactory('amITheWalrus = true'); 9 | expect(rulesFn({})).toEqual({ amITheWalrus: true }); 10 | }); 11 | test('should be able to add a key to input', () => { 12 | const rulesFn = ruleFactory('iAmTheWalrus = true'); 13 | expect(rulesFn({ walrus: true })).toEqual({ 14 | iAmTheWalrus: true, 15 | walrus: true, 16 | }); 17 | }); 18 | test('and return a variable by name', () => { 19 | const rulesFn = ruleFactory([ 20 | 'amITheWalrus = foo + bar', 21 | { return: 'amITheWalrus' }, 22 | ]); 23 | expect(rulesFn({ foo: 1, bar: 2 })).toBe(3); 24 | }); 25 | }); 26 | 27 | describe('Logical', () => { 28 | describe('if/then/else', () => { 29 | test("can process 'then' rules", () => { 30 | const rulesFn = ruleFactory( 31 | [ 32 | { if: 'price >= 25', then: 'discount = 5' }, 33 | { if: 'price >= 100', then: 'discount = 20' }, 34 | { return: 'discount' }, 35 | ], 36 | { trace: true }, 37 | ); 38 | 39 | const input = { price: 100 }; 40 | const result = rulesFn(input); 41 | 42 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 43 | expect(result.returnValue).toBe(20); 44 | expect(result.input.discount).toBe(20); 45 | }); 46 | 47 | test("can process omitted 'else' rules", () => { 48 | const rulesFn = ruleFactory( 49 | [{ if: 'price >= 100', then: 'discount = 20' }, { return: 'discount' }], 50 | { trace: true }, 51 | ); 52 | 53 | const input = { price: 10 }; 54 | const result = rulesFn(input); 55 | 56 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 57 | expect(result.returnValue).toBe(undefined); 58 | }); 59 | 60 | test('can process series of if/then rules', () => { 61 | const rulesFn = ruleFactory( 62 | [ 63 | { if: 'user.plan == "premium"', then: 'discount = 15' }, 64 | { if: 'user.employee == true', then: 'discount = 15' }, 65 | { return: 'discount' }, 66 | ], 67 | { trace: true }, 68 | ); 69 | const result = rulesFn({ 70 | user: { 71 | plan: 'premium', 72 | employee: true, 73 | }, 74 | config: { 75 | maxDiscount: 10, 76 | }, 77 | shoppingCart: { 78 | discount: 1, 79 | total: 100, 80 | }, 81 | }); 82 | 83 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 84 | expect(result.returnValue).toBe(15); 85 | }); 86 | }); 87 | 88 | describe('and/or', () => { 89 | test("can process 'and' rules", () => { 90 | const rulesFn = ruleFactory( 91 | [ 92 | { if: { and: ['price >= 25', 'price <= 50'] }, then: 'discount = 5' }, 93 | { if: 'price >= 100', then: 'discount = 20' }, 94 | { return: 'discount' }, 95 | ], 96 | { trace: true }, 97 | ); 98 | const input = { price: 35 }; 99 | const result = rulesFn(input); 100 | 101 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 102 | expect(result.returnValue).toBe(5); 103 | expect(result.input.discount).toBe(5); 104 | }); 105 | 106 | test('"and" object rule should short circuit', () => { 107 | const rules = [ 108 | { if: { and: ['foo', 'bar = 42'] }, then: '1' }, 109 | { return: 'bar' }, 110 | ]; 111 | 112 | expect(ruleFactory(rules)({ foo: false })).toBe(undefined); 113 | expect(ruleFactory(rules)({ foo: true })).toBe(42); 114 | }); 115 | 116 | test("can process 'or' rules", () => { 117 | const rulesFn = ruleFactory( 118 | [ 119 | { if: 'price <= 100', then: 'discount = 5' }, 120 | { 121 | if: { or: ['price >= 100', 'user.isAdmin == true'] }, 122 | then: 'discount = 20', 123 | }, 124 | { return: 'discount' }, 125 | ], 126 | { trace: true }, 127 | ); 128 | const input = { price: 35, user: { isAdmin: true } }; 129 | const result = rulesFn(input); 130 | 131 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 132 | expect(result.returnValue).toBe(20); 133 | expect(result.input.discount).toBe(20); 134 | }); 135 | 136 | test('"or" object rule should short circuit', () => { 137 | const rules = [ 138 | { if: { or: ['foo', 'bar = 42'] }, then: '1' }, 139 | { return: 'bar' }, 140 | ]; 141 | 142 | expect(ruleFactory(rules)({ foo: false })).toBe(42); 143 | expect(ruleFactory(rules)({ foo: true })).toBe(undefined); 144 | }); 145 | 146 | test('can process nested logical rule arrays', () => { 147 | const rulesFn = ruleFactory( 148 | [ 149 | { 150 | if: 'price <= 100', 151 | then: ['discount = 5', 'user.discountApplied = true'], 152 | }, 153 | { 154 | if: { and: ['price >= 90', 'user.discountApplied != true'] }, 155 | then: 'discount = 20', 156 | }, 157 | { return: 'discount' }, 158 | ], 159 | { trace: true }, 160 | ); 161 | const input = { price: 90, user: { isAdmin: true } }; 162 | const result = rulesFn(input); 163 | 164 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 165 | expect(result.returnValue).toBe(5); 166 | expect(result.input.discount).toBe(5); 167 | expect(result.input.user?.discountApplied).toBe(true); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('Custom Functions', () => { 173 | test('can use functions as expressions', () => { 174 | const unMockDate = mockDateHelper(new Date('2020-01-20T00:00:00.000Z')); 175 | const rulesFn = ruleFactory('DATEISO("10m")'); 176 | expect(rulesFn({})).toMatchSnapshot(); 177 | unMockDate(); 178 | }); 179 | 180 | test('can invoke DATEISO function', () => { 181 | const unMockDate = mockDateHelper(new Date('2020-01-20T00:00:00.000Z')); 182 | const rulesFn = ruleFactory( 183 | [ 184 | { if: '3 >= 1', then: 'inTenMinutes = DATEISO("10m")' }, 185 | { return: 'inTenMinutes' }, 186 | ], 187 | { trace: false }, 188 | ); 189 | 190 | const input = { addToDate: '10m' }; 191 | const result = rulesFn(input); 192 | 193 | expect(result).toMatch(/.*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*/); 194 | unMockDate(); 195 | }); 196 | 197 | test('can invoke DATE function', () => { 198 | const rulesFn = ruleFactory( 199 | [ 200 | 'today = DATE(DATEISO("0d"))', 201 | 'tomorrow = DATE(DATEISO("1d"))', 202 | { 203 | if: 'tomorrow > today', 204 | then: 'return tomorrow - today', 205 | else: 'return 0', 206 | }, 207 | ], 208 | { trace: false }, 209 | ); 210 | const result = rulesFn({}); 211 | // allow for slow systems to run tests (allows event loop lag of 100ms, which shouldn't be exceeded under normal circumstances.) 212 | const isOneDay = result >= 86_400_000 && result <= 86_400_000 + 1000; 213 | expect(isOneDay).toBe(true); 214 | }); 215 | 216 | test('can use functional array helpers', () => { 217 | const rulesFn = ruleFactory( 218 | [ 219 | 'availableCities = FILTER_VALUES(["London", "Milan"], cities)', 220 | { return: 'availableCities' }, 221 | ], 222 | { trace: true }, 223 | ); 224 | 225 | const input = { 226 | cities: ['Denver', 'London', 'LA'], 227 | onlyUSA: true, 228 | }; 229 | const result = rulesFn(input); 230 | 231 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 232 | expect(result.returnValue).toStrictEqual(['Denver', 'LA']); 233 | }); 234 | 235 | test('can use CONTAINS array function', () => { 236 | const rulesFn = ruleFactory(['hasLondon = CONTAINS("London", cities)'], { 237 | trace: true, 238 | }); 239 | 240 | const input = { 241 | cities: ['Denver', 'London', 'LA'], 242 | onlyUSA: true, 243 | }; 244 | const result = rulesFn(input); 245 | 246 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 247 | expect(result.input?.hasLondon).toBe(true); 248 | }); 249 | }); 250 | 251 | describe('Assignment Operators', () => { 252 | test('can process increment operator +=', () => { 253 | const rulesFn = ruleFactory([ 254 | { if: 'price >= 100', then: 'discount += 20' }, 255 | { return: 'discount' }, 256 | ]); 257 | 258 | expect(rulesFn({ price: 100, discount: 10 })).toBe(30); 259 | }); 260 | test('minus equals: -=', () => { 261 | const rulesFn = ruleFactory([ 262 | { if: 'price >= 100', then: 'discount -= 5' }, 263 | { return: 'discount' }, 264 | ]); 265 | 266 | expect(rulesFn({ price: 100, discount: 10 })).toBe(5); 267 | }); 268 | 269 | test('times equals: *=', () => { 270 | const rulesFn = ruleFactory([ 271 | { if: 'price >= 100', then: 'discount *= 5' }, 272 | { return: 'discount' }, 273 | ]); 274 | expect(rulesFn({ price: 100, discount: 10 })).toBe(50); 275 | }); 276 | test('divided by equals: /=', () => { 277 | const rulesFn = ruleFactory([ 278 | { if: 'price >= 100', then: 'discount /= 5' }, 279 | { return: 'discount' }, 280 | ]); 281 | expect(rulesFn({ price: 100, discount: 10 })).toBe(2); 282 | }); 283 | test('nullish coalescing null: ??=', () => { 284 | const rulesFn = ruleFactory([ 285 | { if: 'price >= 100', then: 'discount ??= 13' }, 286 | { return: 'discount' }, 287 | ]); 288 | expect(rulesFn({ price: 100, discount: null })).toBe(13); 289 | }); 290 | 291 | test('nullish coalescing falsy: ??=', () => { 292 | const rulesFn = ruleFactory([ 293 | { if: 'price >= 100', then: 'discount ??= 13' }, 294 | { return: 'discount' }, 295 | ]); 296 | expect(rulesFn({ price: 100, discount: 0 })).toBe(0); 297 | }); 298 | }); 299 | 300 | describe('Nested Rule Structures', () => { 301 | test('can process complex rule expressions', () => { 302 | const rulesFn = ruleFactory( 303 | [ 304 | { if: 'price >= 25', then: 'discount = 5 * 2' }, 305 | { if: 'price >= 100', then: 'discount = 20 * 4' }, 306 | { return: 'discount' }, 307 | ], 308 | { trace: true }, 309 | ); 310 | 311 | const input = { price: 100 }; 312 | const result = rulesFn(input); 313 | 314 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 315 | expect(result.input.discount).toBe(80); 316 | expect(result.returnValue).toBe(80); 317 | }); 318 | }); 319 | 320 | describe('can use string matchers', () => { 321 | describe('STRING_CONTAINS', () => { 322 | it('should return true if the substring is present', () => { 323 | const result = ruleFactory('STRING_CONTAINS("hello", "hello world")')(); 324 | expect(result).toBe(true); 325 | }); 326 | it('should not return false if the substring is not present', () => { 327 | const result = ruleFactory('STRING_CONTAINS("goodbye", "hello world")')(); 328 | expect(result).toBe(false); 329 | }); 330 | }); 331 | 332 | describe('STRING_ENDS_WITH', () => { 333 | it('should return true if the string ends with the substring provided', () => { 334 | const result = ruleFactory('STRING_ENDS_WITH("o", "hello")')(); 335 | expect(result).toBe(true); 336 | }); 337 | it('should return true if the string does not end with the substring provided', () => { 338 | const result = ruleFactory('STRING_ENDS_WITH("x", "hello")')(); 339 | expect(result).toBe(false); 340 | }); 341 | }); 342 | 343 | describe('STRING_STARTS_WITH', () => { 344 | it('should return true if the string starts with the substring provided', () => { 345 | const result = ruleFactory('STRING_STARTS_WITH("hell", "hello")')(); 346 | expect(result).toBe(true); 347 | }); 348 | it('should return true if the string does not start with the substring provided', () => { 349 | const result = ruleFactory('STRING_STARTS_WITH("x", "hello")')(); 350 | expect(result).toBe(false); 351 | }); 352 | }); 353 | }); 354 | 355 | describe('can use functional object helpers', () => { 356 | test('OBJECT_CONTAINS', () => { 357 | const rulesFn = ruleFactory('return OBJECT_CONTAINS("London", cities)'); 358 | 359 | const hasLondonInput = { 360 | cities: { Denver: true, London: true, LA: true }, 361 | }; 362 | const hasTaipeiInput = { 363 | cities: { Denver: true, Taipei: true, LA: true }, 364 | }; 365 | 366 | expect(rulesFn(hasLondonInput)).toBe(true); 367 | expect(rulesFn(hasTaipeiInput)).toBe(false); 368 | }); 369 | 370 | test('COUNT_KEYS', () => { 371 | const rulesFn = ruleFactory('return COUNT_KEYS(cities)'); 372 | const input = { 373 | cities: { Denver: true, London: true, LA: true }, 374 | }; 375 | 376 | expect(rulesFn(input)).toBe(3); 377 | }); 378 | 379 | test('OMIT', () => { 380 | const rulesFn = ruleFactory('return OMIT("London", cities)'); 381 | const input = { 382 | cities: { Denver: true, London: true, LA: true }, 383 | }; 384 | 385 | expect(rulesFn(input)).toStrictEqual({ 386 | Denver: true, 387 | LA: true, 388 | }); 389 | }); 390 | }); 391 | 392 | describe('can throw', () => { 393 | it('should throw an error', () => { 394 | expect(() => ruleFactory('THROW "my error"')()).toThrow(); 395 | }); 396 | }); 397 | 398 | describe('try/catch', () => { 399 | it('should execute try block', () => { 400 | const rule = [ 401 | { try: 'status = "Success"', catch: 'status = "Failure"' }, 402 | { return: 'status' }, 403 | ]; 404 | 405 | expect(ruleFactory(rule)()).toBe('Success'); 406 | }); 407 | it('should execute catch block on error', () => { 408 | const rule = [ 409 | { try: 'THROW "error test"', catch: 'status = "Failure"' }, 410 | { return: 'status' }, 411 | ]; 412 | 413 | expect(ruleFactory(rule)()).toBe('Failure'); 414 | }); 415 | }); 416 | 417 | describe('nested rules', () => { 418 | it('should process nested then', () => { 419 | const rules = [ 420 | 'result = "fail"', 421 | { 422 | if: 'true == true', 423 | then: { 424 | if: 'true == true', 425 | then: 'result = "success"', 426 | }, 427 | }, 428 | { return: 'result' }, 429 | ]; 430 | expect(ruleFactory(rules)()).toBe('success'); 431 | }); 432 | 433 | it('should process nested else', () => { 434 | const rules = [ 435 | 'result = "fail"', 436 | { 437 | if: 'false == true', 438 | then: 'result = "never"', 439 | else: { 440 | if: 'false == true', 441 | then: 'result = "never"', 442 | else: 'result = "success"', 443 | }, 444 | }, 445 | { return: 'result' }, 446 | ]; 447 | expect(ruleFactory(rules)()).toBe('success'); 448 | }); 449 | 450 | it('should process nested try', () => { 451 | const rules = [ 452 | 'result = "fail"', 453 | { 454 | try: { try: 'result = "success"', catch: 'result = "never"' }, 455 | catch: 'result = "never"', 456 | }, 457 | { return: 'result' }, 458 | ]; 459 | expect(ruleFactory(rules)()).toBe('success'); 460 | }); 461 | 462 | it('should process nested catch', () => { 463 | const rules = [ 464 | 'result = "fail"', 465 | { 466 | try: 'THROW "error"', 467 | catch: { try: 'THROW "error"', catch: 'result = "success"' }, 468 | }, 469 | { return: 'result' }, 470 | ]; 471 | expect(ruleFactory(rules)()).toBe('success'); 472 | }); 473 | }); 474 | 475 | describe('Edge cases', () => { 476 | test('should not lose input when rules are missing', () => { 477 | const rulesFn = ruleFactory([]); 478 | expect(rulesFn({ input: 42 })).toEqual({ input: 42 }); 479 | }); 480 | test('should not lose input when conditional does not match', () => { 481 | const rulesFn = ruleFactory( 482 | [ 483 | { 484 | if: 'undefinedKey == false', 485 | then: 'neverSet = 9999', 486 | }, 487 | ], 488 | { trace: true }, 489 | ); 490 | 491 | const result = rulesFn({ input: 42 }); 492 | // expect(result.returnValue).toEqual({ input: 42 }); 493 | expect(result.lastValue).toEqual({ input: 42 }); 494 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 495 | }); 496 | test('should not lose input when conditional does match', () => { 497 | const rulesFn = ruleFactory( 498 | [ 499 | { 500 | if: 'input == 42', 501 | then: 'doSet = 9999', 502 | }, 503 | ], 504 | { trace: true }, 505 | ); 506 | 507 | const result = rulesFn({ input: 42 }); 508 | expect(result.lastValue).toEqual({ input: 42, doSet: 9999 }); 509 | expect(result.trace.map(omitRuntime)).toMatchSnapshot(); 510 | }); 511 | test('should not lose input when conditional does not match and using a return object', () => { 512 | const rulesFn = ruleFactory( 513 | [ 514 | { 515 | if: 'undefinedKey == true', 516 | then: 'neverSet = 9999', 517 | }, 518 | { return: 'input' }, 519 | ], 520 | { trace: true }, 521 | ); 522 | const result = rulesFn({ input: 42 }); 523 | expect(result.returnValue).toEqual(42); 524 | expect(result.lastValue).toEqual(42); 525 | }); 526 | }); 527 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // import debug from 'debug'; 2 | import get from 'lodash/get.js'; 3 | import set from 'lodash/set.js'; 4 | import { isBoolean, isNumber, autoDetectType, toArray } from './utils/utils'; 5 | import performance from './utils/performance'; 6 | import { 7 | assignmentOperators, 8 | ruleExpressionLanguage, 9 | } from './expression-language'; 10 | import { init } from 'expressionparser'; 11 | import { ExpressionValue } from 'expressionparser/dist/ExpressionParser.js'; 12 | import { UserError } from './utils/errors'; 13 | 14 | const trailingQuotes = /^('|").*('|")$/g; 15 | 16 | // const oneify = (value?: TList[] | TList) => value != null && Array.isArray(value) && value.length === 1 ? value[0] : value; 17 | const serialize = (data: unknown) => 18 | data !== null && typeof data === 'object' ? JSON.stringify(data) : data; 19 | 20 | interface RuleMachineOptions { 21 | trace?: boolean; 22 | ignoreMissingKeys?: boolean; 23 | } 24 | 25 | interface TraceRow { 26 | startTime?: number; 27 | runTime?: number; 28 | 29 | operation: string; 30 | rule?: Rule; 31 | input?: any; 32 | result?: any; 33 | stepRow?: number; 34 | stepCount?: number; 35 | lhs?: string; 36 | value?: ExpressionValue; 37 | error?: any; 38 | [key: string]: unknown; 39 | } 40 | 41 | const arrayMethods = ['map', 'filter', 'every', 'some', 'find'] as const; 42 | const isArrayRule = (data: object) => 43 | Object.keys(data).some((key) => key in arrayMethods); 44 | 45 | export function ruleFactory< 46 | TInput extends { 47 | [k: string]: string | boolean | number | null | undefined | TInput; 48 | } = any, 49 | >( 50 | rules: Rule, 51 | options: RuleMachineOptions | undefined = { 52 | trace: false, 53 | ignoreMissingKeys: true, 54 | }, 55 | ) { 56 | if (typeof options === 'string') 57 | options = { name: options } as RuleMachineOptions; 58 | 59 | const { trace, ignoreMissingKeys = true } = options; 60 | // Validate, parse & load rules 61 | // Then return a function that takes an input object and returns a RuleTrace[] 62 | return function executeRulePipeline(input: TInput = {} as TInput) { 63 | const traceSimple: TraceRow[] = []; 64 | 65 | function logTrace({ operation, rule, ...args }: TraceRow) { 66 | if (trace) traceSimple.push({ operation, rule, ...args }); 67 | } 68 | 69 | let stepRow = 0; 70 | let stepCount = 0; 71 | const BREAK = 'BREAK'; 72 | const results = { 73 | input, 74 | trace: traceSimple, 75 | lastValue: input as any, 76 | returnValue: input as any, 77 | }; 78 | 79 | checkInvalidKeys(input); 80 | 81 | // Note: previously used more complex logic here 82 | // TODO: refactor & remove the `getReturnValue()` function 83 | const getReturnValue = () => results.lastValue; 84 | 85 | const startTime = performance.now(); 86 | logTrace({ operation: 'begin', startTime }); 87 | 88 | const parser = init(ruleExpressionLanguage, (term: string) => { 89 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 90 | if (typeof term !== 'string') throw new Error(`Invalid term: ${term}`); 91 | try { 92 | const result = 93 | extractValueOrLiteral( 94 | input, 95 | term, 96 | stepRow, 97 | stepCount, 98 | ignoreMissingKeys, 99 | ) ?? get(input, term, undefined as any); 100 | // console.log(`TERM: ${term} => ${result}`); 101 | logTrace({ 102 | operation: 'key.lookup', 103 | key: term, 104 | value: result, 105 | stepRow, 106 | stepCount, 107 | }); 108 | return result; 109 | } catch (error) { 110 | logTrace({ 111 | operation: 'error', 112 | error, 113 | rule: term, 114 | stepRow, 115 | stepCount, 116 | }); 117 | } 118 | }); 119 | 120 | const handleRule = (rule: Rule) => { 121 | if (typeof rule === 'string') { 122 | results.lastValue = evaluateRule({ stepRow, input, rule }); 123 | if (trace) 124 | logTrace({ 125 | operation: 'ruleString', 126 | rule, 127 | result: serialize(results.lastValue), 128 | currentState: serialize(input), 129 | stepRow, 130 | stepCount, 131 | }); 132 | } else if (Array.isArray(rule)) { 133 | results.lastValue = rule.map((rule) => 134 | // this alloms nested arrays of rules 135 | typeof rule === 'string' 136 | ? evaluateRule({ stepRow, input, rule, ignoreMissingKeys }) 137 | : handleRule(rule), 138 | ); 139 | if (trace) 140 | logTrace({ 141 | operation: 'ruleString[]', 142 | rule, 143 | result: serialize(results.lastValue), 144 | currentState: serialize(input), 145 | stepRow, 146 | stepCount, 147 | }); 148 | } else if ('if' in rule) { 149 | results.lastValue = input; // set the current state to the input object. 150 | 151 | let conditionResult: RuleResult; 152 | if (typeof rule.if === 'object' && 'and' in rule.if) { 153 | const and = toArray(rule.if.and); 154 | for (const rule of and) { 155 | conditionResult = evaluateRule({ 156 | stepRow, 157 | input, 158 | rule, 159 | }); 160 | if (!conditionResult) break; 161 | } 162 | if (trace) 163 | logTrace({ 164 | operation: 'if.and', 165 | rule: and, 166 | result: serialize(conditionResult), 167 | currentState: serialize(input), 168 | stepRow, 169 | stepCount, 170 | }); 171 | } else if (typeof rule.if === 'object' && 'or' in rule.if) { 172 | const or = toArray(rule.if.or); 173 | for (const rule of or) { 174 | conditionResult = evaluateRule({ 175 | stepRow, 176 | input, 177 | rule, 178 | }); 179 | if (conditionResult) break; 180 | } 181 | if (trace) 182 | logTrace({ 183 | operation: 'if.or', 184 | rule: or, 185 | result: serialize(conditionResult), 186 | currentState: serialize(input), 187 | stepRow, 188 | stepCount, 189 | }); 190 | } else if (typeof rule.if !== 'string' && Array.isArray(rule.if)) { 191 | throw new UserError( 192 | 'The `if` value must be a string or logical object (e.g. `{and/if: []}`.) Arrays are currently not supported.', 193 | ); 194 | } else if (typeof rule.if === 'string') { 195 | conditionResult = Boolean( 196 | evaluateRule({ 197 | stepRow, 198 | input, 199 | rule: rule.if, 200 | }), 201 | ); 202 | if (trace) 203 | logTrace({ 204 | operation: 'if', 205 | rule: rule.if, 206 | result: serialize(conditionResult), 207 | currentState: serialize(input), 208 | stepRow, 209 | stepCount, 210 | }); 211 | } 212 | // Now check the condition result 213 | if (conditionResult && rule.then) { 214 | if (trace) 215 | logTrace({ 216 | operation: 'if.then', 217 | rule: rule.then, 218 | currentState: serialize(input), 219 | stepRow, 220 | stepCount, 221 | }); 222 | handleRule(rule.then); 223 | } else if (!conditionResult && rule.else) { 224 | if (trace) 225 | logTrace({ 226 | operation: 'if.else', 227 | rule: rule.else, 228 | currentState: serialize(input), 229 | stepRow, 230 | stepCount, 231 | }); 232 | handleRule(rule.else); 233 | } else { 234 | results.lastValue = input; 235 | } 236 | } else if ('return' in rule) { 237 | const returnResult = evaluateRule({ 238 | stepRow, 239 | input, 240 | rule: rule.return, 241 | ignoreMissingKeys: true, 242 | }); 243 | results.lastValue = returnResult; 244 | results.returnValue = returnResult; 245 | if (trace) 246 | logTrace({ 247 | operation: 'return', 248 | rule: rule.return, 249 | result: serialize(returnResult), 250 | currentState: serialize(input), 251 | stepRow, 252 | stepCount, 253 | }); 254 | // eslint-disable-next-line @typescript-eslint/no-throw-literal 255 | return BREAK; 256 | } else if (isArrayRule(rule) && 'run' in rule) { 257 | const arrayRule = 258 | 'map' in rule 259 | ? rule.map 260 | : 'filter' in rule 261 | ? rule.filter 262 | : 'every' in rule 263 | ? rule.every 264 | : 'some' in rule 265 | ? rule.some 266 | : rule.find; 267 | const arrayOperator = Object.keys(rule).find( 268 | (key) => key in arrayMethods, 269 | ); 270 | if (!arrayOperator) 271 | throw new Error(`Invalid array rule: ${JSON.stringify(rule)}`); 272 | const arrayMethod = 273 | 'map' in rule 274 | ? Array.prototype.map 275 | : 'filter' in rule 276 | ? Array.prototype.filter 277 | : 'every' in rule 278 | ? Array.prototype.every 279 | : 'some' in rule 280 | ? Array.prototype.some 281 | : Array.prototype.find; 282 | 283 | if (trace) 284 | logTrace({ 285 | operation: `${arrayOperator}`, 286 | rule, 287 | currentState: serialize(input), 288 | stepRow, 289 | stepCount, 290 | }); 291 | const data = get(input, arrayRule); 292 | if (data == null) 293 | throw new UserError(`No data found at '${arrayRule}'`); 294 | if (!Array.isArray(data)) 295 | throw new UserError(`Data at '${arrayRule}' is not an array`); 296 | const arrayResult = arrayMethod.call(toArray(data), (item, index) => { 297 | Object.assign(input, { $item: item, $index: index, $array: data }); 298 | handleRule(rule.run); 299 | return results.lastValue; 300 | }); 301 | results.lastValue = arrayResult; 302 | if ('set' in rule) { 303 | // @ts-expect-error 304 | set(input, rule.set, arrayResult); 305 | } 306 | } else if ('try' in rule && 'catch' in rule) { 307 | try { 308 | if (trace) 309 | logTrace({ 310 | operation: 'try', 311 | rule: rule.try, 312 | currentState: serialize(input), 313 | stepRow, 314 | stepCount, 315 | }); 316 | handleRule(rule.try); 317 | } catch (e) { 318 | logTrace({ 319 | operation: 'catch', 320 | rule: rule.catch, 321 | currentState: serialize(input), 322 | stepRow, 323 | stepCount, 324 | }); 325 | handleRule(rule.catch); 326 | } 327 | } 328 | return results.lastValue; 329 | }; 330 | 331 | rules = toArray(rules); 332 | 333 | for (const rule of rules) { 334 | try { 335 | const ruleResult = handleRule(rule); 336 | if (ruleResult === BREAK) break; 337 | } catch (e) { 338 | logTrace({ 339 | operation: 'error', 340 | runTime: performance.now() - startTime, 341 | stepCount, 342 | currentState: serialize(input), 343 | stepRow, 344 | lastValue: e?.message, 345 | }); 346 | throw e; 347 | } 348 | stepRow++; 349 | } 350 | 351 | logTrace({ 352 | operation: 'complete', 353 | runTime: performance.now() - startTime, 354 | stepCount, 355 | currentState: serialize(input), 356 | stepRow, 357 | lastValue: serialize(getReturnValue()), 358 | }); 359 | 360 | if (trace) { 361 | // @ts-expect-error: todo: fix this, add proper type for Result 362 | results.runTime = performance.now() - startTime; 363 | return results; 364 | } else { 365 | return getReturnValue(); 366 | } 367 | 368 | type RuleResult = 369 | | string 370 | | boolean 371 | | number 372 | | null 373 | | undefined 374 | | {} 375 | | Array; 376 | 377 | function evaluateRule({ 378 | stepRow, 379 | input, 380 | rule, 381 | ignoreMissingKeys = false, 382 | }: { 383 | stepRow: number; 384 | input: TInput; 385 | rule: string | string[] | Rule; 386 | ignoreMissingKeys?: boolean; 387 | }): RuleResult { 388 | // checking only the first rule seems unsafe 389 | if (Array.isArray(rule) && typeof rule[0] === 'string') { 390 | return rule.flatMap((rule) => 391 | evaluateRule({ stepRow, input, rule, ignoreMissingKeys }), 392 | ); 393 | } 394 | if (typeof rule !== 'string') 395 | throw new UserError( 396 | `Nesting is not enabled for this rule type: ${JSON.stringify(rule)}`, 397 | ); 398 | 399 | stepCount++; 400 | 401 | try { 402 | const matchedOperator = assignmentOperators.find((op) => 403 | rule.includes(` ${op} `), 404 | ); 405 | 406 | if (matchedOperator) { 407 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 408 | const [lhs, _] = rule.split(matchedOperator, 2).map((s) => s.trim()); 409 | const value = parser.expressionToValue(rule); 410 | const previous = get(input, lhs); 411 | const result = set(input, lhs, value); 412 | results.lastValue = value; 413 | logTrace({ 414 | operation: 'evalRule', 415 | result: serialize(result), 416 | rule, 417 | lhs, 418 | value, 419 | previous: serialize(previous), 420 | stepRow, 421 | stepCount, 422 | }); 423 | return input as any; // value??? 424 | } else { 425 | const result = parser.expressionToValue(rule) as any; 426 | logTrace({ 427 | operation: 'expression', 428 | result, 429 | rule, 430 | stepRow, 431 | stepCount, 432 | }); 433 | results.lastValue = result; 434 | return result; 435 | } 436 | } catch (e) { 437 | logTrace({ 438 | operation: 'error', 439 | error: e.message, 440 | rule, 441 | stepRow, 442 | stepCount, 443 | }); 444 | if (e.name !== 'UserError') console.error('UNEXPECTED ERROR:', e); 445 | throw e; 446 | } 447 | } 448 | }; 449 | } 450 | 451 | export function extractValueOrLiteral< 452 | TInput extends { 453 | [k: string]: string | boolean | number | null | undefined | TInput; 454 | } = any, 455 | >( 456 | input: TInput, 457 | token: string, 458 | stepRow?: number, 459 | stepCount?: number, 460 | ignoreMissingKeys?: boolean, 461 | ) { 462 | const value = get(input, token); 463 | if (value) return value; 464 | 465 | if (trailingQuotes.test(token)) return token.replace(trailingQuotes, ''); 466 | if (isNumber(token) || isBoolean(token)) return autoDetectType(token); 467 | 468 | // throw if we got an undefined key 469 | if (!ignoreMissingKeys && token.length > 0) 470 | throw new Error(`Undefined key: ${token}`); 471 | 472 | if (ignoreMissingKeys) return undefined; 473 | throw Error( 474 | `Unrecognized token in rule expression ${token} (${stepRow}, ${stepCount})`, 475 | ); 476 | // if we have a string key and don't find it in the input, assume it's undefined. 477 | } 478 | 479 | function checkInvalidKeys(data: TInput) { 480 | const arrayFields = ['$item', '$index', '$array']; 481 | const dangerousKeys = [ 482 | '__proto__', 483 | 'prototype', 484 | 'constructor', 485 | 'toString', 486 | 'valueOf', 487 | 'hasOwnProperty', 488 | 'isPrototypeOf', 489 | 'propertyIsEnumerable', 490 | ]; 491 | const unsafeKeys = Object.keys(data).filter((key) => 492 | dangerousKeys.includes(key), 493 | ); 494 | if (unsafeKeys.length > 0) 495 | throw new UserError(`Unsafe keys found in input: ${unsafeKeys.join(', ')}`); 496 | if (arrayFields.some((key) => key in data)) 497 | throw new UserError( 498 | `Input contains reserved field name: ${arrayFields.join(', ')}`, 499 | ); 500 | return data; 501 | } 502 | 503 | export type Rule = 504 | | string 505 | | LogicalRule 506 | | AndRule 507 | | OrRule 508 | | EveryRule 509 | | SomeRule 510 | | FindRule 511 | | MapRule 512 | | FilterRule 513 | | ReturnRule 514 | | TryCatchRule 515 | | Rule[]; 516 | 517 | interface LogicalRule { 518 | if: AndRule | OrRule | string; 519 | then: Rule; 520 | else?: Rule; 521 | } 522 | interface AndRule { 523 | and: string[]; 524 | } 525 | interface OrRule { 526 | or: string[]; 527 | } 528 | interface MapRule { 529 | map: string; 530 | run: Rule; 531 | set?: string; 532 | } 533 | interface FilterRule { 534 | filter: string; 535 | run: Rule; 536 | set?: string; 537 | } 538 | interface EveryRule { 539 | every: string; 540 | run: Rule; 541 | set?: string; 542 | } 543 | interface SomeRule { 544 | some: string; 545 | run: Rule; 546 | set?: string; 547 | } 548 | interface FindRule { 549 | find: string; 550 | run: Rule; 551 | set?: string; 552 | } 553 | interface ReturnRule { 554 | return: string; 555 | } 556 | interface TryCatchRule { 557 | try: Rule; 558 | catch: Rule; 559 | } 560 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type { ruleFactory, Rule } from './index'; 2 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class UserError extends Error { 2 | name = 'UserError'; 3 | constructor(message: string, debugMode = true) { 4 | super(message); 5 | // this.stack = debugMode ? this.stack : ''; 6 | } 7 | 8 | get stack() { 9 | return ''; 10 | } 11 | 12 | toString() { 13 | return this.message; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/mockDateHelper.ts: -------------------------------------------------------------------------------- 1 | export default function mockDateHelper(targetDate: Date | string | number) { 2 | const currentDate = 3 | targetDate instanceof Date ? targetDate : new Date(targetDate); 4 | const RealDate = Date; 5 | // @ts-expect-error 6 | global.Date = class extends Date { 7 | constructor(...args: unknown[]) { 8 | // @ts-expect-error 9 | super(...args); 10 | if (args.length === 0) return currentDate; 11 | 12 | // @ts-expect-error 13 | return new RealDate(...args); 14 | } 15 | 16 | static now = () => currentDate.getTime(); 17 | }; 18 | 19 | const cleanup = () => { 20 | global.Date = RealDate; 21 | }; 22 | 23 | return cleanup; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/performance.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | /* eslint-disable @typescript-eslint/no-use-before-define */ 3 | 'use strict'; 4 | 5 | // @license http://opensource.org/licenses/MIT 6 | // copyright Paul Irish 2015 7 | // Added code by Aaron Levine from: https://gist.github.com/Aldlevine/3f716f447322edbb3671 8 | // Some modifications by Joan Alba Maldonado. 9 | // as Safari 6 doesn't have support for NavigationTiming, we use a Date.now() timestamp for relative values 10 | // if you want values similar to what you'd get with real perf.now, place this towards the head of the page 11 | // but in reality, you're just getting the delta between now() calls, so it's not terribly important where it's placed 12 | // Gist: https://gist.github.com/jalbam/cc805ac3cfe14004ecdf323159ecf40e 13 | // TODO: Think about adding vendor prefixes. 14 | 15 | const performance = { 16 | now() { 17 | // console.warn('Uninitialized performance.now() polyfill'); 18 | return Date.now(); 19 | }, 20 | }; 21 | 22 | var window: any = typeof window !== 'undefined' ? window : {}; 23 | 24 | void (async function () { 25 | if (window?.performance?.now) { 26 | performance.now = () => window.performance.now(); 27 | return; 28 | } 29 | try { 30 | // Check for node environment 31 | const perfHooks = await import('perf_hooks'); 32 | performance.now = () => perfHooks.performance?.now(); 33 | } catch (error) { 34 | /* ignore, couldn't import high-res timer */ 35 | } 36 | })(); 37 | 38 | export default performance; 39 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject.js'; 2 | 3 | export function autoDetectType( 4 | value: any, 5 | ): any[] | string | boolean | number | null | {} { 6 | if (value == null) return null; 7 | if (isBoolean(value)) return toBoolean(value); 8 | if (isNumber(value)) return toNumber(value); 9 | if (isArray(value)) return toArray(value); 10 | if (isObject(value)) return value; 11 | return `${value}`; 12 | } 13 | 14 | export function isNumber(value: string): boolean { 15 | return /^[0-9.]+$/.test(`${value}`); 16 | } 17 | 18 | export function toNumber(value: string | number): number { 19 | return typeof value === 'number' 20 | ? value 21 | : /^[0-9]+$/.test(value) 22 | ? parseInt(value, 10) 23 | : parseFloat(value); 24 | } 25 | 26 | export function isBoolean(value: string): boolean { 27 | return ['true', 'false', 'yes', 'no', 'on', 'off', '0', '1'].includes( 28 | `${value}`.toLowerCase(), 29 | ); 30 | } 31 | 32 | export function toBoolean(value: any) { 33 | value = `${value}`.toString().toLowerCase(); 34 | return value === 'true' || value === 'yes' || value === 'on' || value === '1'; 35 | } 36 | 37 | export function toArray(input: TInput | TInput[]): TInput[] { 38 | return Array.isArray(input) && typeof input !== 'string' ? input : [input]; 39 | } 40 | 41 | export function isArray(input: unknown) { 42 | return Array.isArray(input) && typeof input !== 'string'; 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "alwaysStrict": true, 6 | "baseUrl": ".", 7 | 8 | "declaration": true, 9 | // "declarationMap": true, 10 | // "declarationDir": "./dist/types", 11 | 12 | "esModuleInterop": true, 13 | // "extendedDiagnostics": true, 14 | "forceConsistentCasingInFileNames": true, 15 | // "isolatedModules": true, 16 | // "jsx": "preserve", 17 | // "lib": ["esnext"], 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | // "noEmit": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "outDir": "dist", 23 | "resolveJsonModule": true, 24 | "rootDir": "./", 25 | "skipLibCheck": true, 26 | "sourceMap": true, 27 | "strict": true, 28 | "strictNullChecks": true, 29 | "noErrorTruncation": true, 30 | "target": "ES2020", 31 | "useUnknownInCatchVariables": false 32 | }, 33 | "include": ["**/*.ts", "./.*.js"], 34 | "exclude": ["**/node_modules", "__snapshots__", "**/dist"] 35 | } 36 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { toBoolean } from './src/utils/utils'; 2 | import type { Format, Options } from 'tsup'; 3 | 4 | const format: Format[] = ['cjs', 'esm', 'iife']; 5 | 6 | const env: 'production' | 'development' = 7 | process.env.NODE_ENV === 'production' ? 'production' : 'development'; 8 | const isProd = env === 'production'; 9 | 10 | const singleBundleFile = toBoolean(process.env.BUNDLE_ALL); 11 | const inlinePackagePatterns = singleBundleFile 12 | ? [/lodash\/.*/, 'ms', /expressionparser\/.*/] 13 | : []; 14 | 15 | export default { 16 | format, 17 | outDir: 'dist', 18 | platform: 'node', 19 | target: 'node14', 20 | entry: ['src/index.ts'], 21 | globalName: 'RulesMachine', 22 | clean: true, 23 | bundle: true, 24 | metafile: true, 25 | minify: isProd, 26 | resolve: true, 27 | dts: { 28 | resolve: true, 29 | // build types for `src/index.ts` only 30 | // otherwise `Options` will not be exported by `tsup`, not sure how this happens, probably a bug in rollup-plugin-dts 31 | entry: './src/index.ts', 32 | }, 33 | 34 | skipNodeModulesBundle: singleBundleFile, 35 | sourcemap: true, 36 | // splitting: false,// 37 | noExternal: inlinePackagePatterns, 38 | }; 39 | --------------------------------------------------------------------------------