├── .eslintignore ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ └── FEATURE_REQUEST.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── release-please.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── condition.spec.ts ├── condition.ts ├── default-engine.ts ├── default-operators.ts ├── engine.ts ├── engne.spec.ts ├── index.ts ├── interfaces.ts ├── operator.ts ├── rule.spec.ts └── rule.ts ├── test └── mock-data.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | env: { 5 | browser: true, 6 | node: true, 7 | }, 8 | plugins: ['@typescript-eslint'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'prettier', 13 | ], 14 | rules: { 15 | '@typescript-eslint/explicit-module-boundary-types': 'off', 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 6 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 7 | disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, 8 | socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to creating a positive environment include: 13 | 14 | - Using welcoming and inclusive language 15 | - Being respectful of differing viewpoints and experiences 16 | - Gracefully accepting constructive criticism 17 | - Focusing on what is best for the community 18 | - Showing empathy towards other community members 19 | 20 | Examples of unacceptable behavior by participants include: 21 | 22 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 23 | - Trolling, insulting/derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 26 | - Other conduct which could reasonably be considered inappropriate in a professional setting 27 | 28 | ## Our Responsibilities 29 | 30 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take 31 | appropriate and fair corrective action in response to any instances of unacceptable behavior. 32 | 33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, 34 | issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any 35 | contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 36 | 37 | ## Scope 38 | 39 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the 40 | project or its community. Examples of representing a project or community include using an official project e-mail 41 | address, posting via an official social media account, or acting as an appointed representative at an online or offline 42 | event. Representation of a project may be further defined and clarified by project maintainers. 43 | 44 | ## Enforcement 45 | 46 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. 47 | All complaints will be reviewed and investigated and will result in a response that is deemed 48 | necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard 49 | to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 50 | 51 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent 52 | repercussions as determined by other members of the project's leadership. 53 | 54 | ## Attribution 55 | 56 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, 57 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 58 | 59 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 60 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | As a contributor, here are the guidelines we would like you to follow. 4 | 5 | - [Creating an Issue](#issue) 6 | - [Creating a Pull Request](#pull-request) 7 | - [Commit Message Guidelines](#commit) 8 | - [License](#license) 9 | 10 | ## Creating an Issue 11 | 12 | If you think you have found a bug, or have a new feature idea, please start by making sure it hasn't 13 | already been reported. You can search through existing issues to see if there is a similar one reported. Include closed 14 | issues as it may have been closed with a solution. 15 | 16 | If you would like to implement a new feature, please submit an issue with a proposal for your work 17 | first, to be sure that we can use it. 18 | 19 | ## Creating a Pull Request 20 | 21 | Before submitting a pull request, we ask that you please create an issue that explains the bug or feature request and 22 | let us know that you plan on creating a pull request for it. If an issue already exists, please comment on that issue 23 | letting us know you would like to submit a pull request for it. This helps us to keep track of the pull request and 24 | make sure there isn't duplicated effort. 25 | 26 | ## Commit Message Guidelines 27 | 28 | Each commit message should follow the [Conventional Commits](https://conventionalcommits.org/) standard. 29 | 30 | ## License 31 | 32 | By contributing your code to the this repository, you agree to license your contribution under the 33 | specified [license](../LICENSE). 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Type 2 | 3 | What kind of issue is this? 4 | 5 | 6 | 7 | ``` 8 | [ ] Bug report. 9 | [ ] Feature request. 10 | ``` 11 | 12 | ## Description the Issue 13 | 14 | 15 | 16 | Todo 17 | 18 | ## Steps to Reproduce 19 | 20 | 21 | 22 | n/a 23 | 24 | ## Other Information 25 | 26 | 27 | 28 | n/a 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Tell us about a bug you may have identified 4 | --- 5 | 6 | ## Describe the Issue 7 | 8 | 9 | 10 | Todo 11 | 12 | ## Expected behavior 13 | 14 | 15 | 16 | Todo 17 | 18 | ## Steps to Reproduce 19 | 20 | 21 | 22 | n/a 23 | 24 | ## Other Information 25 | 26 | 27 | 28 | n/a 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | ## Describe the Feature 7 | 8 | 9 | 10 | Todo 11 | 12 | ## Suggested Solution 13 | 14 | 15 | 16 | Todo 17 | 18 | ## Other Information 19 | 20 | 21 | 22 | n/a 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | Todo 6 | 7 | ## Checklist 8 | 9 | Please ensure your pull request fulfills the following requirements: 10 | 11 | - [ ] The commit messages follow our guidelines ([CONTRIBUTING.md](./CONTRIBUTING.md)). 12 | - [ ] Tests for any changes have been added (for bug fixes / features). 13 | - [ ] Docs have been added / updated (for bug fixes / features). 14 | 15 | ## Type 16 | 17 | What kind of change does this pull request introduce? 18 | 19 | 20 | 21 | ``` 22 | [ ] Bug. 23 | [ ] Feature. 24 | [ ] Code style update (formatting, local variables). 25 | [ ] Refactoring (no functional changes, no api changes). 26 | [ ] Build related changes. 27 | [ ] CI related changes. 28 | [ ] Documentation content changes. 29 | [ ] Other (please describe below). 30 | ``` 31 | 32 | ## Breaking Changes 33 | 34 | Does this pull request introduce any breaking changes? 35 | 36 | 37 | 38 | ``` 39 | [ ] Yes 40 | [ ] No 41 | ``` 42 | 43 | 44 | 45 | ## Other Information 46 | 47 | 48 | 49 | n/a 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Install 13 | run: npm ci 14 | 15 | - name: Lint 16 | run: npm run lint 17 | 18 | - name: Test 19 | run: npm run test 20 | env: 21 | CI: true 22 | 23 | - name: Coverage 24 | uses: codecov/codecov-action@v4 25 | 26 | - name: Build 27 | run: npm run build 28 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v4 17 | id: release 18 | with: 19 | release-type: node 20 | package-name: js-rules-engine 21 | - uses: actions/checkout@v4 22 | if: ${{ steps.release.outputs.release_created }} 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 18 26 | registry-url: 'https://registry.npmjs.org' 27 | if: ${{ steps.release.outputs.release_created }} 28 | - run: npm ci 29 | if: ${{ steps.release.outputs.release_created }} 30 | - run: npm publish 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 33 | if: ${{ steps.release.outputs.release_created }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .idea 3 | .settings 4 | .vs 5 | .vscode 6 | 7 | # Other 8 | .nyc_output/ 9 | coverage/ 10 | dist/ 11 | node_modules/ 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | CHANGELOG.md 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.1.1](https://github.com/justinlettau/js-rules-engine/compare/v1.1.0...v1.1.1) (2020-11-28) 6 | 7 | # [1.1.0](https://github.com/justinlettau/js-rules-engine/compare/v1.0.2...v1.1.0) (2019-03-17) 8 | 9 | 10 | ### Features 11 | 12 | * simplify json structure ([9f8b14f](https://github.com/justinlettau/js-rules-engine/commit/9f8b14f)) 13 | 14 | 15 | 16 | ## [1.0.2](https://github.com/justinlettau/js-rules-engine/compare/v1.0.1...v1.0.2) (2019-03-16) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * **rule:** evaluation of nested conditions ([006e26f](https://github.com/justinlettau/js-rules-engine/commit/006e26f)) 22 | 23 | 24 | 25 | ## 1.0.1 (2019-03-16) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Justin Lettau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM Version](https://badge.fury.io/js/js-rules-engine.svg)](https://badge.fury.io/js/js-rules-engine) 2 | [![CI](https://github.com/justinlettau/js-rules-engine/workflows/CI/badge.svg)](https://github.com/justinlettau/js-rules-engine/actions) 3 | 4 | # Rules Engine 5 | 6 | JavaScript rules engine for validating data object structures. 7 | 8 | # Table of Contents 9 | 10 | - [Features](#features) 11 | - [Installation](#installation) 12 | - [Usage](#usage) 13 | - [Default Engine](#default-engine) 14 | - [Override Engine](#override-engine) 15 | - [Default Operators](#default-operators) 16 | - [Customizing Operators](#customizing-operators) 17 | - [Rule Conditions](#rule-conditions) 18 | - [Persisting Rules](#persisting-rules) 19 | - [Development](#development) 20 | 21 | # Features 22 | 23 | - 💪 Easy to use **chainable API**. 24 | - 💥 Support for **infinitely nested** `AND` / `OR` conditions. 25 | - 🚀 Rules can be expressed in **simple JSON**. 26 | - ✔️ **Customize operators** with your own functions. 27 | - 🏄 Access nested properties with **dot notation** paths. 28 | 29 | # Installation 30 | 31 | ```bash 32 | npm install js-rules-engine --save 33 | ``` 34 | 35 | # Usage 36 | 37 | ```js 38 | import { Rule } from 'js-rules-engine'; 39 | 40 | // homeWorld.name equals 'Tatooine' AND (name contains 'Skywalker' OR eyeColor is 'green') 41 | const rule = new Rule().equals('homeWorld.name', 'Tatooine').or((sub) => { 42 | sub.contains('name', 'Skywalker').equals('eyeColor', 'green'); 43 | }); 44 | 45 | // object of data to evaluate rule against 46 | const fact = { 47 | eyeColor: 'blue', 48 | homeWorld: { 49 | name: 'Tatooine', 50 | }, 51 | name: 'Luke Skywalker', 52 | }; 53 | 54 | rule.evaluate(fact); 55 | // => true 56 | ``` 57 | 58 | ## Default Engine 59 | 60 | An `Engine` contains all operators available to a `Rule`. By default, a single `Engine` instance is used for all `Rule` instances. The default `Engine`'s operators can be customized like this: 61 | 62 | ```js 63 | import { defaultEngine } from 'js-rules-engine'; 64 | 65 | defaultEngine.removeOperator('greaterThan'); 66 | defaultEngine.addOperator('moreGreaterThan', myAwesomeFunction); 67 | ``` 68 | 69 | ## Override Engine 70 | 71 | Each instance of `Rule` has the ability to use it's own `Engine` instance, overriding the default. 72 | 73 | ```js 74 | import { Engine, Rule } from 'js-rules-engine'; 75 | 76 | const engine = new Engine(); 77 | const rule = new Rule(null, engine); 78 | ``` 79 | 80 | ## Default Operators 81 | 82 | Each `Engine` contains the follow operators by default: 83 | 84 | - `equals` 85 | - `notEquals` 86 | - `in` 87 | - `notIn` 88 | - `contains` 89 | - `notContains` 90 | - `lessThan` 91 | - `lessThanOrEquals` 92 | - `greaterThan` 93 | - `greaterThanOrEquals` 94 | 95 | ## Customizing Operators 96 | 97 | Add your own operators to an `Engine`. Once added, any custom `Operator` can be used via the `Rule`'s `add()` method. 98 | 99 | ```js 100 | import { defaultEngine, Operator } from 'js-rules-engine'; 101 | 102 | const noop = new Operator('noop', (a, b) => true); 103 | defaultEngine.addOperator(noop); 104 | ``` 105 | 106 | You can also remove an `Operator`. 107 | 108 | ```js 109 | import { defaultEngine } from 'js-rules-engine'; 110 | 111 | defaultEngine.removeOperator('noop'); 112 | ``` 113 | 114 | ## Rule Conditions 115 | 116 | The `add` method is a generic way to add a condition to the `Rule`. The conditions operator is added via it's `name`. 117 | The `value` type should match what the operator is expecting. 118 | 119 | | Param | Description | Type | 120 | | ---------- | ----------------------------------- | -------- | 121 | | `fact` | Property name or dot notation path. | `string` | 122 | | `operator` | Name of operator to use. | `string` | 123 | | `value` | Value to compare. | `any` | 124 | 125 | A `Rule` has shortcut methods for all default operators. Each method takes two arguments (`fact` and `value`) and returns 126 | the `Rule` instance for chaining. 127 | 128 | | Method | Fact Type | Value Type | 129 | | --------------------- | --------- | ---------- | 130 | | `equals` | `string` | `any` | 131 | | `notEquals` | `string` | `any` | 132 | | `in` | `string` | `string` | 133 | | `notIn` | `string` | `string` | 134 | | `contains` | `string` | `any` | 135 | | `notContains` | `string` | `any` | 136 | | `lessThan` | `string` | `number` | 137 | | `lessThanOrEquals` | `string` | `number` | 138 | | `greaterThan` | `string` | `number` | 139 | | `greaterThanOrEquals` | `string` | `number` | 140 | 141 | Nested conditions can be achieved with the `and()` / `or()` methods. Each methods takes one parameter, a callback 142 | function that is supplied with a nested `Rule` instance as the first argument, and returns the original `Rule` instance 143 | for chaining. 144 | 145 | ## Persisting Rules 146 | 147 | Rules can easily be converted to JSON and persisted to a database, file system, or elsewhere. 148 | 149 | ```js 150 | // save rule as JSON string ... 151 | const jsonString = JSON.stringify(rule); 152 | localStorage.setItem('persistedRule', jsonString); 153 | ``` 154 | 155 | ```js 156 | // ... and hydrate rules from a JSON object! 157 | const jsonString = localStorage.getItem('persistedRule'); 158 | const json = JSON.parse(jsonString); 159 | const rule = new Rule(json); 160 | ``` 161 | 162 | Example JSON structure: 163 | 164 | ```json 165 | { 166 | "and": [ 167 | { 168 | "fact": "homeWorld.name", 169 | "operator": "equals", 170 | "value": "Tatooine" 171 | }, 172 | { 173 | "or": [ 174 | { 175 | "fact": "name", 176 | "operator": "contains", 177 | "value": "Skywalker" 178 | }, 179 | { 180 | "fact": "eyeColor", 181 | "operator": "equals", 182 | "value": "green" 183 | } 184 | ] 185 | } 186 | ] 187 | } 188 | ``` 189 | 190 | # Development 191 | 192 | ``` 193 | npm install 194 | npm run build 195 | ``` 196 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | collectCoverage: true, 5 | roots: ['/src/'], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-rules-engine", 3 | "description": "JavaScript rules engine for validating data object structures.", 4 | "version": "1.1.1", 5 | "keywords": [ 6 | "rules engine", 7 | "rules", 8 | "engine", 9 | "javascript", 10 | "json" 11 | ], 12 | "author": { 13 | "name": "Justin Lettau", 14 | "email": "me@justinlettau.com", 15 | "url": "https://justinlettau.com" 16 | }, 17 | "license": "MIT", 18 | "homepage": "https://github.com/justinlettau/js-rules-engine", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/justinlettau/js-rules-engine" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/justinlettau/js-rules-engine/issues" 25 | }, 26 | "main": "dist/index.js", 27 | "types": "dist/index.d.js", 28 | "files": [ 29 | "dist/" 30 | ], 31 | "scripts": { 32 | "lint": "eslint . --ext .js,.ts", 33 | "test": "jest", 34 | "build": "tsc -p ./tsconfig.json", 35 | "prepublishOnly": "npm run build", 36 | "format": "prettier --write ." 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "pretty-quick --staged" 41 | } 42 | }, 43 | "devDependencies": { 44 | "@types/jest": "^26.0.24", 45 | "@typescript-eslint/eslint-plugin": "^4.29.0", 46 | "@typescript-eslint/parser": "^4.29.0", 47 | "eslint": "^7.32.0", 48 | "eslint-config-prettier": "^8.3.0", 49 | "husky": "^7.0.1", 50 | "jest": "^27.0.6", 51 | "prettier": "^2.3.2", 52 | "pretty-quick": "^3.1.1", 53 | "ts-jest": "^27.0.4", 54 | "ts-node": "^10.1.0", 55 | "typescript": "^4.3.5" 56 | }, 57 | "dependencies": { 58 | "ts-dot-prop": "^1.4.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/condition.spec.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from './condition'; 2 | import { defaultEngine } from './default-engine'; 3 | import { ConditionJson } from './interfaces'; 4 | 5 | describe('Condition class', () => { 6 | describe('constructor', () => { 7 | const json: ConditionJson = { 8 | fact: 'name', 9 | operator: 'equals', 10 | value: 'Luke Skywalker', 11 | }; 12 | 13 | it('should hydrate correctly', () => { 14 | const condition = new Condition(json, defaultEngine) as any; 15 | expect(condition.engine).toBeDefined(); 16 | expect(condition.fact).toEqual('name'); 17 | expect(condition.operator).toEqual(defaultEngine.getOperator('equals')); 18 | expect(condition.value).toEqual('Luke Skywalker'); 19 | }); 20 | 21 | it('should require json param', () => { 22 | expect( 23 | () => new Condition(undefined as any, defaultEngine) 24 | ).toThrowError(); 25 | }); 26 | 27 | it('should require json fact property', () => { 28 | const invalidJson = { ...json }; 29 | delete invalidJson.fact; 30 | expect(() => new Condition(invalidJson, defaultEngine)).toThrowError(); 31 | }); 32 | 33 | it('should require json operator property', () => { 34 | const invalidJson = { ...json }; 35 | delete invalidJson.operator; 36 | expect(() => new Condition(invalidJson, defaultEngine)).toThrowError(); 37 | }); 38 | 39 | it('should require engine param', () => { 40 | expect(() => new Condition(json, undefined as any)).toThrowError(); 41 | }); 42 | }); 43 | 44 | describe('toJSON method', () => { 45 | it('should stringify correctly', () => { 46 | const json = { 47 | fact: 'name', 48 | operator: 'equals', 49 | value: 'Luke Skywalker', 50 | }; 51 | const condition = new Condition(json, defaultEngine); 52 | const result = JSON.stringify(condition); 53 | expect(result).toEqual( 54 | '{"fact":"name","operator":"equals","value":"Luke Skywalker"}' 55 | ); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/condition.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'ts-dot-prop'; 2 | 3 | import { Engine } from './engine'; 4 | import { ConditionJson } from './interfaces'; 5 | import { Operator } from './operator'; 6 | 7 | /** 8 | * Condition. 9 | */ 10 | export class Condition { 11 | constructor(config: ConditionJson, engine: Engine) { 12 | this.init(config, engine); 13 | } 14 | 15 | /** 16 | * Condition fact. 17 | */ 18 | private fact: string; 19 | 20 | /** 21 | * Condition operator. 22 | */ 23 | private operator: Operator; 24 | 25 | /** 26 | * Condition value. 27 | */ 28 | private value: any; 29 | 30 | /** 31 | * Engine instance. 32 | */ 33 | private engine: Engine; 34 | 35 | /** 36 | * Evaluate a rule's conditions against the provided data. 37 | * 38 | * @param data Data object to use. 39 | */ 40 | evaluate(data: Record) { 41 | const factValue = get(data, this.fact); 42 | return this.operator.evaluate(factValue, this.value); 43 | } 44 | 45 | /** 46 | * To json. 47 | */ 48 | toJSON() { 49 | return { 50 | fact: this.fact, 51 | operator: this.operator.name, 52 | value: this.value, 53 | }; 54 | } 55 | 56 | /** 57 | * Init condition from configuration object. 58 | * 59 | * @param json Json object. 60 | * @param engine Engine instance to use. 61 | */ 62 | private init(json: ConditionJson, engine: Engine) { 63 | if (!engine) { 64 | throw new Error('Condition: constructor requires engine instance'); 65 | } 66 | 67 | this.engine = engine; 68 | 69 | if (!json) { 70 | throw new Error('Condition: constructor requires object'); 71 | } 72 | 73 | if (!json.fact) { 74 | throw new Error('Condition: "fact" property is required'); 75 | } 76 | 77 | if (!json.operator) { 78 | throw new Error('Condition: "operator" property is required'); 79 | } 80 | 81 | this.fact = json.fact; 82 | this.operator = this.engine.getOperator(json.operator); 83 | this.value = json.value; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/default-engine.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from './engine'; 2 | 3 | /** 4 | * Default engine instance. 5 | */ 6 | export const defaultEngine = new Engine(); 7 | -------------------------------------------------------------------------------- /src/default-operators.ts: -------------------------------------------------------------------------------- 1 | import { Operator } from './operator'; 2 | 3 | /** 4 | * Default operators. 5 | */ 6 | export const defaultOperators = [ 7 | new Operator('equals', (a, b) => a === b), 8 | new Operator('notEquals', (a, b) => a !== b), 9 | new Operator('in', (a, b) => a.indexOf(b) > -1), 10 | new Operator('notIn', (a, b) => a.indexOf(b) === -1), 11 | new Operator('contains', (a, b) => a.indexOf(b) > -1), 12 | new Operator('notContains', (a, b) => a.indexOf(b) === -1), 13 | new Operator('lessThan', (a, b) => a < b), 14 | new Operator('lessThanOrEquals', (a, b) => a <= b), 15 | new Operator('greaterThan', (a, b) => a > b), 16 | new Operator('greaterThanOrEquals', (a, b) => a >= b), 17 | ]; 18 | -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | import { defaultOperators } from './default-operators'; 2 | import { Operator } from './operator'; 3 | 4 | /** 5 | * Engine. 6 | */ 7 | export class Engine { 8 | constructor() { 9 | this.operators = [...defaultOperators]; 10 | } 11 | 12 | /** 13 | * Registered operators. 14 | */ 15 | private operators: Operator[] = []; 16 | 17 | /** 18 | * Get operator by name. 19 | * 20 | * @param name Operator name. 21 | */ 22 | getOperator(name: string) { 23 | const operator = this.operators.find((item) => item.name === name); 24 | 25 | if (!operator) { 26 | throw new Error(`Engine: operator "${name}" not found`); 27 | } 28 | 29 | return operator; 30 | } 31 | 32 | /** 33 | * Add an operator. 34 | * 35 | * @param operator Operator to add. 36 | */ 37 | addOperator(operator: Operator) { 38 | const exists = this.operators.some((item) => item.name === operator.name); 39 | 40 | if (exists) { 41 | throw new Error(`Engine: operator "${operator.name}" already exists`); 42 | } 43 | 44 | this.operators.push(operator); 45 | } 46 | 47 | /** 48 | * Remove an operator by name. 49 | * 50 | * @param name Operator name. 51 | */ 52 | removeOperator(name: string) { 53 | const index = this.operators.findIndex((item) => item.name === name); 54 | 55 | if (index !== -1) { 56 | this.operators.splice(index, 1); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/engne.spec.ts: -------------------------------------------------------------------------------- 1 | import { defaultOperators } from './default-operators'; 2 | import { Engine } from './engine'; 3 | import { Operator } from './operator'; 4 | 5 | describe('Engine class', () => { 6 | describe('addOperator method', () => { 7 | it('should add operator', () => { 8 | const engine = new Engine(); 9 | const operator = new Operator('noop', () => true); 10 | engine.addOperator(operator); 11 | expect((engine as any).operators.length).toEqual( 12 | defaultOperators.length + 1 13 | ); 14 | }); 15 | 16 | it('should throw error if operator already exists', () => { 17 | const engine = new Engine(); 18 | const operator = new Operator('equals', () => true); 19 | expect(() => engine.addOperator(operator)).toThrowError(); 20 | }); 21 | }); 22 | 23 | describe('removeOperator method', () => { 24 | it('should remove operator', () => { 25 | const engine = new Engine(); 26 | engine.removeOperator('notEquals'); 27 | expect((engine as any).operators.length).toEqual( 28 | defaultOperators.length - 1 29 | ); 30 | }); 31 | 32 | it('should no nothing if operator does not exists', () => { 33 | const engine = new Engine(); 34 | engine.removeOperator('404'); 35 | expect((engine as any).operators.length).toEqual(defaultOperators.length); 36 | }); 37 | }); 38 | 39 | describe('getOperator method', () => { 40 | it('should return operator', () => { 41 | const engine = new Engine(); 42 | const operator = engine.getOperator('equals'); 43 | expect(operator).toBeDefined(); 44 | }); 45 | 46 | it('should throw error if operator does not exists', () => { 47 | const engine = new Engine(); 48 | expect(() => engine.getOperator('404')).toThrowError(); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './condition'; 2 | export * from './default-engine'; 3 | export * from './engine'; 4 | export * from './interfaces'; 5 | export * from './operator'; 6 | export * from './rule'; 7 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule type. 3 | */ 4 | export type RuleType = 'and' | 'or'; 5 | 6 | /** 7 | * Operator callback function. 8 | */ 9 | export type OperatorFn = (a: any, b: any) => boolean; 10 | 11 | /** 12 | * Rule json configuration. 13 | */ 14 | export interface RuleJson extends Object { 15 | and?: Array; 16 | or?: Array; 17 | } 18 | 19 | /** 20 | * Condition json configuration. 21 | */ 22 | export interface ConditionJson { 23 | fact: string; 24 | operator: string; 25 | value: any; 26 | } 27 | -------------------------------------------------------------------------------- /src/operator.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFn } from './interfaces'; 2 | 3 | /** 4 | * Operator. 5 | */ 6 | export class Operator { 7 | constructor(name: string, fn: OperatorFn) { 8 | this.name = name; 9 | this.fn = fn; 10 | } 11 | 12 | /** 13 | * Operator name. 14 | */ 15 | name: string; 16 | 17 | /** 18 | * Operator callback function. 19 | */ 20 | fn: OperatorFn; 21 | 22 | /** 23 | * Use the provided callback function to evaluate to values. 24 | * 25 | * @param a Left side value. 26 | * @param b Right side value. 27 | */ 28 | evaluate(a: any, b: any) { 29 | return this.fn(a, b); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/rule.spec.ts: -------------------------------------------------------------------------------- 1 | import { person } from '../test/mock-data'; 2 | import { Condition } from './condition'; 3 | import { RuleJson } from './interfaces'; 4 | import { Rule } from './rule'; 5 | 6 | describe('Rule class', () => { 7 | describe('constructor', () => { 8 | it('should hydrate correctly', () => { 9 | const json: RuleJson = { 10 | and: [ 11 | { 12 | fact: 'name', 13 | operator: 'equals', 14 | value: 'Luke Skywalker', 15 | }, 16 | { 17 | or: [ 18 | { 19 | fact: 'height', 20 | operator: 'lessThan', 21 | value: 200, 22 | }, 23 | { 24 | fact: 'height', 25 | operator: 'greaterThan', 26 | value: 100, 27 | }, 28 | ], 29 | }, 30 | ], 31 | }; 32 | const rule = new Rule(json) as any; 33 | expect(rule.type).toEqual('and'); 34 | expect(rule.items.length).toEqual(2); 35 | expect(rule.items[0] instanceof Condition).toEqual(true); 36 | expect(rule.items[1] instanceof Rule).toEqual(true); 37 | expect(rule.items[1].type).toEqual('or'); 38 | expect(rule.items[1].items.length).toEqual(2); 39 | expect(rule.items[1].items[0] instanceof Condition).toEqual(true); 40 | expect(rule.items[1].items[1] instanceof Condition).toEqual(true); 41 | }); 42 | }); 43 | 44 | describe('equals method', () => { 45 | it('should evaluate true when value equals fact', () => { 46 | const rule = new Rule().equals('name', 'Luke Skywalker'); 47 | expect(rule.evaluate(person)).toEqual(true); 48 | }); 49 | }); 50 | 51 | describe('notEquals method', () => { 52 | it('should evaluate false when value equals fact', () => { 53 | const rule = new Rule().notEquals('name', 'Luke Skywalker'); 54 | expect(rule.evaluate(person)).toEqual(false); 55 | }); 56 | }); 57 | 58 | describe('in method', () => { 59 | it('should evaluate true when value is in fact', () => { 60 | const rule = new Rule().in('name', 'Skywalker'); 61 | expect(rule.evaluate(person)).toEqual(true); 62 | }); 63 | }); 64 | 65 | describe('notIn method', () => { 66 | it('should evaluate false when value is in fact', () => { 67 | const rule = new Rule().notIn('name', 'Skywalker'); 68 | expect(rule.evaluate(person)).toEqual(false); 69 | }); 70 | }); 71 | 72 | describe('contains method', () => { 73 | it('should evaluate true when value contains fact', () => { 74 | const rule = new Rule().contains('vehicles', 'Snowspeeder'); 75 | expect(rule.evaluate(person)).toEqual(true); 76 | }); 77 | }); 78 | 79 | describe('notContains method', () => { 80 | it('should evaluate false when value contains fact', () => { 81 | const rule = new Rule().notContains('vehicles', 'Snowspeeder'); 82 | expect(rule.evaluate(person)).toEqual(false); 83 | }); 84 | }); 85 | 86 | describe('lessThan method', () => { 87 | it('should evaluate true when value is less than fact', () => { 88 | const rule = new Rule().lessThan('height', 200); 89 | expect(rule.evaluate(person)).toEqual(true); 90 | }); 91 | }); 92 | 93 | describe('lessThanOrEquals method', () => { 94 | it('should evaluate true when value is less than fact', () => { 95 | const rule = new Rule().lessThanOrEquals('height', 200); 96 | expect(rule.evaluate(person)).toEqual(true); 97 | }); 98 | 99 | it('should evaluate true when value equals fact', () => { 100 | const rule = new Rule().lessThanOrEquals('height', 200); 101 | expect(rule.evaluate(person)).toEqual(true); 102 | }); 103 | }); 104 | 105 | describe('greaterThan method', () => { 106 | it('should evaluate true when value is greater than fact', () => { 107 | const rule = new Rule().greaterThan('height', 100); 108 | expect(rule.evaluate(person)).toEqual(true); 109 | }); 110 | }); 111 | 112 | describe('greaterThanOrEquals method', () => { 113 | it('should evaluate true when value is greater than fact', () => { 114 | const rule = new Rule().greaterThanOrEquals('height', 100); 115 | expect(rule.evaluate(person)).toEqual(true); 116 | }); 117 | 118 | it('should evaluate true when value equals fact', () => { 119 | const rule = new Rule().greaterThanOrEquals('height', 172); 120 | expect(rule.evaluate(person)).toEqual(true); 121 | }); 122 | }); 123 | 124 | describe('and method', () => { 125 | it('should evaluate true when all conditions are true', () => { 126 | const rule = new Rule().and((item) => { 127 | item.equals('name', 'Luke Skywalker').equals('eyeColor', 'blue'); 128 | }); 129 | 130 | expect(rule.evaluate(person)).toEqual(true); 131 | }); 132 | 133 | it('should evaluate false when any conditions are false', () => { 134 | const rule = new Rule().and((item) => { 135 | item.equals('name', 'Luke Skywalker').equals('eyeColor', 'green'); 136 | }); 137 | 138 | expect(rule.evaluate(person)).toEqual(false); 139 | }); 140 | }); 141 | 142 | describe('or method', () => { 143 | it('should evaluate true when any condition is true', () => { 144 | const rule = new Rule().or((item) => { 145 | item.equals('name', 'Luke Skywalker').equals('eyeColor', 'green'); 146 | }); 147 | 148 | expect(rule.evaluate(person)).toEqual(true); 149 | }); 150 | 151 | it('should evaluate false when no conditions are true', () => { 152 | const rule = new Rule().or((item) => { 153 | item.equals('name', 'Han Solo').equals('eyeColor', 'green'); 154 | }); 155 | 156 | expect(rule.evaluate(person)).toEqual(false); 157 | }); 158 | }); 159 | 160 | it('complex rules should evaluate correctly', () => { 161 | const rule = new Rule().equals('homeWorld.name', 'Tatooine').or((sub) => { 162 | sub.contains('name', 'Skywalker').equals('eyeColor', 'green'); 163 | }); 164 | 165 | expect(rule.evaluate(person)).toEqual(true); 166 | }); 167 | 168 | describe('toJSON method', () => { 169 | it('should stringify correctly', () => { 170 | const rule = new Rule().equals('name', 'Luke Skywalker'); 171 | const result = JSON.stringify(rule); 172 | 173 | expect(result).toEqual( 174 | '{"and":[{"fact":"name","operator":"equals","value":"Luke Skywalker"}]}' 175 | ); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/rule.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from './condition'; 2 | import { defaultEngine } from './default-engine'; 3 | import { Engine } from './engine'; 4 | import { ConditionJson, RuleJson, RuleType } from './interfaces'; 5 | 6 | /** 7 | * Rule. 8 | */ 9 | export class Rule { 10 | constructor(json?: RuleJson, engine?: Engine) { 11 | if (engine) { 12 | this.engine = engine; 13 | } 14 | 15 | if (json) { 16 | this.init(json); 17 | } 18 | } 19 | 20 | /** 21 | * Rule type. 22 | */ 23 | private type: RuleType = 'and'; 24 | 25 | /** 26 | * Rule items. 27 | */ 28 | private items: Array = []; 29 | 30 | /** 31 | * Engine instance. 32 | */ 33 | private engine = defaultEngine; 34 | 35 | /** 36 | * Add a condition with an equals operator. 37 | * 38 | * @param fact Property name or dot notation path. 39 | * @param value Value to compare. 40 | */ 41 | equals(fact: string, value: any) { 42 | return this.add(fact, 'equals', value); 43 | } 44 | 45 | /** 46 | * Add a condition with an notEquals operator. 47 | * 48 | * @param fact Property name or dot notation path. 49 | * @param value Value to compare. 50 | */ 51 | notEquals(fact: string, value: any) { 52 | return this.add(fact, 'notEquals', value); 53 | } 54 | 55 | /** 56 | * Add a condition with an in operator. 57 | * 58 | * @param fact Property name or dot notation path. 59 | * @param value Value to compare. 60 | */ 61 | in(fact: string, value: string) { 62 | return this.add(fact, 'in', value); 63 | } 64 | 65 | /** 66 | * Add a condition with an notIn operator. 67 | * 68 | * @param fact Property name or dot notation path. 69 | * @param value Value to compare. 70 | */ 71 | notIn(fact: string, value: string) { 72 | return this.add(fact, 'notIn', value); 73 | } 74 | 75 | /** 76 | * Add a condition with an contains operator. 77 | * 78 | * @param fact Property name or dot notation path. 79 | * @param value Value to compare. 80 | */ 81 | contains(fact: string, value: any) { 82 | return this.add(fact, 'contains', value); 83 | } 84 | 85 | /** 86 | * Add a condition with an notContains operator. 87 | * 88 | * @param fact Property name or dot notation path. 89 | * @param value Value to compare. 90 | */ 91 | notContains(fact: string, value: any) { 92 | return this.add(fact, 'notContains', value); 93 | } 94 | 95 | /** 96 | * Add a condition with an lessThan operator. 97 | * 98 | * @param fact Property name or dot notation path. 99 | * @param value Value to compare. 100 | */ 101 | lessThan(fact: string, value: number) { 102 | return this.add(fact, 'lessThan', value); 103 | } 104 | 105 | /** 106 | * Add a condition with an lessThanOrEquals operator. 107 | * 108 | * @param fact Property name or dot notation path. 109 | * @param value Value to compare. 110 | */ 111 | lessThanOrEquals(fact: string, value: number) { 112 | return this.add(fact, 'lessThanOrEquals', value); 113 | } 114 | 115 | /** 116 | * Add a condition with an greaterThan operator. 117 | * 118 | * @param fact Property name or dot notation path. 119 | * @param value Value to compare. 120 | */ 121 | greaterThan(fact: string, value: number) { 122 | return this.add(fact, 'greaterThan', value); 123 | } 124 | 125 | /** 126 | * Add a condition with an greaterThanOrEquals operator. 127 | * 128 | * @param fact Property name or dot notation path. 129 | * @param value Value to compare. 130 | */ 131 | greaterThanOrEquals(fact: string, value: number) { 132 | return this.add(fact, 'greaterThanOrEquals', value); 133 | } 134 | 135 | /** 136 | * Add a condition. 137 | * 138 | * @param fact Property name or dot notation path. 139 | * @param operator Name of operator to use. 140 | * @param value Value to compare. 141 | */ 142 | add(fact: string, operator: string, value: any) { 143 | this.items.push(new Condition({ fact, operator, value }, this.engine)); 144 | return this; 145 | } 146 | 147 | /** 148 | * Add a nested AND rule. 149 | * 150 | * @param fn Callback function. 151 | */ 152 | and(fn: (rule: Rule) => void) { 153 | const rule = new Rule(null, this.engine); 154 | rule.type = 'and'; 155 | 156 | fn.call(null, rule); 157 | this.items.push(rule); 158 | 159 | return this; 160 | } 161 | 162 | /** 163 | * Add a nested OR rule. 164 | * 165 | * @param fn Callback function. 166 | */ 167 | or(fn: (rule: Rule) => void) { 168 | const rule = new Rule(null, this.engine); 169 | rule.type = 'or'; 170 | 171 | fn.call(null, rule); 172 | this.items.push(rule); 173 | 174 | return this; 175 | } 176 | 177 | /** 178 | * Evaluate a rule's conditions against the provided data. 179 | * 180 | * @param data Data object to use. 181 | */ 182 | evaluate(data: Record) { 183 | for (const item of this.items) { 184 | const result = item.evaluate(data); 185 | 186 | if (this.type === 'and' && !result) { 187 | return false; 188 | } else if (this.type === 'or' && result) { 189 | return true; 190 | } 191 | } 192 | 193 | if (this.type === 'and') { 194 | return true; 195 | } else { 196 | return false; 197 | } 198 | } 199 | 200 | /** 201 | * To json. 202 | */ 203 | toJSON() { 204 | return { 205 | [this.type]: this.items, 206 | }; 207 | } 208 | 209 | /** 210 | * Init rule from json object. 211 | * 212 | * @param json Json object. 213 | */ 214 | private init(json?: RuleJson) { 215 | const hasOr = Object.prototype.hasOwnProperty.call(json, 'or'); 216 | const hasAnd = Object.prototype.hasOwnProperty.call(json, 'and'); 217 | 218 | if (hasOr && hasAnd) { 219 | throw new Error('Rule: can only have on property ("and" / "or")'); 220 | } 221 | 222 | const items = json.or || json.and || []; 223 | 224 | this.type = hasOr ? 'or' : 'and'; 225 | this.items = items.map((item) => { 226 | if (this.isRule(item)) { 227 | return new Rule(item, this.engine); 228 | } else { 229 | return new Condition(item, this.engine); 230 | } 231 | }); 232 | } 233 | 234 | /** 235 | * Identifies if a json object is a rule or a condition. 236 | * 237 | * @param json Object to check. 238 | */ 239 | private isRule(json: RuleJson | ConditionJson): json is RuleJson { 240 | return ( 241 | Object.prototype.hasOwnProperty.call(json, 'and') || 242 | Object.prototype.hasOwnProperty.call(json, 'or') 243 | ); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /test/mock-data.ts: -------------------------------------------------------------------------------- 1 | export const person = { 2 | eyeColor: 'blue', 3 | gender: 'male', 4 | hairColor: 'blond', 5 | height: 172, 6 | homeWorld: { 7 | climate: 'arid', 8 | diameter: 10465, 9 | name: 'Tatooine', 10 | population: 200000, 11 | }, 12 | mass: 77, 13 | name: 'Luke Skywalker', 14 | skinColor: 'fair', 15 | vehicles: ['Snowspeeder', 'Imperial Speeder Bike'], 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": ["es6", "dom"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "outDir": "dist", 9 | "sourceMap": true, 10 | "target": "es5" 11 | }, 12 | "include": ["src/**/*.ts"], 13 | "exclude": ["src/**/*.spec.ts"] 14 | } 15 | --------------------------------------------------------------------------------