├── .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 | [](https://badge.fury.io/js/js-rules-engine)
2 | [](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 |
--------------------------------------------------------------------------------