Skip to content

Latest commit

 

History

History
814 lines (633 loc) · 25.2 KB

README.md

File metadata and controls

814 lines (633 loc) · 25.2 KB

RulePilot

npm version

Statements Functions Lines
Statements Functions Lines

Overview

RulePilot is a fast and lightweight rule engine for JavaScript. It is designed to be simple to use and easy to integrate into your application.

The rule engine evaluates human-readable JSON rules against a set of criteria. The rules are evaluated in a top-down fashion.

Simple rules can be written to evaluate to a boolean value (indicating whether the criteria tested passes the rule).

Otherwise, granular rules can be created, where each condition of the rule can evaluate to a boolean, number, string, object or array. This is particularly useful when you want to evaluate a rule and return a result based on each condition's evaluation.

Features

  • Simple to use
  • Written in TypeScript
  • Human-readable JSON rules
  • Runs in both Node & the browser
  • Lightweight with zero dependencies & Fast (10,000 rule evaluations in ~35-40ms)
  • Fluent rule builder tool (ORM style)
  • Both Simple & Granular rule evaluation
  • Sub-rules & infinitely nested conditions
  • Dynamic criteria mutation
  • Supports Criteria objects with nested properties
  • Rule validation & debugging tools
  • Supports Any, All, and None type conditions
  • Supports Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, In, NotIn, Contains, Not Contains, ContainsAny, Not ContainsAny, Matches and Not Matches operators

Usage

Installation

npm install rulepilot
yarn add rulepilot

Importing

import { RulePilot } from "rulepilot";

For TypeScript users, you can import the Rule interface to get type definitions for the rule JSON.

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  // ...
};

Static vs Instantiated Approach

The RulePilot library can be used in two ways, either as a static class or as an instance.

The static class is great for most cases as long as you do not intend to use different mutations in multiple places in your codebase, (more on what mutations are further down).

Otherwise, you can create an instance of the RulePilot class and use it to evaluate rules.

import { RulePilot } from "rulepilot";

let result;

// Static
result = await RulePilot.evaluate(rule, criteria);

// Instance
const rulePilot = new RulePilot();
result = await rulePilot.evaluate(rule, criteria);

Basic Example

Here we are defining a rule which will evaluate to true or false based on the criteria provided. In this example, we are checking whether a user is allowed to benefit from a discount at checkout of not.

For the discount to be applied, the user must be from either the UK or Finland, have a coupon and the total checkout price must be greater than or equal to 120.00.

import { RulePilot, Rule } from "rulepilot";

// Define a rule which caters for your needs
const rule: Rule = {
  conditions: {
    all: [
      { field: "country", operator: "in", value: ["GB", "FI"] },
      { field: "hasCoupon", operator: "==", value: true },
      { field: "totalCheckoutPrice", operator: ">=", value: 120.0 },
    ],
  },
};

// Define the criteria which will be evaluated against the rule
const criteria = {
  country: "GB",
  totalCheckoutPrice: 125.0,
  hasCoupon: true,
};

/** Evaluate the criteria against the rule */
let result = await RulePilot.evaluate<boolean>(rule, criteria);
// result == true

// However, if any of the criteria do not pass the check, the result will be false
criteria.totalCheckoutPrice = 25.0;

/** Evaluate the new criteria against the rule */
result = await RulePilot.evaluate<boolean>(rule, criteria); // result == false

We can add additional conditions to the rule, for example apart from the above-mentioned conditions, we can also say that if the user is either over 18 years old or has a valid student card then we will evaluate to true.

Take note of how the conditions property is now an array of objects.

import { RulePilot, Rule } from "rulepilot";

// Define a rule which caters for your needs
const rule: Rule = {
  conditions: [
    {
      all: [
        { field: "country", operator: "in", value: ["GB", "FI"] },
        { field: "hasCoupon", operator: "==", value: true },
        { field: "totalCheckoutPrice", operator: ">=", value: 120.0 },
      ],
    },
    {
      any: [
        { field: "age", operator: ">=",  value: 18 },
        { field: "hasStudentCard", operator: "==", value: true },
      ],
    },
  ],
};

// Define the criteria which will be evaluated against the rule
const criteria = {
  country: "GB",
  totalCheckoutPrice: 20.0,
  hasCoupon: true,
};

/** Evaluate the criteria against the rule */
let result = await RulePilot.evaluate<boolean>(rule, criteria); // result == false

criteria.hasStudentCard = true;

/** Evaluate the new criteria against the rule */
result = await RulePilot.evaluate<boolean>(rule, criteria); // result == true

If we want to add additional requirements to the rule, we can do so by adding another any or all condition.

For example, we can add a requirement that a discount will also be given to all users from Sweden as long as they are 18+ or have a valid student card (irrelevant of any other conditions set).

const rule: Rule = {
  conditions: [
    {
      any: [
        {
          all: [
            { field: "country", operator: "in", value: ["GB", "FI"] },
            { field: "hasCoupon", operator: "==", value: true },
            { field: "totalCheckoutPrice", operator: ">=", value: 120.0 },
          ],
        },
        { field: "country",  operator: "==", value: "SE" },
      ],
    },
    {
      any: [
        { field: "age", operator: ">=",  value: 18 },
        { field: "hasStudentCard", operator: "==", value: true },
      ],
    },
  ],
};

The criteria can be narrowed down further by specifying Swedish users cannot be from Stockholm or Gothenburg otherwise they must spend more than 200.00 at checkout.

const rule: Rule = {
  conditions: [
    {
      any: [
        {
          all: [
            { field: "country", operator: "in", value: ["GB", "FI"] },
            { field: "hasCoupon", operator: "==", value: true },
            { field: "totalCheckoutPrice", operator: ">=", value: 120.0 },
          ],
        },
        {
          any: [
            {
              all: [
                { field: "country", operator: "==", value: "SE" },
                { field: "city", operator: "not in", value: ["Stockholm", "Gothenburg"] },
              ],
            },
            {
              all: [
                { field: "country",  operator: "==", value: "SE" },
                { field: "city", operator: "totalCheckoutPrice",  value: 200 },
              ],
            },
          ],
        },
      ],
    },
    {
      any: [
        { field: "age", operator: ">=",  value: 18 },
        { field: "hasStudentCard", operator: "==", value: true },
      ],
    },
  ],
};

Granular Example

It might be the case that we want to give different discounts to people based on the criteria they meet. For example, we want to give a 10% discount to all users who 18+ or have a student card and a 5% discount to the rest of the users who meet the other criteria.

To accomplish this, we can assign a result to each condition which will be used to calculate the discount.

const rule: Rule = {
  conditions: [
    {
      any: [
        {
          all: [
            { field: "country", operator: "in", value: ["GB", "FI"] },
            { field: "hasCoupon", operator: "==", value: true },
            { field: "totalCheckoutPrice", operator: ">=", value: 120.0 },
          ],
        },
        {
          any: [
            {
              all: [
                { field: "country", operator: "==", value: "SE" },
                { field: "city", operator: "not in", value: ["Stockholm", "Gothenburg"] },
              ],
            },
            {
              all: [
                { field: "country",  operator: "==", value: "SE" },
                { field: "city", operator: "totalCheckoutPrice",  value: 200 },
              ],
            },
          ],
        },
      ],
      result: 5,
    },
    {
      any: [
        { field: "age", operator: ">=",  value: 18 },
        { field: "hasStudentCard", operator: "==", value: true },
      ],
      result: 10,
    },
  ],
};

In such a setup the result of our evaluation will be the value of the result property in condition which was met first.

import { RulePilot } from "rulepilot";

// Define the criteria which will be evaluated against the rule
const criteria = {
  country: "GB",
  totalCheckoutPrice: 340.22,
  hasCoupon: true,
};

/** Evaluate the criteria against the rule */
let result = await RulePilot.evaluate<number>(rule, criteria); // result = 5

criteria.country = "SE";
criteria.city = "Linköping";

/** Evaluate the new criteria against the rule */
result = await RulePilot.evaluate<number>(rule, criteria); // result = 10

criteria.country = "IT";
criteria.age = 17;
criteria.hasStudentCard = false;

/** Evaluate the new criteria against the rule */
result = await RulePilot.evaluate<number>(rule, criteria); // result = false

Important When using granular rules, the order of conditions in the rule matters!

The first condition in the rule which is met will be the one which is used to calculate the discount.

Defaulting A Rule Result

In granular rules, it is possible to set a default value which will be used if no conditions are met.

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  conditions: [
    {
      // ..
    },
  ],
  default: 2.5,
};


/** Evaluate the criteria against the rule */
let result = await RulePilot.evaluate<number>(rule, {}); // result = 2.5

In such a setup as seen above, if no conditions are met, the result will be 2.5.

Condition Types

There are three (3) types of conditions which can be used in a rule:

  • all - All criteria in the condition must be met
  • any - Any criteria in the condition must be met
  • none - No criteria in the conditions must be met (none === !all)

Condition types can be mixed and matched or nested to create complex rules.

Operators

These are the operators available for a constraint and how they are used:

  • ==: Applies JavaScript equality (==) operator to criterion and constraint value
  • !=: Applies JavaScript inequality (!=) operator to criterion and constraint value
  • >: Applies JavaScript greater than (>) operator to criterion and constraint value
  • <: Applies JavaScript less than (<) operator to criterion and constraint value
  • >=: Applies JavaScript greater than or equal (>=) operator to criterion and constraint value
  • <=: Applies JavaScript less than or equal (<=) operator to criterion and constraint value
  • in: Tests if the criterion is an element of the constraint value (value must be an array)
  • not in: Tests if the criterion is not an element of the constraint value (value must be an array)
  • contains: Tests if the constraint value is an element of the criterion (criterion must be an array)
  • contains any: Tests if any element in the constraint value is an element of the criterion (criterion and constraint value must be an array)
  • not contains: Tests if the constraint value is not an element of the criterion (criterion must be an array)
  • not contains any: Tests if any element in the constraint value is bot an element of the criterion (criterion and constraint value must be an array)
  • matches: Tests if the constraint value matches a regular expression (criterion must be a valid regex)
  • not matches: Tests if the constraint value does not match a regular expression (criterion must be a valid regex)

Criteria With Nested Properties

In some cases, the criteria which is used to evaluate a rule might be more complex objects with nested properties.

For example, we might want to evaluate a rule against a User object which has a profile property which contains the user's profile information.

To do so, we can use the . (dot) notation to access nested properties in the criteria.

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  conditions: {
    all: [{ field: "profile.age",  operator: ">=", value: 18 }],
  },
};

const criteria = {
  profile: { age: 20 },
};

/** Evaluate the criteria against the rule */
let result = await RulePilot.evaluate(rule, criteria); // result = true

Evaluating Multiple Criteria At Once

Multiple criteria can be evaluated against a rule at once by passing an array of criteria to the evaluate() method.

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  conditions: {
    all: [{ field: "profile.age",  operator: ">=", value: 18 }],
  },
};

const criteria = [
  { profile: { age: 20 } },
  { profile: { age: 17 } },
];

/** Evaluate the criteria against the rule */
let result = await RulePilot.evaluate(rule, criteria); // result = [true, false]

Sub Rules

Sub-rules can be used to create early exit points in a rule by assigning a result property to a condition.

This value of this result will be returned when the rule is evaluated if all the constraints from the root of the rule to this result are met.

Note: Sub-rules do not need to evaluate to true for their parent condition to pass evaluation.

Sub-rules provide a convenient way to create complex rules with early exit points and avoid repeating the same constraints in multiple places.

An example of a sub-rule can be seen below:

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  conditions: {
    any: [
      { field: "profile.age", operator: ">=",  value: 18 },
      { all: [{ field: "foo", operator: "==", value: 'A' }], result: 10 },
      { all: [{ field: "foo", operator: "==", value: 'B' }], result: 20 }
    ],
    result: 5
  }
};

let criteria = { profile: { age: 20 } }
let result = await RulePilot.evaluate(rule, criteria); // result = 5

criteria = { profile: { age: 20 }, foo: 'A' };
result = await RulePilot.evaluate(rule, criteria); // result = 10

criteria = { profile: { age: 20 }, foo: 'B' };
result = await RulePilot.evaluate(rule, criteria); // result = 20

criteria = { profile: { age: 20 }, foo: 'C' };
result = await RulePilot.evaluate(rule, criteria); // result = 5

criteria = { profile: { age: 10 }, foo: 'A' };
result = await RulePilot.evaluate(rule, criteria); // result = false

Validating A Rule

Validation can be performed on a rule to ensure it is valid and properly structured.

The validate() method will return true if the rule is valid, otherwise it will return an error message describing the problem along with the problem node from the rule for easy debugging.

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  // ...
};

const result = RulePilot.validate(rule);

For TypeScript users, the ValidationResult interface can be imported.

import { RulePilot, Rule, ValidationResult } from "rulepilot";

const rule: Rule = {
  // ...
};

const validationResult: ValidationResult = RulePilot.validate(rule);

Criteria Mutations

Mutations are a powerful tool built straight into RulePilot which allow for the modification of criteria before evaluation.

Mutations can be basic functions which modify the criteria in some way, or they can be more complex functions which make API calls or perform other operations.

The mutation logic built into RulePilot is designed to be as efficient as possible, avoiding multiple repeat evaluations by caching the results of previous evaluations. The mutator logic also identifies all unique mutations required at runtime and executes them all in parallel before passing the criteria to the rule engine for evaluation.

Let's take a look at a simple example use case for mutations, where we use an API call to fetch account information into the criteria before evaluation.

import { RulePilot, Rule } from "rulepilot";

// Example client method to fetch account information
import { fetchAccount } from './api'

const rule: Rule = {
  conditions: [{
    all: [
      { field: "account.balance", operator: ">=", value: 100.00 },
      { field: "account.age", operator: ">=", value: 18 }
    ],
  }],
};

const criteria = [{ account: 123 }, { account: 123 }, { account: 124 }];

// Instantiate a new RulePilot instance (recommended)
const rulePilot = new RulePilot();

// Add a mutation to fetch account information
// This mutation will be called once for each unique accountId in the criteria
rulePilot.addMutation("account", async (accountId, criteria) => {
  return await fetchAccount(accountId);
});

// Evaluate the rule
const result = await rulePilot.evaluate(rule, criteria);

In the example above, the fetchAccount() function will be called twice in parallel with (123, 124). All 3 criteria objects will be updated with the account information before the rule is evaluated.

Important Notes About Mutations

  • Mutations are only called once per unique criteria value
  • Multiple mutations are called in parallel, not sequentially (for performance reasons)
  • Mutations are cached by default, so if the same criteria value is encountered again, the mutation will not be called
  • Mutations will copy the criteria object before mutating it, so the original criteria object will not be modified

Note: Mutations can be used with the static implementation of RulePilot, e.g. RulePilot.addMutation(...), however it is not recommended to do so (instead the instantiated implementation should be used), otherwise you risk having different parts of your application pushing different mutations for different rules to the same general pool.

Note: Mutation results are permanently cached for any given criteria value, so if you need to re-evaluate any rule with the same criteria mutation while needing to re-process the mutation, you will need to clear the cache first.

// Clear the cache for the account mutation
rulePilot.clearMutationCache("account");

// or ...

// Clear the entire mutation cache
rulePilot.clearMutationCache();

Debugging Mutations

Mutations can be debugged by setting an environment variable DEBUG="true" when running RulePilot. This will cause mutations to log debug information to the console.

process.env.DEBUG = "true";

Introspection

Rule introspection is built into RulePilot and can be used to inspect a rule and get information about it.

When RulePilot introspects a rule it attempts to determine, for each distinct result in the rule, the distribution of inputs which will satisfy the rule resolving to said result, provided an input criteria.

For example, using introspection we can ask Rule Pilot the following question:

Given rule A, If I evaluate the rule with a value X = 100, what are all the possible values of Y for which the rule will evaluate to a result, and what results would the rule evaluate to?

This is a useful feature when you want to know what inputs will result in a specific output, or what inputs will result in a specific output distribution.

For example if a RulePilot rule is being used to evaluate what type of discount a user should get, you can use the introspection feature to tell the user what products, quantities, requirements, etc. they must fulfill in order to obtain each possible discount.

This is particularly useful when using some form of rule based configuration to drive the UI of an application.

Taking a simple granular rule as an example:

import { RulePilot, Rule } from "rulepilot";

const rule: Rule = {
  conditions: [
    {
      any: [
        {
          all: [
            { field: "country", operator: "in", value: ["GB", "FI"] },
            { field: "hasCoupon", operator: "==", value: true },
            { field: "totalCheckoutPrice", operator: ">=", value: 120.0 },
          ],
        },
        { field: "country", operator: "==", value: "SE" },
      ],
      result: 5,
    },
    {
      all: [
        { field: "age", operator: ">=", value: 18 },
        { field: "hasStudentCard", operator: "==", value: true },
      ],
      result: 10,
    },
  ],
};

We can introspect the rule to determine what countries are available to get a discount if the user has a coupon, and what the discount amount would be in each case.

const subjects = ["country"];
const constraint = { field: "hasCoupon", value: true };
const introspection = RulePilot.introspect(rule, constraint, subjects);

The following will be returned by the introspection:

[{
  "result": 5,
  "subjects": [
    {
      "subject": "country",
      "values": [{ "value": "SE", "operator": "==" }, { "value": ["GB", "FI"], "operator": "in" }]
    }
  ]
}]

We can also ask RulePilot to introspect the rule to determine what countries would receive aa discount and what that discount would be if the totalCheckoutPrice was 100.

const subjects = ["country"];
const constraint = { field: "totalCheckoutPrice", value: 100 };
const introspection = RulePilot.introspect(rule, constraint, subjects);

The following will be returned by the introspection:

[{
  "result": 5,
  "subjects": [
    {
      "subject": "country",
      "values": [{ "value": "SE", "operator": "==" }]
    }
  ]
}]

Each object in the response criteria which are possible inputs for the rule to evaluate to the result with the criteria provided.

The results from the introspection are sanitized before being returned. This essentially means that if the introspection results in these possibilities price == 10 || price > 10 || price == 20, the results will be sanitized to simply price > 10.

Similarly to mutations, it is possible to enable debug mode on the introspection feature by setting the environment variable DEBUG="true".

process.env.DEBUG = "true";

Note: Introspection requires a Granular rule to be passed to it, otherwise RulePilot will throw an RuleTypeError.

Note Introspection does not yet work with these operators:

  • contains
  • not contains
  • contains any
  • not contains any
  • matches
  • not matches

Fluent Rule Builder

Although creating rules in plain JSON is very straightforward, RulePilot comes with a Builder class which can be used to create rules in a fluent manner.

The add() method allows for the addition of a root condition to the rule. This condition can be then setup as required.

The default() method allows for the addition of a default value result for the rule.

import { RulePilot, Rule } from "rulepilot";

const builder = RulePilot.builder();

const rule: Rule = builder
  .add(
    builder.condition(
      "all",
      [
        builder.condition("any", [
          builder.constraint("size", "==", "medium"),
          builder.constraint("weight", ">=", 2),
        ]),
        builder.constraint("category", "not in", ["free", "out of stock"]),
      ],
      { price: 20 }
    )
  )
  .add(
    builder.condition(
      "any",
      [
        builder.constraint("size", "==", "small"),
        builder.constraint("weight", "<", "2"),
      ],
      { price: 10 }
    )
  )
  .default({ price: 5 })
  .build();

Adding Sub Rules in the builder

import { RulePilot, Rule } from "rulepilot";

const builder = RulePilot.builder();

const rule: Rule = builder
  .add(
    builder.condition(
      "all",
      [
        builder.condition("any", [
          builder.constraint("size", "==", "medium"),
          builder.constraint("weight", ">=", 2),
          builder.condition(
            "all", 
            [
              builder.constraint("color", "==", "green"), 
              builder.constraint("discount", ">", 20)
            ],
            { price: 10 }
          )
        ]),
        builder.constraint("category", "not in", ["free", "out of stock"]),
      ],
      { price: 20 },
    )
  )
  .add(
    builder.condition(
      "any",
      [
        builder.constraint("size", "==", "small"),
        builder.constraint("weight", "<", "2"),
      ],
      { price: 10 }
    )
  )
  .default({ price: 5 })
  .build();

Building The Library

The distribution can be built as follows, with the output being placed in a dist directory.

npm run build
yarn build

Running Unit Tests

Tests are written in Jest and can be run with the following commands:

npm run jest --testPathPattern=test --color --forceExit --silent
yarn jest --testPathPattern=test --color --forceExit --silent