Skip to content

elite-libs/rules-machine

Repository files navigation

Rules Machine

CI Status NPM version GitHub stars

Rules Machine

Rules Against The Machine 🀘

Table of Content

What's a Rules Machine?

It's a fast, general purpose JSON Rules Engine library for both the Browser & Node.js! πŸš€

Goals

  • Share business logic - move logic around the I/O layer, just like data.
    • Shared validation logic (same logic from the web form to the backend)
    • Push rules where they are needed: Cloud functions, CloudFlare Workers, Lambda@Edge, etc.)
  • Organize complexity - isolate complex Business Rules from App Logic and state.
    • Name, group and chain rules.
    • Don't repeat yourself: reference common rule(s) by name. (applySalesTax)
  • Modeling workflows - model your business logic as a series of readable steps.
    • Help non-dev stakeholders (QA, Product) understand critical logic.
    • Simply formatting JSON Rules sheds light on both hierarchy & steps.

Key Terms

App Logic != Business Rules

  • App Logic - applies more broadly and changes less frequently than Business Rules.
    • "Throw Error if ShoppingCart total is less than zero."
    • "Only one discount code can be applied at a time."
  • Business Rules - targeted & detailed, can change frequently.
    • Supports business goals & objectives (as they evolve) from Product, Leadership, Legal, Finance, A/B Tuning, etc.
    • "Premium customers can apply 3 discounts, up to 25% off."
    • "If we're in lock-down, double shipping estimates."
    • "If State is NY, add NY tax."
    • "If State is AZ and during Daylight Savings, offset an hour."

Finding Opportunities for Rules

Typically Business Rules are better-suited to 'rules engine' style pattern.

If your Business Rules or logic changes frequently, you can get alignment benefits by moving that logic to a serializable & sharable format. Specifically, this can provide immediate benefits to mobile & native apps, as you don't have to wait for an approvals process for every change. ✨

Why Rules Engines?

Typically App Logic & Business Rules are woven together throughout the project. This co-location of logic is usually helpful, keeping things readable in small and even mid-sized projects.

This works great, until you run into one of the following challenges:

  1. Storing Rules
    • A note taking app could let users create custom shortcuts, where typing "TODO" could load a template.
    • These "shortcuts" (JSON Rules) can be stored in a local file, synced to a database, or even broadcast over a mesh network.
  2. Unavoidable Complexity
    • In many industries like healthcare, insurance, finance, etc. it's common to find 100's or 1,000s of rules run on every transaction.
    • Over time, "Hand-coded Rules" can distract & obscure from core App Logic.
    • Example: Adding a feature to a DepositTransaction controller shouldn't require careful reading of 2,000 lines of custom rules around currency hackery & country-code checks.
    • Without a strategy, code eventually sprawls as logic gets duplicated & placed arbitrarily. Projects become harder to understand, risky to modify, and adding new rules become high-stakes exercises.
  3. Tracing Errors or Miscalculations
    • Complex pricing, taxes & discount policies can be fully "covered" by unit tests, yet still fail in surprising ways.
    • Determining how a customer's subtotal WAS calculated after the fact can be tedious & time consuming.
Additional Scenarios & Details
  • Example: Sales tax rates and rules are defined by several layers of local government. (Mainly City, County, and State.)
    • Depending on the State rules, you'll need to calculate based on the Billing Address or Shipping Address.
  • Scenario: A California customer has expanded into Canada. Their new shipping destination seems to cause double taxation!?!
    • In this situation, a trace of the computations can save hours of dev work, boost Customer Support' confidence issuing a partial refund, and the data team can use the raw data to understand the scope of the issue.
  • Scenario: "Why did we approve a $10,000,000 loan for 'The Joker'?"
  • Scenario: "How did an Ultra Sports Car ($1M+) qualify for fiscal hardship rates?"

Pros

  • Uses a subset of JavaScript and structured JSON object(s).
  • Easy to start using & experimenting with, larger implementations require more planning.
  • Provides a trace, with details on each step, what happened, and the time taken.

Cons

  • Sizable projects require up-front planning & design work to properly adapt this pattern. (1,000s rules, for example.)
  • Possible early optimization or premature architecture decision.
  • Not as easy to write compared to a native language.

Install

yarn add @elite-libs/rules-machine
# Or
npm install @elite-libs/rules-machine

Usage

import { ruleFactory } from '@elite-libs/rules-machine';

const fishRhyme = ruleFactory([
  { if: 'fish == "oneFish"', then: 'fish = "twoFish"' },
  { if: 'fish == "redFish"', then: 'fish = "blueFish"' },
]);
// Equivalent to:
// if (fish == "oneFish") fish = "twoFish"
// if (fish == "redFish") fish = "blueFish"

fishyRhyme({ fish: 'oneFish' }); // {fish: 'twoFish'}

Examples

Example Rule: Apply Either $5 or $10 Discount

// Using "and" object style operator
[
  {"if": {"and": ["price >= 25", "price <= 50"]}, "then": "discount = 5"},
  {"if": "price > 50", "then": "discount = 10"},
  {"return": "discount"}
]
// Using inline AND operator
[
  {"if": "price >= 25 AND price <= 50", "then": "discount = 5"},
  {"if": "price > 50", "then": "discount = 10"},
  {"return": "discount"}
]
Show YAML
- if: { and: [price >= 25, price <= 50] }
  then: discount = 5
- if: price > 50
  then: discount = 10
- return: discount

Example Rule: Apply $15 Discount if Employee, or Premium Customer

[
  {
    "if": "user.plan == \"premium\"",
    "then": "discount = 15"
  },
  {
    "if": "user.employee == true",
    "then": "discount = 15"
  },
  {
    "return": "discount"
  }
]

Example Rule: Multiple Conditional

[
  {
    "if": "price <= 100",
    "then": "discount = 5"
  },
  {
    "if": {
      "or": ["price >= 100", "user.isAdmin == true"]
    },
    "then": "discount = 20"
  },
  {
    "return": "discount"
  }
]
Show YAML
- if: price <= 100
  then: discount = 5
- if:
    or: [price >= 100, user.isAdmin == true]
  then: discount = 20
- return: discount

Example Rule: Use variable between rules

[
  {
    "if": "price <= 100",
    "then": ["discount = 5", "user.discountApplied = true"]
  },
  {
    "if": {
      "and": ["price >= 90", "user.discountApplied != true"]
    },
    "then": "discount = 20"
  },
  {
    "return": "discount"
  }
]
Show YAML
- if: price <= 100
  then:
    - discount = 5
    - user.discountApplied = true
- if:
    and:
      - price >= 90
      - user.discountApplied != true
  then: discount = 20
- return: discount

Rules API

Array Expressions

map

const doubleList = ruleFactory([
  {
    map: 'list',
    run: '$item * 2',
    set: 'list',
  },
]);
doubleList({ list: [1, 2, 3, 4] });
// [2, 4, 6, 8]

filter

const multiplesOfThree = ruleFactory([
  {
    filter: 'list',
    run: '$item % 3 == 0',
    set: 'results',
  },
  { return: 'results' }
]);
multiplesOfThree({ list: [1, 2, 3, 4] });
// [3]

find

const getFirstMultipleOfThree = ruleFactory([
  {
    find: 'list',
    run: '$item % 3 == 0',
    set: 'results',
  },
  { return: 'results' }
]);
getFirstMultipleOfThree({list: [1, 2, 3, 4]})
// 3
getFirstMultipleOfThree({list: [9, 3, 4]})
// 9
getFirstMultipleOfThree({list: [99]})
// undefined

every

const isEveryNumberMultipleOfThree = ruleFactory([
  {
    every: 'list',
    run: '$item % 3 == 0',
    set: 'results',
  },
  { return: 'results' }
]);
isEveryNumberMultipleOfThree({list: [3, 6, 9]})
// true
isEveryNumberMultipleOfThree({list: [3, 6, 9, 10]})
// false

some

const hasEvenNumbers = ruleFactory([
  {
    some: 'list',
    run: '2 % $item == 0',
    set: 'results',
  },
  { return: 'results' }
]);
hasEvenNumbers({list: [2, 4]})
// true
hasEvenNumbers({list: [2, 4, 5]})
// true
hasEvenNumbers({list: [5]})
// false

if/then

const calculateDiscount = ruleFactory([
  {"if": {"and": ["price >= 25", "price <= 50"]}, "then": "discount = 5"},
  {"if": "price > 50", "then": "discount = 10"},
  {"return": "discount"}
]);
calculateDiscount({price: 40, discount: 0})
// 5
calculateDiscount({price: 60, discount: 0})
// 10

and/or

const isScoreValid = ruleFactory({
  "if": {"and": ["score > 0", "score <= 100"]},
  "then": "valid = true",
  "else": "valid = false",
})
isScoreValid({score: 10})
// { score: 10, valid: true }}
isScoreValid({score: -10})
// { score: 10, valid: false }}
isScoreValid({score: 101})
// { score: 10, valid: false }}

try/catch

Execute string rule from try. Handle errors in the catch expression.

[
  { 
    try: 'THROW "error"',
    catch: 'status = "Failure"',
  },
  { return: 'status' }, // returns "Failure"
]

return

Ends rule execution, returning the specified value.

[
  { return: '"blue"' }, // returns "blue"
  { return: '"green"' }, // is not executed
]

Builtin Operators

  1. !=
  2. = - equality check.
  3. == - equality check.
  4. <
  5. <=
  6. <>
  7. >
  8. >=
  9. % - 10 % 2 => 0 (tip: odd/even check)
  10. * - 42 * 10 => 420
  11. + - 42 + 10 => 52
  12. -
  13. /
  14. ^
  15. ~=
  16. AND - this does not short circuit if the first operand is false, but the object form does.
  17. OR - this does not short circuit if the first operand is true, but the object form does.

Functions

Extended Methods

  1. REMOVE_VALUES(matches, input) - will remove all values matching the item(s) in the 1st argument from the 2nd argument array. (XOR operation.)
  2. FILTER_VALUES(matches, input) - will ONLY INCLUDE values that are in the 1st & 2nd arguments. (Intersection operation.)
  3. CONTAINS(42, [41, 42, 43]) => true

Utility Functions

  1. IF() - IF(7 > 5, 8, 10) => 8
  2. GET() - GET('users[2].name', users) => Mary

Math Functions: Core

  1. AVERAGE() - AVERAGE([10, 20, 30]) => 20
  2. CEIL() - CEIL(0.1) => 1
  3. FLOOR() - FLOOR(1.9) => 1
  4. ROUND() - FLOOR(0.6) => 1
  5. TRUNC() - TRUNC(1.9) => 1
  6. SUM() - SUM([1,2,3]) => 6
  7. ADD() - ADD(2, 3) => 5
  8. SUB() - SUB(2, 3) => -1
  9. DIV() - DIV(9, 3) => 3
  10. MUL() - MUL(3, 3) => 9
  11. NEG() - NEG(ADD(1, 2)) => -3
  12. NOT() - NOT(ISPRIME(7)) => false
  13. ISNAN() - ISNAN('hai') => true
  14. ISPRIME() - ISPRIME(7) => true
  15. MOD() - MOD(10, 2) => 0
  16. GCD() - GCD(9, 3) => 3

Array Functions

  1. SLICE() - SLICE(1, 3, [1, 42, 69, 54]) => [42, 69]
  2. LENGTH() - LENGTH([42, 69, 54]) => 3
  3. SORT() - SORT([2,2,1]) => [1, 2, 2]
  4. FILTER() - FILTER(isEven, [1,2,3,4,5,6]) => [2, 4, 6]
  5. INDEX() - INDEX([42, 69, 54], 0) => 42
  6. MAP() - MAP("NOT", [FALSE, TRUE, FALSE]) => [true, false, true]
  7. MIN() - MIN([42, 69, 54]) => 42
  8. MAX() - MAX([42, 69, 54]) => 69
  9. HEAD() - HEAD([42, 69, 54]) => 42
  10. LAST() - LAST([42, 69, 54]) => 54
  11. TAIL() - TAIL([42, 69, 54]) => [69, 54]
  12. TAKE() - TAKE(2, [42, 69, 54]) => [42, 69]
  13. TAKEWHILE() - TAKEWHILE(isEven, [0,2,4,5,6,7,8]) => [0, 2, 4]
  14. DROP() - DROP(2, [1, 42, 69, 54]) => [69, 54]
  15. DROPWHILE() - DROPWHILE(isEven, [0,2,4,5,6,7,8]) => [5,6,7,8]
  16. REDUCE() - REDUCE("ADD", 0, [1, 2, 3]) => 6
  17. REVERSE() - REVERSE([1,2,2]) => [2, 2, 1]
  18. CHARARRAY() - CHARARRAY("abc") => ['a', 'b', 'c']
  19. CONCAT() - CONCAT([42, 69], [54]) => [42, 69, 54]
  20. CONS() - CONS(2, [3, 4]) => [2, 3, 4]
  21. JOIN() - JOIN(",", ["a", "b"]) => a,b
  22. RANGE() - RANGE(0, 5) => [0, 1, 2, 3, 4]
  23. UNZIPDICT() - UNZIPDICT([["a", 1], ["b", 5]]) => {a: 1, b: 5}
  24. ZIP() - ZIP([1, 3], [2, 4]) => [[1, 2], [3, 4]]

Object Functions

  1. DICT() - DICT(["a", "b"], [1, 4]) => {a: 1, b: 4}
  2. KEYS() - KEYS(DICT(["a", "b"], [1, 4])) => ['a', 'b']
  3. VALUES() - VALUES(DICT(["a", "b"], [1, 4])) => [1, 4]
  4. UNZIP() - UNZIP([[1, 2], [3, 4]]) => [[1, 3], [2, 4]]
  5. CONTAINS() - CONTAINS("x", {x: 1}) => true
  6. COUNT_KEYS() - COUNT_KEYS({x: 1}) => 1
  7. OMIT() - OMIT("x", {x: 1}) => {}

String Functions

  1. LOWER() - LOWER('HELLO') => hello
  2. UPPER() - UPPER('hello') => HELLO
  3. SPLIT() - SPLIT(',', 'a,b') => ['a', 'b']
  4. CHAR() - CHAR(65) => A
  5. CODE() - CODE('A') => 65
  6. BIN2DEC() - BIN2DEC('101010') => 42
  7. DEC2BIN() - DEC2BIN(42) => 101010
  8. DEC2HEX() - DEC2HEX('42') => 2a
  9. DEC2STR() - DEC2STR('42') => 42
  10. HEX2DEC() - HEX2DEC("F") => 15
  11. STR2DEC() - STR2DEC('42') => 42
  12. STRING_CONTAINS() - STRING_CONTAINS("lo wo", "hello world") => true, note: this function does not currently accept regular expressions
  13. STRING_ENDS_WITH() - STRING_ENDS_WITH("rld", "hello world") => true, note: this function does not currently accept regular expressions
  14. STRING_STARTS_WITH() - STRING_STARTS_WITH("hell", "hello world") => true, note: this function does not currently accept regular expressions

Math Functions: Advanced

  1. SQRT()
  2. CUBEROOT()
  3. SIGN() - SIGN(-42) => -1
  4. ABS() - ABS(-42) => 42
  5. ACOS()
  6. ACOSH()
  7. ASIN()
  8. ASINH()
  9. ATAN()
  10. ATAN2()
  11. ATANH()
  12. COS()
  13. COSH()
  14. DEGREES()
  15. RADIANS()
  16. SIN()
  17. SINH()
  18. TAN()
  19. TANH()
  20. EXP()
  21. LN()
  22. LOG()
  23. LOG2()

Utility

  1. THROW() - Will throw an error. Expects a string. Cannot be (ab)used for flow control yet. THROW("my error") => PARSER FAIL: Error: my error

More Reading & Related Projects

TODO

  • Web app to test & build rules.
  • Design async data injection mechanism
  • Return result by default, make trace and metadata opt-in via options.
  • Add arithmetic & function support to expression parser.
    • Over 80 builtin functions supported.
  • Publish modules for CJS, ESM, AMD, UMD.
  • misc: Structured Type validation.
  • security: NEVER use eval/Function('...') parsing.
  • misc: Simplify TS, making Rule[] the sole recursive type.
  • misc: Use reduced JS syntax, scope.
  • misc: Use single object for input and output. (Doesn't mutate input.)
  • misc: Add support for multiple boolean expressions. (see: {"and": []} {"or": []}).
  • misc: Rules are serializable, and can be shared.
  • rule type: {"try": "rules", "catch": {"return": "error"}}
  • rule type: {"run": Rule[] | Rule | "ruleSetName"}
  • rule type: {"log": "rule/value expression"}
  • rule type: {"set": "newVar = value"}
  • Disallow input keys that can cause weirdness: undefined, valueOf, toString, __proto__, constructor.