Skip to content

Latest commit

 

History

History
487 lines (366 loc) · 17.7 KB

rules.md

File metadata and controls

487 lines (366 loc) · 17.7 KB

Rules

Rules contain a set of conditions and a single event. When the engine is run, each rule condition is evaluated. If the results are truthy, the rule's event is triggered.

Methods

constructor([Object options|String json])

Returns a new rule instance

let options = {
  conditions: {
    all: [
      {
        fact: 'my-fact',
        operator: 'equal',
        value: 'some-value'
      }
    ]
  },
  event: {
    type: 'my-event',
    params: {
      customProperty: 'customValue'
    }
  },
  name: any,                               // optional
  priority: 1,                             // optional, default: 1
  onSuccess: function (event, almanac) {}, // optional
  onFailure: function (event, almanac) {}, // optional
}
let rule = new Rule(options)

options.conditions : [Object] Rule conditions object

options.event : [Object] Sets the .on('success') and on('failure') event argument emitted whenever the rule passes. Event objects must have a type property, and an optional params property.

options.priority : [Number, default 1] Dictates when rule should be run, relative to other rules. Higher priority rules are run before lower priority rules. Rules with the same priority are run in parallel. Priority must be a positive, non-zero integer.

options.onSuccess : [Function(Object event, Almanac almanac)] Registers callback with the rule's on('success') listener. The rule's event property and the current Almanac are passed as arguments. Any promise returned by the callback will be waited on to resolve before execution continues.

options.onFailure : [Function(Object event, Almanac almanac)] Registers callback with the rule's on('failure') listener. The rule's event property and the current Almanac are passed as arguments. Any promise returned by the callback will be waited on to resolve before execution continues.

options.name : [Any] A way of naming your rules, allowing them to be easily identifiable in Rule Results. This is usually of type String, but could also be Object, Array, or Number. Note that the name need not be unique, and that it has no impact on execution of the rule.

setConditions(Array conditions)

Helper for setting rule conditions. Alternative to passing the conditions option to the rule constructor.

getConditions() -> Object

Retrieves rule condition set by constructor or setCondition()

setEvent(Object event)

Helper for setting rule event. Alternative to passing the event option to the rule constructor.

getEvent() -> Object

Retrieves rule event set by constructor or setEvent()

setPriority(Integer priority = 1)

Helper for setting rule priority. Alternative to passing the priority option to the rule constructor.

getPriority() -> Integer

Retrieves rule priority set by constructor or setPriority()

toJSON(Boolean stringify = true)

Serializes the rule into a JSON string. Often used when persisting rules.

let jsonString = rule.toJSON() // string: '{"conditions":{"all":[]},"priority":50 ...

let rule = new Rule(jsonString) // restored rule; same conditions, priority, event

// without stringifying
let jsonObject = rule.toJSON(false) // object: {conditions:{ all: [] }, priority: 50 ...

Conditions

Rule conditions are a combination of facts, operators, and values that determine whether the rule is a success or a failure.

Basic conditions

The simplest form of a condition consists of a fact, an operator, and a value. When the engine runs, the operator is used to compare the fact against the value.

// my-fact <= 1
let rule = new Rule({
  conditions: {
    all: [
      {
        fact: 'my-fact',
        operator: 'lessThanInclusive',
        value: 1
      }
    ]
  }
})

See the hello-world example.

Boolean expressions: all, any, and not

Each rule's conditions must have an all or any operator containing an array of conditions at its root, a not operator containing a single condition, or a condition reference. The all operator specifies that all conditions contained within must be truthy for the rule to be considered a success. The any operator only requires one condition to be truthy for the rule to succeed. The not operator will negate whatever condition it contains.

// all:
let rule = new Rule({
  conditions: {
    all: [
      { /* condition 1 */ },
      { /* condition 2 */ },
      { /* condition n */ },
    ]
  }
})

// any:
let rule = new Rule({
  conditions: {
    any: [
      { /* condition 1 */ },
      { /* condition 2 */ },
      { /* condition n */ },
      {
        not: {
          all: [ /* more conditions */ ]
        }
      }
    ]
  }
})

// not:
let rule = new Rule({
  conditions: {
    not: { /* condition */ }
  }
})

Notice in the second example how all, any, and not can be nested within one another to produce complex boolean expressions. See the nested-boolean-logic example.

Condition Reference

Rules may reference conditions based on their name.

let rule = new Rule({
  conditions: {
    all: [
      { condition: 'conditionName' },
      { /* additional condition */ }
    ]
  }
})

Before running the rule the condition should be added to the engine.

engine.setCondition('conditionName', { /* conditions */ });

Conditions must start with all, any, not, or reference a condition.

Condition helpers: params

Sometimes facts require additional input to perform calculations. For this, the params property is passed as an argument to the fact handler. params essentially functions as fact arguments, enabling fact handlers to be more generic and reusable.

// product-price retrieves any product's price based on the "productId" in "params"
engine.addFact('product-price', function (params, almanac) {
  return productLoader(params.productId) // loads the "widget" product
    .then(product => product.price)
})

// identifies whether the current widget price is above $100
let rule = new Rule({
  conditions: {
    all: [
      {
        fact: 'product-price',
        params: {
          productId: 'widget' // specifies which product to load
        },
        operator: 'greaterThan',
        value: 100
      }
    ]
  }
})

See the dynamic-facts example

Condition helpers: path

In the params example above, the dynamic fact handler loads an object, then returns a specific object property. For more complex data structures, writing a separate fact handler for each object property quickly becomes verbose and unwieldy.

To address this, a path property may be provided to traverse fact data using json-path syntax. The example above becomes simpler, and only one fact handler must be written:

// product-price retrieves any product's price based on the "productId" in "params"
engine.addFact('product-price', function (params, almanac) {
  // NOTE: `then` is not required; .price is specified via "path" below
  return productLoader(params.productId)
})

// identifies whether the current widget price is above $100
let rule = new Rule({
  conditions: {
    all: [
      {
        fact: 'product-price',
        path: '$.price',
        params: {
          productId: 'widget'
        },
        operator: 'greaterThan',
        value: 100
      }
    ]
  }
})

json-path support is provided by jsonpath-plus

For an example, see fact-dependency

Condition helpers: custom path resolver

To use a custom path resolver instead of the json-path default, a pathResolver callback option may be passed to the engine. The callback will be invoked during execution when a path property is encountered.

const { get } = require('lodash') // to use the lodash path resolver, for example

function pathResolver (object, path) {
  // when the rule below is evaluated:
  //   "object" will be the 'fact1' value
  //   "path" will be '.price[0]'
  return get(object, path)
}
const engine = new Engine(rules, { pathResolver })
engine.addRule(new Rule({
  conditions: {
    all: [
      {
        fact: 'fact1',
        path: '.price[0]', // uses lodash path syntax
        operator: 'equal',
        value: 1
      }
    ]
  })
)

This feature may be useful in cases where the higher performance offered by simpler object traversal DSLs are preferable to the advanced expressions provided by json-path. It can also be useful for leveraging more complex DSLs (jsonata, for example) that offer more advanced capabilities than json-path.

Comparing facts

Sometimes it is necessary to compare facts against other facts. This can be accomplished by nesting the second fact within the value property. This second fact has access to the same params and path helpers as the primary fact.

// identifies whether the current widget price is above a maximum
let rule = new Rule({
  conditions: {
    all: [
      // widget-price > budget
      {
        fact: 'product-price',
        params: {
          productId: 'widget',
          path: '$.price'
        },
        operator: 'greaterThan',
        // "value" contains a fact
        value: {
          fact: 'budget' // "params" and "path" helpers are available as well
        }
      }
    ]
  }
})

See the fact-comparison example

Events

Listen for success and failure events emitted when rule is evaluated.

rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))

The callback will receive the event object, the current Almanac, and the Rule Result.

// whenever rule is evaluated and the conditions pass, 'success' will trigger
rule.on('success', function(event, almanac, ruleResult) {
  console.log(event) // { type: 'my-event', params: { id: 1 }
})

rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))

Companion to success, except fires when the rule fails. The callback will receive the event object, the current Almanac, and the Rule Result.

engine.on('failure', function(event, almanac, ruleResult) {
  console.log(event) // { type: 'my-event', params: { id: 1 }
})

Referencing Facts In Events

With the engine option replaceFactsInEventParams the parameters of the event may include references to facts in the same form as Comparing Facts. These references will be replaced with the value of the fact before the event is emitted.

const engine = new Engine([], { replaceFactsInEventParams: true });
engine.addRule({
    conditions: { /* ... */ },
    event: {
      type: "gameover",
      params: {
        initials: {
          fact: "currentHighScore",
          path: "$.initials",
          params: { foo: 'bar' }
        }
      }
    }
  })

See 11-using-facts-in-events.js for a complete example.

Operators

Each rule condition must begin with a boolean operator(all, any, or not) at its root.

The operator compares the value returned by the fact to what is stored in the value property. If the result is truthy, the condition passes.

String and Numeric operators:

equal - fact must equal value

notEqual - fact must not equal value

these operators use strict equality (===) and inequality (!==)

Numeric operators:

lessThan - fact must be less than value

lessThanInclusive- fact must be less than or equal to value

greaterThan - fact must be greater than value

greaterThanInclusive- fact must be greater than or equal to value

Array operators:

in - fact must be included in value (an array)

notIn - fact must not be included in value (an array)

contains - fact (an array) must include value

doesNotContain - fact (an array) must not include value

Operator Decorators

Operator Decorators modify the behavior of an operator either by changing the input or the output. To specify one or more decorators prefix the name of the operator with them in the operator field and use the colon (:) symbol to separate decorators and the operator. For instance everyFact:greaterThan will produce an operator that checks that every element of the fact is greater than the value.

See 12-using-operator-decorators.js for an example.

Array Decorators:

everyFact - fact (an array) must have every element pass the decorated operator for value

everyValue - fact must pass the decorated operator for every element of value (an array)

someFact - fact (an array) must have at-least one element pass the decorated operator for value

someValue - fact must pass the decorated operator for at-least one element of value (an array)

Logical Decorators

not - negate the result of the decorated operator

Utility Decorators

swap - Swap fact and value for the decorated operator

Decorator Composition

Operator Decorators can be composed by chaining them together with the colon to separate them. For example if you wanted to ensure that every number in an array was less than every number in another array you could use everyFact:everyValue:lessThan.

swap and not are useful when there are not symmetric or negated versions of custom operators, for instance you could check if a value does not start with a letter contained in a fact using the decorated custom operator swap:not:startsWithLetter. This allows a single custom operator to have 4 permutations.

Rule Results

After a rule is evaluated, a rule result object is provided to the success and failure events. This argument is similar to a regular rule, and contains additional metadata about how the rule was evaluated. Rule results can be used to extract the results of individual conditions, computed fact values, and boolean logic results. name can be used to easily identify a given rule.

Rule results are structured similar to rules, with two additional pieces of metadata sprinkled throughout: result and factResult

{
  result: false,                    // denotes whether rule computed truthy or falsey
  conditions: {
    all: [
      {
        fact: 'my-fact',
        operator: 'equal',
        value: 'some-value',
        result: false,             // denotes whether condition computed truthy or falsey
        factResult: 'other-value'  // denotes what 'my-fact' was computed to be
      }
    ]
  },
  event: {
    type: 'my-event',
    params: {
      customProperty: 'customValue'
    }
  },
  priority: 1,
  name: 'someName'
}

A demonstration can be found in the rule-results example.

Persisting

Rules may be easily converted to JSON and persisted to a database, file system, or elsewhere. To convert a rule to JSON, simply call the rule.toJSON() method. Later, a rule may be restored by feeding the json into the Rule constructor.

// save somewhere...
let jsonString = rule.toJSON()

// ...later:
let rule = new Rule(jsonString)

Why aren't "fact" methods persistable? This is by design, for several reasons. Firstly, facts are by definition business logic bespoke to your application, and therefore lie outside the scope of this library. Secondly, many times this request indicates a design smell; try thinking of other ways to compose the rules and facts to accomplish the same objective. Finally, persisting fact methods would involve serializing javascript code, and restoring it later via eval().