Skip to content

Commit f6e27a0

Browse files
authored
Merge pull request #241 from CacheControl/path-resolver
2 parents 75353df + 99bd96b commit f6e27a0

File tree

11 files changed

+163
-54
lines changed

11 files changed

+163
-54
lines changed

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
#### 6.0. / 2020-12-XX
22
* BREAKING CHANGES
3-
* Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future.
3+
* 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:
4+
```js
5+
const pathResolver = (object, path) => {
6+
return selectn(path)(object)
7+
}
8+
const engine = new Engine(rules, { pathResolver })
9+
```
10+
(fixes #205)
411
* 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)
12+
* Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future.
513
* 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)
14+
* NEW FEATURES
15+
* 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)
16+
617
718
#### 5.3.0 / 2020-12-02
819
* Allow facts to have a value of `undefined`

docs/almanac.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Almanac
22

3+
* [Overview](#overview)
4+
* [Methods](#methods)
5+
* [almanac.factValue(Fact fact, Object params, String path) -> Promise](#almanacfactvaluefact-fact-object-params-string-path---promise)
6+
* [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value)
7+
* [Common Use Cases](#common-use-cases)
8+
* [Fact dependencies](#fact-dependencies)
9+
* [Retrieve fact values when handling events](#retrieve-fact-values-when-handling-events)
10+
* [Rule Chaining](#rule-chaining)
11+
312
## Overview
413

514
An almanac collects facts through an engine run cycle. As the engine computes fact values,

docs/engine.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
The Engine stores and executes rules, emits events, and maintains state.
44

5+
* [Methods](#methods)
6+
* [constructor([Array rules], Object [options])](#constructorarray-rules-object-options)
7+
* [Options](#options)
8+
* [engine.addFact(String id, Function [definitionFunc], Object [options])](#engineaddfactstring-id-function-definitionfunc-object-options)
9+
* [engine.removeFact(String id)](#engineremovefactstring-id)
10+
* [engine.addRule(Rule instance|Object options)](#engineaddrulerule-instanceobject-options)
11+
* [engine.removeRule(Rule instance)](#engineremoverulerule-instance)
12+
* [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue)
13+
* [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname)
14+
* [engine.run([Object facts], [Object options]) -> Promise ({ events: Events, almanac: Almanac})](#enginerunobject-facts-object-options---promise--events-events-almanac-almanac)
15+
* [engine.stop() -> Engine](#enginestop---engine)
16+
* [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
17+
* [engine.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult)
18+
519
## Methods
620

721
### constructor([Array rules], Object [options])
@@ -16,7 +30,8 @@ let engine = new Engine([Array rules])
1630

1731
// initialize with options
1832
let options = {
19-
allowUndefinedFacts: false
33+
allowUndefinedFacts: false,
34+
pathResolver: (object, path) => _.get(object, path)
2035
};
2136
let engine = new Engine([Array rules], options)
2237
```
@@ -27,6 +42,8 @@ let engine = new Engine([Array rules], options)
2742
an exception is thrown. Turning this option on will cause the engine to treat
2843
undefined facts as `undefined`. (default: false)
2944

45+
`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.
46+
3047
### engine.addFact(String id, Function [definitionFunc], Object [options])
3148

3249
```js

docs/facts.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
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.
44
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_.
55

6+
* [Methods](#methods)
7+
* [constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance](#constructorstring-id-constantfunctionobject-params-almanac-almanac-object-options---instance)
8+
69
## Methods
710

811
### constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance

docs/rules.md

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,30 @@
33

44
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.
55

6-
[Methods](#methods)
7-
8-
[Conditions](#conditions)
9-
10-
[Events](#events)
11-
12-
[Operators](#operators)
13-
14-
[Rule Results](#rule-results)
6+
* [Methods](#methods)
7+
* [constructor([Object options|String json])](#constructorobject-optionsstring-json)
8+
* [setConditions(Array conditions)](#setconditionsarray-conditions)
9+
* [getConditions() -> Object](#getconditions---object)
10+
* [setEvent(Object event)](#seteventobject-event)
11+
* [getEvent() -> Object](#getevent---object)
12+
* [setPriority(Integer priority = 1)](#setpriorityinteger-priority--1)
13+
* [getPriority() -> Integer](#getpriority---integer)
14+
* [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true)
15+
* [Conditions](#conditions)
16+
* [Basic conditions](#basic-conditions)
17+
* [Boolean expressions: all and any](#boolean-expressions-all-and-any)
18+
* [Condition helpers: params](#condition-helpers-params)
19+
* [Condition helpers: path](#condition-helpers-path)
20+
* [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver)
21+
* [Comparing facts](#comparing-facts)
22+
* [Events](#events)
23+
* [rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
24+
* [rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult)
25+
* [Operators](#operators)
26+
* [String and Numeric operators:](#string-and-numeric-operators)
27+
* [Numeric operators:](#numeric-operators)
28+
* [Array operators:](#array-operators)
29+
* [Rule Results](#rule-results)
1530

1631
## Methods
1732

@@ -217,6 +232,36 @@ json-path support is provided by [jsonpath-plus](https://github.com/s3u/JSONPath
217232

218233
For an example, see [fact-dependency](../examples/04-fact-dependency.js)
219234

235+
### Condition helpers: custom `path` resolver
236+
237+
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.
238+
239+
```js
240+
const { get } = require('lodash') // to use the lodash path resolver, for example
241+
242+
function pathResolver (object, path) {
243+
// when the rule below is evaluated:
244+
// "object" will be the 'fact1' value
245+
// "path" will be '.price[0]'
246+
return get(object, path)
247+
}
248+
const engine = new Engine(rules, { pathResolver })
249+
engine.addRule(new Rule({
250+
conditions: {
251+
all: [
252+
{
253+
fact: 'fact1',
254+
path: '.price[0]', // uses lodash path syntax
255+
operator: 'equal',
256+
value: 1
257+
}
258+
]
259+
})
260+
)
261+
```
262+
263+
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`.
264+
220265
### Comparing facts
221266
222267
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.

docs/walkthrough.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Walkthrough
22

3+
* [Step 1: Create an Engine](#step-1-create-an-engine)
4+
* [Step 2: Add Rules](#step-2-add-rules)
5+
* [Step 3: Define Facts](#step-3-define-facts)
6+
* [Step 4: Handing Events](#step-4-handing-events)
7+
* [Step 5: Run the engine](#step-5-run-the-engine)
8+
39
## Step 1: Create an Engine
410

511
```js

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"chai-as-promised": "^7.1.1",
7474
"colors": "~1.4.0",
7575
"dirty-chai": "2.0.1",
76+
"lodash": "4.17.20",
7677
"mocha": "^8.1.3",
7778
"perfy": "^1.1.5",
7879
"sinon": "^9.0.3",

src/almanac.js

Lines changed: 18 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import debug from './debug'
77
import { JSONPath } from 'jsonpath-plus'
88
import isObjectLike from 'lodash.isobjectlike'
99

10+
function defaultPathResolver (value, path) {
11+
return JSONPath({ path, json: value, wrap: false })
12+
}
13+
1014
/**
1115
* Fact results lookup
1216
* Triggers fact computations and saves the results
@@ -17,6 +21,7 @@ export default class Almanac {
1721
this.factMap = new Map(factMap)
1822
this.factResultsCache = new Map() // { cacheKey: Promise<factValu> }
1923
this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts)
24+
this.pathResolver = options.pathResolver || defaultPathResolver
2025
this.successEvents = []
2126

2227
for (const factId in runtimeFacts) {
@@ -122,44 +127,19 @@ export default class Almanac {
122127
factValuePromise = this._setFactValue(fact, params, fact.calculate(params, this))
123128
}
124129
}
125-
if (path) { // selectn supports arrays and strings as a 'path'
126-
// strings starting with '$' denotes json path. otherwise fall back to deprecated 'selectn' syntax
127-
if (typeof path === 'string' && path.startsWith('$')) {
128-
debug(`condition::evaluate extracting object property ${path}`)
129-
return factValuePromise
130-
.then(factValue => {
131-
if (isObjectLike(factValue)) {
132-
const pathValue = JSONPath({ path, json: factValue, wrap: false })
133-
debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`)
134-
return pathValue
135-
} else {
136-
debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`)
137-
return factValue
138-
}
139-
})
140-
} else {
141-
let selectn
142-
try {
143-
selectn = require('selectn')
144-
} catch (err) {
145-
console.error('Oops! Looks like you\'re trying to use the deprecated syntax for the ".path" property.')
146-
console.error('Please convert your "path" properties to JsonPath syntax (ensure your path starts with "$")')
147-
console.error('Alternatively, if you wish to continue using old syntax (provided by selectn), you may "npm install selectn" as a direct dependency.')
148-
console.error('See https://github.com/CacheControl/json-rules-engine/blob/master/CHANGELOG.md#500--2019-10-27 for more information.')
149-
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')
150-
}
151-
return factValuePromise
152-
.then(factValue => {
153-
if (isObjectLike(factValue)) {
154-
const pathValue = selectn(path)(factValue)
155-
debug(`condition::evaluate extracting object property ${path}, received: ${pathValue}`)
156-
return pathValue
157-
} else {
158-
debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`)
159-
return factValue
160-
}
161-
})
162-
}
130+
if (path) {
131+
debug(`condition::evaluate extracting object property ${path}`)
132+
return factValuePromise
133+
.then(factValue => {
134+
if (isObjectLike(factValue)) {
135+
const pathValue = this.pathResolver(factValue, path)
136+
debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`)
137+
return pathValue
138+
} else {
139+
debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`)
140+
return factValue
141+
}
142+
})
163143
}
164144

165145
return factValuePromise

src/engine.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Engine extends EventEmitter {
2121
super()
2222
this.rules = []
2323
this.allowUndefinedFacts = options.allowUndefinedFacts || false
24+
this.pathResolver = options.pathResolver
2425
this.operators = new Map()
2526
this.facts = new Map()
2627
this.status = READY
@@ -210,7 +211,11 @@ class Engine extends EventEmitter {
210211
run (runtimeFacts = {}) {
211212
debug('engine::run started')
212213
this.status = RUNNING
213-
const almanac = new Almanac(this.facts, runtimeFacts, { allowUndefinedFacts: this.allowUndefinedFacts })
214+
const almanacOptions = {
215+
allowUndefinedFacts: this.allowUndefinedFacts,
216+
pathResolver: this.pathResolver
217+
}
218+
const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions)
214219
const orderedSets = this.prioritizeRules()
215220
let cursor = Promise.resolve()
216221
// for each rule set, evaluate in parallel,

test/engine-fact.test.js

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
import sinon from 'sinon'
4+
import { get } from 'lodash'
45
import engineFactory from '../src/index'
56

67
const CHILD = 14
@@ -224,9 +225,6 @@ describe('Engine: fact evaluation', () => {
224225
value: 1
225226
}]
226227
}
227-
const event = {
228-
type: 'runtimeEvent'
229-
}
230228

231229
engine = engineFactory([])
232230
const rule = factories.rule({ conditions, event })
@@ -268,6 +266,34 @@ describe('Engine: fact evaluation', () => {
268266
await engine.run()
269267
expect(successSpy).to.have.been.calledWith(event)
270268
})
269+
270+
describe('pathResolver', () => {
271+
it('allows a custom path resolver to be registered which interprets the path property', async () => {
272+
const fact = { x: { y: [99] }, a: 2 }
273+
const conditions = {
274+
all: [{
275+
fact: 'x',
276+
path: 'y[0]',
277+
operator: 'equal',
278+
value: 99
279+
}]
280+
}
281+
const pathResolver = (value, path) => {
282+
return get(value, path)
283+
}
284+
285+
engine = engineFactory([], { pathResolver })
286+
const rule = factories.rule({ conditions, event })
287+
engine.addRule(rule)
288+
engine.on('success', successSpy)
289+
engine.on('failure', failureSpy)
290+
291+
await engine.run(fact)
292+
293+
expect(successSpy).to.have.been.calledWith(event)
294+
expect(failureSpy).to.not.have.been.called()
295+
})
296+
})
271297
})
272298

273299
describe('promises', () => {

0 commit comments

Comments
 (0)