Rules Against The Machine π€
Table of Content
- What's a
Rules Machine? - Why Rules Engines?
- Install
- Usage
- Examples
- Rules API
- More Reading & Related Projects
- TODO
It's a fast, general purpose JSON Rules Engine library for both the Browser & Node.js! π
- 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.
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."
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. β¨
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:
- 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.
- 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
DepositTransactioncontroller 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.
- 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?"
- 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.
- 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.
yarn add @elite-libs/rules-machine # Or npm install @elite-libs/rules-machineimport { 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'}// 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[ { "if": "user.plan == \"premium\"", "then": "discount = 15" }, { "if": "user.employee == true", "then": "discount = 15" }, { "return": "discount" } ][ { "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[ { "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: discountconst doubleList = ruleFactory([ { map: 'list', run: '$item * 2', set: 'list', }, ]); doubleList({ list: [1, 2, 3, 4] }); // [2, 4, 6, 8]const multiplesOfThree = ruleFactory([ { filter: 'list', run: '$item % 3 == 0', set: 'results', }, { return: 'results' } ]); multiplesOfThree({ list: [1, 2, 3, 4] }); // [3]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]}) // undefinedconst isEveryNumberMultipleOfThree = ruleFactory([ { every: 'list', run: '$item % 3 == 0', set: 'results', }, { return: 'results' } ]); isEveryNumberMultipleOfThree({list: [3, 6, 9]}) // true isEveryNumberMultipleOfThree({list: [3, 6, 9, 10]}) // falseconst 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]}) // falseconst 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}) // 10const 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 }}Execute string rule from try. Handle errors in the catch expression.
[ { try: 'THROW "error"', catch: 'status = "Failure"', }, { return: 'status' }, // returns "Failure" ]Ends rule execution, returning the specified value.
[ { return: '"blue"' }, // returns "blue" { return: '"green"' }, // is not executed ]!==- equality check.==- equality check.<<=<>>>=%-10 % 2=>0(tip: odd/even check)*-42 * 10=>420+-42 + 10=>52-/^~=AND- this does not short circuit if the first operand is false, but the object form does.OR- this does not short circuit if the first operand is true, but the object form does.
REMOVE_VALUES(matches, input)- will remove all values matching the item(s) in the 1st argument from the 2nd argument array. (XOR operation.)FILTER_VALUES(matches, input)- will ONLY INCLUDE values that are in the 1st & 2nd arguments. (Intersection operation.)CONTAINS(42, [41, 42, 43])=>true
- IF() -
IF(7 > 5, 8, 10)=>8 - GET() -
GET('users[2].name', users)=>Mary
- AVERAGE() -
AVERAGE([10, 20, 30])=>20 - CEIL() -
CEIL(0.1)=>1 - FLOOR() -
FLOOR(1.9)=>1 - ROUND() -
FLOOR(0.6)=>1 - TRUNC() -
TRUNC(1.9)=>1 - SUM() -
SUM([1,2,3])=>6 - ADD() -
ADD(2, 3)=>5 - SUB() -
SUB(2, 3)=>-1 - DIV() -
DIV(9, 3)=>3 - MUL() -
MUL(3, 3)=>9 - NEG() -
NEG(ADD(1, 2))=>-3 - NOT() -
NOT(ISPRIME(7))=>false - ISNAN() -
ISNAN('hai')=>true - ISPRIME() -
ISPRIME(7)=>true - MOD() -
MOD(10, 2)=>0 - GCD() -
GCD(9, 3)=>3
- SLICE() -
SLICE(1, 3, [1, 42, 69, 54])=>[42, 69] - LENGTH() -
LENGTH([42, 69, 54])=>3 - SORT() -
SORT([2,2,1])=>[1, 2, 2] - FILTER() -
FILTER(isEven, [1,2,3,4,5,6])=>[2, 4, 6] - INDEX() -
INDEX([42, 69, 54], 0)=>42 - MAP() -
MAP("NOT", [FALSE, TRUE, FALSE])=>[true, false, true] - MIN() -
MIN([42, 69, 54])=>42 - MAX() -
MAX([42, 69, 54])=>69 - HEAD() -
HEAD([42, 69, 54])=>42 - LAST() -
LAST([42, 69, 54])=>54 - TAIL() -
TAIL([42, 69, 54])=>[69, 54] - TAKE() -
TAKE(2, [42, 69, 54])=>[42, 69] - TAKEWHILE() -
TAKEWHILE(isEven, [0,2,4,5,6,7,8])=>[0, 2, 4] - DROP() -
DROP(2, [1, 42, 69, 54])=>[69, 54] - DROPWHILE() -
DROPWHILE(isEven, [0,2,4,5,6,7,8])=>[5,6,7,8] - REDUCE() -
REDUCE("ADD", 0, [1, 2, 3])=>6 - REVERSE() -
REVERSE([1,2,2])=>[2, 2, 1] - CHARARRAY() -
CHARARRAY("abc")=>['a', 'b', 'c'] - CONCAT() -
CONCAT([42, 69], [54])=>[42, 69, 54] - CONS() -
CONS(2, [3, 4])=>[2, 3, 4] - JOIN() -
JOIN(",", ["a", "b"])=>a,b - RANGE() -
RANGE(0, 5)=>[0, 1, 2, 3, 4] - UNZIPDICT() -
UNZIPDICT([["a", 1], ["b", 5]])=>{a: 1, b: 5} - ZIP() -
ZIP([1, 3], [2, 4])=>[[1, 2], [3, 4]]
- DICT() -
DICT(["a", "b"], [1, 4])=>{a: 1, b: 4} - KEYS() -
KEYS(DICT(["a", "b"], [1, 4]))=>['a', 'b'] - VALUES() -
VALUES(DICT(["a", "b"], [1, 4]))=>[1, 4] - UNZIP() -
UNZIP([[1, 2], [3, 4]])=>[[1, 3], [2, 4]] - CONTAINS() -
CONTAINS("x", {x: 1})=>true - COUNT_KEYS() -
COUNT_KEYS({x: 1})=>1 - OMIT() -
OMIT("x", {x: 1})=>{}
- LOWER() -
LOWER('HELLO')=>hello - UPPER() -
UPPER('hello')=>HELLO - SPLIT() -
SPLIT(',', 'a,b')=>['a', 'b'] - CHAR() -
CHAR(65)=>A - CODE() -
CODE('A')=>65 - BIN2DEC() -
BIN2DEC('101010')=>42 - DEC2BIN() -
DEC2BIN(42)=>101010 - DEC2HEX() -
DEC2HEX('42')=>2a - DEC2STR() -
DEC2STR('42')=>42 - HEX2DEC() -
HEX2DEC("F")=>15 - STR2DEC() -
STR2DEC('42')=>42 - STRING_CONTAINS() -
STRING_CONTAINS("lo wo", "hello world")=>true, note: this function does not currently accept regular expressions - STRING_ENDS_WITH() -
STRING_ENDS_WITH("rld", "hello world")=>true, note: this function does not currently accept regular expressions - STRING_STARTS_WITH() -
STRING_STARTS_WITH("hell", "hello world")=>true, note: this function does not currently accept regular expressions
- SQRT()
- CUBEROOT()
- SIGN() -
SIGN(-42)=>-1 - ABS() -
ABS(-42)=>42 - ACOS()
- ACOSH()
- ASIN()
- ASINH()
- ATAN()
- ATAN2()
- ATANH()
- COS()
- COSH()
- DEGREES()
- RADIANS()
- SIN()
- SINH()
- TAN()
- TANH()
- EXP()
- LN()
- LOG()
- LOG2()
- THROW() - Will throw an error. Expects a string. Cannot be (ab)used for flow control yet.
THROW("my error") => PARSER FAIL: Error: my error
- Should I use a Rules Engine?
- JSON Rules Engine.
- GitHub Actions YAML conditional syntax.
- 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.