From 15f7033ef16cdd4ac4406f474fbcc42fad32f65a Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sun, 13 Dec 2020 13:45:47 -0700 Subject: [PATCH 1/6] Add engine pathResolver option; remove selectn automatic fallback --- CHANGELOG.md | 9 ++++++- package.json | 1 + src/almanac.js | 56 +++++++++++++--------------------------- src/engine.js | 7 ++++- test/engine-fact.test.js | 31 +++++++++++++++++++--- types/index.d.ts | 1 + 6 files changed, 62 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0bb99..7e64286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,14 @@ #### 6.0. / 2020-12-XX * BREAKING CHANGES - * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future. + * `path` support using `selectn` should use the `pathResolver` feature. Read more [here](). To continue using selectn, add the following to the engine constructor: + ```js + const pathResolver = (value, path) => { + return selectn(path)(value) + } + const engine = new Engine(rules, { pathResolver }) + ``` * Engine and Rule events `on('success')`, `on('failure')`, and Rule callbacks `onSuccess` and `onFailure` now honor returned promises; any event handler that returns a promise will be waited upon to resolve before engine execution continues. (fixes #235) + * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future. * The 'success-events' fact used to store successful events has been converted to an internal data structure and will no longer appear in the almanac's facts. (fixes #187) #### 5.3.0 / 2020-12-02 diff --git a/package.json b/package.json index 6cb140d..3dff2f3 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "chai-as-promised": "^7.1.1", "colors": "~1.4.0", "dirty-chai": "2.0.1", + "lodash": "4.17.20", "mocha": "^8.1.3", "perfy": "^1.1.5", "sinon": "^9.0.3", diff --git a/src/almanac.js b/src/almanac.js index d9c2f63..87391c1 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -7,6 +7,10 @@ import debug from './debug' import { JSONPath } from 'jsonpath-plus' import isObjectLike from 'lodash.isobjectlike' +function defaultPathResolver (value, path) { + return JSONPath({ path, json: value, wrap: false }) +} + /** * Fact results lookup * Triggers fact computations and saves the results @@ -17,6 +21,7 @@ export default class Almanac { this.factMap = new Map(factMap) this.factResultsCache = new Map() // { cacheKey: Promise } this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts) + this.pathResolver = options.pathResolver || defaultPathResolver this.successEvents = [] for (const factId in runtimeFacts) { @@ -122,44 +127,19 @@ export default class Almanac { factValuePromise = this._setFactValue(fact, params, fact.calculate(params, this)) } } - if (path) { // selectn supports arrays and strings as a 'path' - // strings starting with '$' denotes json path. otherwise fall back to deprecated 'selectn' syntax - if (typeof path === 'string' && path.startsWith('$')) { - debug(`condition::evaluate extracting object property ${path}`) - return factValuePromise - .then(factValue => { - if (isObjectLike(factValue)) { - const pathValue = JSONPath({ path, json: factValue, wrap: false }) - debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`) - return pathValue - } else { - debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`) - return factValue - } - }) - } else { - let selectn - try { - selectn = require('selectn') - } catch (err) { - console.error('Oops! Looks like you\'re trying to use the deprecated syntax for the ".path" property.') - console.error('Please convert your "path" properties to JsonPath syntax (ensure your path starts with "$")') - console.error('Alternatively, if you wish to continue using old syntax (provided by selectn), you may "npm install selectn" as a direct dependency.') - console.error('See https://github.com/CacheControl/json-rules-engine/blob/master/CHANGELOG.md#500--2019-10-27 for more information.') - throw new Error('json-rules-engine: Unmet peer dependency "selectn" required for use of deprecated ".path" syntax. please "npm install selectn" or convert to json-path syntax') - } - return factValuePromise - .then(factValue => { - if (isObjectLike(factValue)) { - const pathValue = selectn(path)(factValue) - debug(`condition::evaluate extracting object property ${path}, received: ${pathValue}`) - return pathValue - } else { - debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`) - return factValue - } - }) - } + if (path) { + debug(`condition::evaluate extracting object property ${path}`) + return factValuePromise + .then(factValue => { + if (isObjectLike(factValue)) { + const pathValue = this.pathResolver(factValue, path) + debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`) + return pathValue + } else { + debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`) + return factValue + } + }) } return factValuePromise diff --git a/src/engine.js b/src/engine.js index 6dba752..ecffaea 100644 --- a/src/engine.js +++ b/src/engine.js @@ -21,6 +21,7 @@ class Engine extends EventEmitter { super() this.rules = [] this.allowUndefinedFacts = options.allowUndefinedFacts || false + this.pathResolver = options.pathResolver this.operators = new Map() this.facts = new Map() this.status = READY @@ -210,7 +211,11 @@ class Engine extends EventEmitter { run (runtimeFacts = {}) { debug('engine::run started') this.status = RUNNING - const almanac = new Almanac(this.facts, runtimeFacts, { allowUndefinedFacts: this.allowUndefinedFacts }) + const almanacOptions = { + allowUndefinedFacts: this.allowUndefinedFacts, + pathResolver: this.pathResolver + } + const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions) const orderedSets = this.prioritizeRules() let cursor = Promise.resolve() // for each rule set, evaluate in parallel, diff --git a/test/engine-fact.test.js b/test/engine-fact.test.js index c88978c..fe821e2 100644 --- a/test/engine-fact.test.js +++ b/test/engine-fact.test.js @@ -1,6 +1,7 @@ 'use strict' import sinon from 'sinon' +import { get } from 'lodash' import engineFactory from '../src/index' const CHILD = 14 @@ -224,9 +225,6 @@ describe('Engine: fact evaluation', () => { value: 1 }] } - const event = { - type: 'runtimeEvent' - } engine = engineFactory([]) const rule = factories.rule({ conditions, event }) @@ -268,6 +266,33 @@ describe('Engine: fact evaluation', () => { await engine.run() expect(successSpy).to.have.been.calledWith(event) }) + + describe('pathResolver', () => { + it('allows a custom path resolver to be registered which interprets the path property', async () => { + const fact = { x: { y: [99] }, a: 2 } + const conditions = { + all: [{ + fact: 'x', + path: 'y[0]', + operator: 'equal', + value: 99 + }] + } + const pathResolver = (value, path) => { + return get(value, path) + } + + engine = engineFactory([], { pathResolver }) + const rule = factories.rule({ conditions, event }) + await engine.run() + engine.addRule(rule) + engine.on('success', successSpy) + engine.on('failure', failureSpy) + await engine.run(fact) + expect(successSpy).to.have.been.calledWith(event) + expect(failureSpy).to.not.have.been.calledWith(event) + }) + }) }) describe('promises', () => { diff --git a/types/index.d.ts b/types/index.d.ts index b82fb8f..8b83493 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,5 +1,6 @@ export interface EngineOptions { allowUndefinedFacts: boolean; + pathResolver: string; } export interface EngineResult { From 4b6d9876d107adb98256158077ac46db0a021f3c Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sun, 13 Dec 2020 14:32:45 -0700 Subject: [PATCH 2/6] Document path resolver option; add table of contents to docs --- CHANGELOG.md | 4 +-- docs/almanac.md | 9 +++++++ docs/engine.md | 19 +++++++++++++- docs/facts.md | 3 +++ docs/rules.md | 61 ++++++++++++++++++++++++++++++++++++++------- docs/walkthrough.md | 6 +++++ 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e64286..c7c5aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ * BREAKING CHANGES * `path` support using `selectn` should use the `pathResolver` feature. Read more [here](). To continue using selectn, add the following to the engine constructor: ```js - const pathResolver = (value, path) => { - return selectn(path)(value) + const pathResolver = (object, path) => { + return selectn(path)(object) } const engine = new Engine(rules, { pathResolver }) ``` diff --git a/docs/almanac.md b/docs/almanac.md index 638a36c..515a22a 100644 --- a/docs/almanac.md +++ b/docs/almanac.md @@ -1,5 +1,14 @@ # Almanac +* [Overview](#overview) +* [Methods](#methods) + * [almanac.factValue(Fact fact, Object params, String path) -> Promise](#almanacfactvaluefact-fact-object-params-string-path---promise) + * [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value) +* [Common Use Cases](#common-use-cases) + * [Fact dependencies](#fact-dependencies) + * [Retrieve fact values when handling events](#retrieve-fact-values-when-handling-events) + * [Rule Chaining](#rule-chaining) + ## Overview An almanac collects facts through an engine run cycle. As the engine computes fact values, diff --git a/docs/engine.md b/docs/engine.md index c96b72d..08dd9f5 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -2,6 +2,20 @@ The Engine stores and executes rules, emits events, and maintains state. +* [Methods](#methods) + * [constructor([Array rules], Object [options])](#constructorarray-rules-object-options) + * [Options](#options) + * [engine.addFact(String id, Function [definitionFunc], Object [options])](#engineaddfactstring-id-function-definitionfunc-object-options) + * [engine.removeFact(String id)](#engineremovefactstring-id) + * [engine.addRule(Rule instance|Object options)](#engineaddrulerule-instanceobject-options) + * [engine.removeRule(Rule instance)](#engineremoverulerule-instance) + * [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue) + * [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname) + * [engine.run([Object facts], [Object options]) -> Promise ({ events: Events, almanac: Almanac})](#enginerunobject-facts-object-options---promise--events-events-almanac-almanac) + * [engine.stop() -> Engine](#enginestop---engine) + * [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult) + * [engine.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult) + ## Methods ### constructor([Array rules], Object [options]) @@ -16,7 +30,8 @@ let engine = new Engine([Array rules]) // initialize with options let options = { - allowUndefinedFacts: false + allowUndefinedFacts: false, + pathResolver: (object, path) => _.get(object, path) }; let engine = new Engine([Array rules], options) ``` @@ -27,6 +42,8 @@ let engine = new Engine([Array rules], options) an exception is thrown. Turning this option on will cause the engine to treat undefined facts as `undefined`. (default: false) +`pathResolver` - Allows a custom object path resolution library to be used. (default: `json-path` syntax). See [custom path resolver](./rules.md#condition-helpers-custom-path-resolver) docs. + ### engine.addFact(String id, Function [definitionFunc], Object [options]) ```js diff --git a/docs/facts.md b/docs/facts.md index c1b1c82..588f5c4 100644 --- a/docs/facts.md +++ b/docs/facts.md @@ -3,6 +3,9 @@ Facts are methods or constants registered with the engine prior to runtime and referenced within rule conditions. Each fact method should be a pure function that may return a either computed value, or promise that resolves to a computed value. As rule conditions are evaluated during runtime, they retrieve fact values dynamically and use the condition _operator_ to compare the fact result with the condition _value_. +* [Methods](#methods) + * [constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance](#constructorstring-id-constantfunctionobject-params-almanac-almanac-object-options---instance) + ## Methods ### constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance diff --git a/docs/rules.md b/docs/rules.md index 6110936..8b2d446 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -3,15 +3,30 @@ 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](#methods) - -[Conditions](#conditions) - -[Events](#events) - -[Operators](#operators) - -[Rule Results](#rule-results) +* [Methods](#methods) + * [constructor([Object options|String json])](#constructorobject-optionsstring-json) + * [setConditions(Array conditions)](#setconditionsarray-conditions) + * [getConditions() -> Object](#getconditions---object) + * [setEvent(Object event)](#seteventobject-event) + * [getEvent() -> Object](#getevent---object) + * [setPriority(Integer priority = 1)](#setpriorityinteger-priority--1) + * [getPriority() -> Integer](#getpriority---integer) + * [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true) +* [Conditions](#conditions) + * [Basic conditions](#basic-conditions) + * [Boolean expressions: all and any](#boolean-expressions-all-and-any) + * [Condition helpers: params](#condition-helpers-params) + * [Condition helpers: path](#condition-helpers-path) + * [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver) + * [Comparing facts](#comparing-facts) +* [Events](#events) + * [rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult) + * [rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult) +* [Operators](#operators) + * [String and Numeric operators:](#string-and-numeric-operators) + * [Numeric operators:](#numeric-operators) + * [Array operators:](#array-operators) +* [Rule Results](#rule-results) ## Methods @@ -217,6 +232,34 @@ json-path support is provided by [jsonpath-plus](https://github.com/s3u/JSONPath For an example, see [fact-dependency](../examples/04-fact-dependency.js) +### 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. + +```js +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 + } + ] + }) +) +``` + ### 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. diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 744bf31..f0f7cf0 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -1,5 +1,11 @@ # Walkthrough +* [Step 1: Create an Engine](#step-1-create-an-engine) +* [Step 2: Add Rules](#step-2-add-rules) + * [Step 3: Define Facts](#step-3-define-facts) +* [Step 4: Handing Events](#step-4-handing-events) +* [Step 5: Run the engine](#step-5-run-the-engine) + ## Step 1: Create an Engine ```js From 07ee3d6350b8a4879c259222ec79cb7ca22cb9e1 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sun, 13 Dec 2020 14:38:00 -0700 Subject: [PATCH 3/6] Document path resolver doc link in changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c5aae..a743ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,19 @@ #### 6.0. / 2020-12-XX * BREAKING CHANGES - * `path` support using `selectn` should use the `pathResolver` feature. Read more [here](). To continue using selectn, add the following to the engine constructor: + * `path` support using `selectn` should use the `pathResolver` feature. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). To continue using `selectn`, add the following to the engine constructor: ```js const pathResolver = (object, path) => { return selectn(path)(object) } const engine = new Engine(rules, { pathResolver }) ``` + (fixes #205) * Engine and Rule events `on('success')`, `on('failure')`, and Rule callbacks `onSuccess` and `onFailure` now honor returned promises; any event handler that returns a promise will be waited upon to resolve before engine execution continues. (fixes #235) * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future. * The 'success-events' fact used to store successful events has been converted to an internal data structure and will no longer appear in the almanac's facts. (fixes #187) + * NEW FEATURES + * Engine constructor now accepts a `pathResolver` option for resolving condition `path` properties. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). (fixes #210) + #### 5.3.0 / 2020-12-02 * Allow facts to have a value of `undefined` From ae3482e9c8362a352d2acf920258f0be0b949add Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sun, 13 Dec 2020 19:07:51 -0700 Subject: [PATCH 4/6] Add ts types for path-resolver engine option --- test/engine-fact.test.js | 5 +++-- types/index.d.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/test/engine-fact.test.js b/test/engine-fact.test.js index fe821e2..93d9b99 100644 --- a/test/engine-fact.test.js +++ b/test/engine-fact.test.js @@ -284,13 +284,14 @@ describe('Engine: fact evaluation', () => { engine = engineFactory([], { pathResolver }) const rule = factories.rule({ conditions, event }) - await engine.run() engine.addRule(rule) engine.on('success', successSpy) engine.on('failure', failureSpy) + await engine.run(fact) + expect(successSpy).to.have.been.calledWith(event) - expect(failureSpy).to.not.have.been.calledWith(event) + expect(failureSpy).to.not.have.been.called() }) }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 8b83493..e054b3a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,6 @@ export interface EngineOptions { - allowUndefinedFacts: boolean; - pathResolver: string; + allowUndefinedFacts?: boolean; + pathResolver?: PathResolver; } export interface EngineResult { @@ -94,6 +94,11 @@ export interface Event { params?: Record; } +export type PathResolver = ( + value: object, + path: string, +) => any; + export type EventHandler = ( event: Event, almanac: Almanac, From 21c6632bfd78f983692ccf767ac1e3aa9d773273 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sun, 13 Dec 2020 19:12:31 -0700 Subject: [PATCH 5/6] Link to selectn in changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a743ecb..396ec13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ #### 6.0. / 2020-12-XX * BREAKING CHANGES - * `path` support using `selectn` should use the `pathResolver` feature. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). To continue using `selectn`, add the following to the engine constructor: + * To continue using [selectn](https://github.com/wilmoore/selectn.js) syntax for condition `path`s, use the new `pathResolver` feature. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). Add the following to the engine constructor: ```js const pathResolver = (object, path) => { return selectn(path)(object) @@ -12,7 +12,7 @@ * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future. * The 'success-events' fact used to store successful events has been converted to an internal data structure and will no longer appear in the almanac's facts. (fixes #187) * NEW FEATURES - * Engine constructor now accepts a `pathResolver` option for resolving condition `path` properties. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). (fixes #210) + * Engine constructor now accepts a `pathResolver` option for resolving condition `path` properties. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). (fixes #210) #### 5.3.0 / 2020-12-02 From 99bd96b469b763df5dc78e8b0c1c2a0894a35f29 Mon Sep 17 00:00:00 2001 From: Cache Hamm Date: Sun, 13 Dec 2020 19:22:24 -0700 Subject: [PATCH 6/6] Document reasons for using a path-resolver --- docs/rules.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rules.md b/docs/rules.md index 8b2d446..cc40c1d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -260,6 +260,8 @@ engine.addRule(new Rule({ ) ``` +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](https://jsonata.org/), 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.