diff --git a/examples/10-facts-with-pipes.js b/examples/10-facts-with-pipes.js new file mode 100644 index 0000000..a47e7ec --- /dev/null +++ b/examples/10-facts-with-pipes.js @@ -0,0 +1,142 @@ +'use strict' +/* + * This is a basic example demonstrating a condition that applies pipes to facts' values + * + * Usage: + * node ./examples/10-fact-comparison.js + * + * For detailed output: + * DEBUG=json-rules-engine node ./examples/10-fact-comparison.js + */ + +require('colors') +const { Engine } = require('json-rules-engine') + +async function start () { + /** + * Setup a new engine + */ + const engine = new Engine() + + /** + * Rule for determining if account can affor a gift card product with a 50% discount + * + * customer-account-balance >= $50 gift card + */ + const discount = 0.5 + const rule = { + conditions: { + all: [{ + // extract 'balance' from the 'partner' account type + fact: 'account', + path: '$.balance', + params: { + accountType: 'partner' + }, + + operator: 'greaterThanInclusive', // >= + + // "value" in this instance is an object containing a fact definition + // fact helpers "path" and "params" are supported here as well + value: { + fact: 'product', + path: '$.price', + pipes: [ { name: 'scale', args: [discount]}], + params: { + productId: 'giftCard' + } + } + }] + }, + event: { type: 'customer-can-partially-afford-gift-card' } + } + engine.addRule(rule) + + engine.addFact('account', (params, almanac) => { + // get account list + return almanac.factValue('accounts') + .then(accounts => { + // use "params" to filter down to the type specified, in this case the "customer" account + const customerAccount = accounts.filter(account => account.type === params.accountType) + // return the customerAccount object, which "path" will use to pull the "balance" property + return customerAccount[0] + }) + }) + + engine.addFact('product', (params, almanac) => { + // get product list + return almanac.factValue('products') + .then(products => { + // use "params" to filter down to the product specified, in this case the "giftCard" product + const product = products.filter(product => product.productId === params.productId) + // return the product object, which "path" will use to pull the "price" property + return product[0] + }) + }) + + /** + * Register listeners with the engine for rule success and failure + */ + let facts + engine + .on('success', (event, almanac) => { + console.log(facts.userId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.') + }) + .on('failure', event => { + console.log(facts.userId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.') + }) + + // define fact(s) known at runtime + const productList = { + products: [ + { + productId: 'giftCard', + price: 50 + }, { + productId: 'widget', + price: 45 + }, { + productId: 'widget-plus', + price: 800 + } + ] + } + + let userFacts = { + userId: 'washington', + accounts: [{ + type: 'customer', + balance: 500 + }, { + type: 'partner', + balance: 30 + }] + } + + // compile facts to be fed to the engine + facts = Object.assign({}, userFacts, productList) + + // first run, user can afford a discounted gift card + await engine.run(facts) + + // second run; a user that cannot afford a discounted gift card + userFacts = { + userId: 'jefferson', + accounts: [{ + type: 'customer', + balance: 30 + }, { + type: 'partner', + balance: 10 + }] + } + facts = Object.assign({}, userFacts, productList) + await engine.run(facts) +} +start() +/* + * OUTPUT: + * + * washington DID meet conditions for the customer-can-afford-gift-card rule. + * jefferson did NOT meet conditions for the customer-can-afford-gift-card rule. + */ diff --git a/src/condition.js b/src/condition.js index 98a7690..39bfab3 100644 --- a/src/condition.js +++ b/src/condition.js @@ -4,25 +4,28 @@ import debug from './debug' import isObjectLike from 'lodash.isobjectlike' export default class Condition { - constructor (properties) { + constructor(properties) { if (!properties) throw new Error('Condition: constructor options required') const booleanOperator = Condition.booleanOperator(properties) Object.assign(this, properties) if (booleanOperator) { const subConditions = properties[booleanOperator] - if (!(Array.isArray(subConditions))) { + if (!Array.isArray(subConditions)) { throw new Error(`"${booleanOperator}" must be an array`) } this.operator = booleanOperator - // boolean conditions always have a priority; default 1 + // boolean conditions always have a priority default 1 this.priority = parseInt(properties.priority, 10) || 1 this[booleanOperator] = subConditions.map((c) => { return new Condition(c) }) } else { - if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) throw new Error('Condition: constructor "fact" property required') - if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) throw new Error('Condition: constructor "operator" property required') - if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required') + if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) + throw new Error('Condition: constructor "fact" property required') + if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) + throw new Error('Condition: constructor "operator" property required') + if (!Object.prototype.hasOwnProperty.call(properties, 'value')) + throw new Error('Condition: constructor "value" property required') // a non-boolean condition does not have a priority by default. this allows // priority to be dictated by the fact definition @@ -37,7 +40,7 @@ export default class Condition { * @param {Boolean} stringify - whether to return as a json string * @returns {string,object} json string or json-friendly object */ - toJSON (stringify = true) { + toJSON(stringify = true) { const props = {} if (this.priority) { props.priority = this.priority @@ -68,13 +71,42 @@ export default class Condition { return props } + /** + * Apply the given set of pipes to the given value. + */ + _evalPipes(value, pipes, pipeMap) { + if (!Array.isArray(pipes)) + return Promise.reject(new Error('pipes must be an array')) + if (!pipeMap) return Promise.reject(new Error('pipeMap required')) + + let currValue = value + for (const pipeObj of pipes) { + const pipe = pipeMap.get(pipeObj.name) + if (!pipe) + return Promise.reject(new Error(`Unknown pipe: ${pipeObj.name}`)) + currValue = pipe.evaluate(currValue, ...(pipeObj.args || [])) + } + + return currValue + } + /** * Interprets .value as either a primitive, or if a fact, retrieves the fact value */ - _getValue (almanac) { + _getValue(almanac, pipeMap) { const value = this.value - if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value: { fact: 'xyz' } - return almanac.factValue(value.fact, value.params, value.path) + if ( + isObjectLike(value) && + Object.prototype.hasOwnProperty.call(value, 'fact') + ) { + // value: { fact: 'xyz' } + return almanac + .factValue(value.fact, value.params, value.path) + .then((factValue) => + value.pipes + ? this._evalPipes(factValue, value.pipes, pipeMap) + : factValue + ) } return Promise.resolve(value) } @@ -86,23 +118,43 @@ export default class Condition { * * @param {Almanac} almanac * @param {Map} operatorMap - map of available operators, keyed by operator name + * @param {Map} pipeMap - map of available pipes, keyed by pipe name * @returns {Boolean} - evaluation result */ - evaluate (almanac, operatorMap) { + evaluate(almanac, operatorMap, pipeMap) { if (!almanac) return Promise.reject(new Error('almanac required')) if (!operatorMap) return Promise.reject(new Error('operatorMap required')) - if (this.isBooleanOperator()) return Promise.reject(new Error('Cannot evaluate() a boolean condition')) + if (!pipeMap && this.pipes && this.pipes.length) + return Promise.reject(new Error('pipeMap required')) + if (this.isBooleanOperator()) + return Promise.reject(new Error('Cannot evaluate() a boolean condition')) const op = operatorMap.get(this.operator) - if (!op) return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) + if (!op) + return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) - return this._getValue(almanac) // todo - parallelize - .then(rightHandSideValue => { - return almanac.factValue(this.fact, this.params, this.path) - .then(leftHandSideValue => { + return this._getValue(almanac, pipeMap) // todo - parallelize + .then((rightHandSideValue) => { + return almanac + .factValue(this.fact, this.params, this.path) + .then((leftHandSideValue) => + this.pipes + ? this._evalPipes(leftHandSideValue, this.pipes, pipeMap) + : leftHandSideValue + ) + .then((leftHandSideValue) => { const result = op.evaluate(leftHandSideValue, rightHandSideValue) - debug(`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${this.operator} ${JSON.stringify(rightHandSideValue)}?> (${result})`) - return { result, leftHandSideValue, rightHandSideValue, operator: this.operator } + debug( + `condition::evaluate <${JSON.stringify(leftHandSideValue)} ${ + this.operator + } ${JSON.stringify(rightHandSideValue)}?> (${result})` + ) + return { + result, + leftHandSideValue, + rightHandSideValue, + operator: this.operator, + } }) }) } @@ -112,7 +164,7 @@ export default class Condition { * If the condition is not a boolean condition, the result will be 'undefined' * @return {string 'all' or 'any'} */ - static booleanOperator (condition) { + static booleanOperator(condition) { if (Object.prototype.hasOwnProperty.call(condition, 'any')) { return 'any' } else if (Object.prototype.hasOwnProperty.call(condition, 'all')) { @@ -125,7 +177,7 @@ export default class Condition { * Instance version of Condition.isBooleanOperator * @returns {string,undefined} - 'any', 'all', or undefined (if not a boolean condition) */ - booleanOperator () { + booleanOperator() { return Condition.booleanOperator(this) } @@ -133,7 +185,7 @@ export default class Condition { * Whether the operator is boolean ('all', 'any') * @returns {Boolean} */ - isBooleanOperator () { + isBooleanOperator() { return Condition.booleanOperator(this) !== undefined } } diff --git a/src/engine-default-pipes.js b/src/engine-default-pipes.js new file mode 100644 index 0000000..fcdf0f6 --- /dev/null +++ b/src/engine-default-pipes.js @@ -0,0 +1,17 @@ +"use strict"; + +import Pipe from "./pipe"; + +const Pipes = []; + +//numbers pipes +Pipes.push(new Pipe("scale", (v, factor) => v * factor)); +Pipes.push(new Pipe("add", (v, r) => v + r)); +Pipes.push(new Pipe("sub", (v, r) => v - r)); + +//strings pipes +Pipes.push(new Pipe("trim", (v) => v.trim())); +Pipes.push(new Pipe("upper", (v) => v.toUpperCase())); +Pipes.push(new Pipe("lower", (v) => v.toLowerCase())); + +export default Pipes; diff --git a/src/engine.js b/src/engine.js index 3bd958e..e7837f6 100644 --- a/src/engine.js +++ b/src/engine.js @@ -6,7 +6,9 @@ import Operator from './operator' import Almanac from './almanac' import EventEmitter from 'eventemitter2' import defaultOperators from './engine-default-operators' +import defaultPipes from "./engine-default-pipes" import debug from './debug' +import Pipe from './pipe' export const READY = 'READY' export const RUNNING = 'RUNNING' @@ -23,10 +25,12 @@ class Engine extends EventEmitter { this.allowUndefinedFacts = options.allowUndefinedFacts || false this.pathResolver = options.pathResolver this.operators = new Map() + this.pipes = new Map() this.facts = new Map() this.status = READY rules.map(r => this.addRule(r)) defaultOperators.map(o => this.addOperator(o)) + defaultPipes.map((p) => this.addPipe(p)) } /** @@ -94,7 +98,7 @@ class Engine extends EventEmitter { /** * Add a custom operator definition - * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + * @param {Operator|string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. */ addOperator (operatorOrName, cb) { @@ -110,7 +114,7 @@ class Engine extends EventEmitter { /** * Remove a custom operator definition - * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + * @param {Operator|string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. */ removeOperator (operatorOrName) { @@ -124,6 +128,38 @@ class Engine extends EventEmitter { return this.operators.delete(operatorName) } + /** + * Add a custom pipe definition + * @param {Pipe|string} pipeOrName - pipe identifier within the condition; i.e. instead of 'scale', 'trim', etc + * @param {function(factValue, jsonValue)} callback - the method to execute when the pipe is encountered. + */ + addPipe (pipeOrName, cb) { + let pipe + if (pipeOrName instanceof Pipe) { + pipe = pipeOrName + } else { + pipe = new Pipe(pipeOrName, cb) + } + debug(`engine::addPipe name:${pipe.name}`) + this.pipes.set(pipe.name, pipe) + } + + /** + * Remove a custom pipe definition + * @param {Pipe|string} pipeOrName - pipe identifier within the condition; i.e. instead of 'scale', 'trim', etc + * @param {function(factValue, jsonValue)} callback - the method to execute when the pipe is encountered. + */ + removePipe (pipeOrName) { + let pipeName + if (pipeOrName instanceof Pipe) { + pipeName = pipeOrName.name + } else { + pipeName = pipeOrName + } + + return this.operators.delete(pipeName) + } + /** * Add a fact definition to the engine. Facts are called by rules as they are evaluated. * @param {object|Fact} id - fact identifier or instance of Fact diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index 339c3c0..d2c9a3d 100644 --- a/src/json-rules-engine.js +++ b/src/json-rules-engine.js @@ -2,8 +2,9 @@ import Engine from './engine' import Fact from './fact' import Rule from './rule' import Operator from './operator' +import Pipe from './pipe' -export { Fact, Rule, Operator, Engine } +export { Fact, Rule, Operator, Pipe, Engine } export default function (rules, options) { return new Engine(rules, options) } diff --git a/src/pipe.js b/src/pipe.js new file mode 100644 index 0000000..9d215bb --- /dev/null +++ b/src/pipe.js @@ -0,0 +1,29 @@ +"use strict"; + +export default class Pipe { + /** + * Constructor + * @param {string} name - pipe identifier + * @param {function(factValue, jsonValue)} callback - pipe evaluation method + * @param {function} [factValueValidator] - optional validator for asserting the data type of the fact + * @returns {Pipe} - instance + */ + constructor(name, cb, factValueValidator) { + this.name = String(name) + if (!name) throw new Error("Missing pipe name") + if (typeof cb !== "function") throw new Error("Missing pipe callback") + this.cb = cb + this.factValueValidator = factValueValidator + if (!this.factValueValidator) this.factValueValidator = () => true + } + + /** + * Takes the fact result and compares it to the condition 'value', using the callback + * @param {mixed} factValue - fact result + * @param {mixed} jsonValue - "value" property of the condition + * @returns {Boolean} - whether the values pass the pipe test + */ + evaluate(factValue, ...args) { + return this.factValueValidator(factValue) && this.cb(factValue, ...args) + } +} diff --git a/src/rule.js b/src/rule.js index 5db0191..7352f41 100644 --- a/src/rule.js +++ b/src/rule.js @@ -203,7 +203,7 @@ class Rule extends EventEmitter { return passes }) } else { - return condition.evaluate(almanac, this.engine.operators) + return condition.evaluate(almanac, this.engine.operators, this.engine.pipes) .then(evaluationResult => { const passes = evaluationResult.result condition.factResult = evaluationResult.leftHandSideValue diff --git a/test/engine-pipe.test.js b/test/engine-pipe.test.js new file mode 100644 index 0000000..b6ff436 --- /dev/null +++ b/test/engine-pipe.test.js @@ -0,0 +1,108 @@ +'use strict' + +import sinon from 'sinon' +import engineFactory from '../src/index' + +async function dictionary (params, engine) { + const words = ['coffee', 'Aardvark', 'impossible', 'ladder', 'antelope'] + return words[params.wordIndex] +} + +describe('Engine: pipe', () => { + let sandbox + before(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) + const event = { + type: 'pipeTrigger' + } + const baseConditions = { + any: [{ + fact: 'dictionary', + operator: 'equal', + value: null, + params: { + wordIndex: null + }, + pipes: [ + { name: 'lower' }, + { name: 'padEnd', args: [8, '*'] } + ] + }] + } + let eventSpy + function setup (conditions = baseConditions) { + eventSpy = sandbox.spy() + const engine = engineFactory() + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + engine.addPipe('padEnd', (factValue, maxLength, fillString = ' ') => { + return factValue.padEnd(maxLength, fillString) + }) + engine.addFact('dictionary', dictionary) + engine.on('success', eventSpy) + return engine + } + + describe('evaluation', () => { + describe('word length is less than the maxLength arg', () => { + it('succeeds and emits', async () => { + const conditions = Object.assign({}, baseConditions) + conditions.any[0].params.wordIndex = 0 + conditions.any[0].value = 'coffee**' + const engine = setup() + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + it('fails and will not emit', async () => { + const conditions = Object.assign({}, baseConditions) + conditions.any[0].params.wordIndex = 0 + conditions.any[0].value = 'coffee' + const engine = setup() + await engine.run() + expect(eventSpy).to.not.have.been.calledWith(event) + }) + }) + + describe('word length equals maxLength arg', () => { + it('succeeds and emits', async () => { + const conditions = Object.assign({}, baseConditions) + conditions.any[0].params.wordIndex = 1 + conditions.any[0].value = 'aardvark' + const engine = setup() + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + it('fails and will not emit', async () => { + const conditions = Object.assign({}, baseConditions) + conditions.any[0].params.wordIndex = 1 + conditions.any[0].value = 'Aardvark' + const engine = setup() + await engine.run() + expect(eventSpy).to.not.have.been.calledWith(event) + }) + }) + + describe('word length is greater than the maxLength arg', () => { + it('succeeds and emits', async () => { + const conditions = Object.assign({}, baseConditions) + conditions.any[0].params.wordIndex = 2 + conditions.any[0].value = 'impossible' + const engine = setup() + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + it('fails and will not emit', async () => { + const conditions = Object.assign({}, baseConditions) + conditions.any[0].params.wordIndex = 2 + conditions.any[0].value = 'impossible*' + const engine = setup() + await engine.run() + expect(eventSpy).to.not.have.been.calledWith(event) + }) + }) + }) +}) diff --git a/test/pipe.test.js b/test/pipe.test.js new file mode 100644 index 0000000..a9cc5c4 --- /dev/null +++ b/test/pipe.test.js @@ -0,0 +1,31 @@ +'use strict' + +import { Pipe } from '../src/index' + +describe('Pipe', () => { + describe('constructor()', () => { + function subject (...args) { + return new Pipe(...args) + } + + it('adds the pipe', () => { + const scalePipe = subject('scale', (factValue, factor) => { + return factValue * factor + }) + expect(scalePipe.name).to.equal('scale') + expect(scalePipe.cb).to.an.instanceof(Function) + }) + + it('pipe name', () => { + expect(() => { + subject() + }).to.throw(/Missing pipe name/) + }) + + it('pipe definition', () => { + expect(() => { + subject('scale') + }).to.throw(/Missing pipe callback/) + }) + }) +}) diff --git a/test/support/condition-factory.js b/test/support/condition-factory.js index 3546ed1..1c567db 100644 --- a/test/support/condition-factory.js +++ b/test/support/condition-factory.js @@ -1,9 +1,13 @@ 'use strict' module.exports = function (options) { - return { + const cond = { fact: options.fact || null, value: options.value || null, - operator: options.operator || 'equal' + operator: options.operator || 'equal', } + if (options.pipes) { + cond.pipes = options.pipes + } + return cond } diff --git a/types/index.d.ts b/types/index.d.ts index b3d39e2..f452d5d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -23,13 +23,17 @@ export class Engine { removeRule(ruleOrName: Rule | string): boolean; updateRule(rule: Rule): void; - addOperator(operator: Operator): Map; + addOperator(operator: Operator): void; addOperator( operatorName: string, callback: OperatorEvaluator - ): Map; + ): void; removeOperator(operator: Operator | string): boolean; + addPipe(pipe: Pipe): void; + addPipe(pipeName: string, callback: PipeEvaluator): void; + removePipe(pipe: Pipe | string): boolean; + addFact(fact: Fact): this; addFact( id: string, @@ -60,6 +64,19 @@ export class Operator { ); } +export interface PipeEvaluator { + (factValue: A, ...args: any): R; +} + +export class Pipe { + public name: string; + constructor( + name: string, + evaluator: PipeEvaluator, + validator?: (factValue: A) => boolean + ); +} + export class Almanac { factValue( factId: string, @@ -98,10 +115,7 @@ export interface Event { params?: Record; } -export type PathResolver = ( - value: object, - path: string, -) => any; +export type PathResolver = (value: object, path: string) => any; export type EventHandler = ( event: Event, diff --git a/types/index.test-d.ts b/types/index.test-d.ts index dcf5541..1bb61a5 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -10,17 +10,19 @@ import rulesEngine, { PathResolver, Rule, RuleProperties, - RuleSerializable + RuleSerializable, + PipeEvaluator, + Pipe, } from "../"; // setup basic fixture data const ruleProps: RuleProperties = { conditions: { - all: [] + all: [], }, event: { - type: "message" - } + type: "message", + }, }; const complexRuleProps: RuleProperties = { @@ -29,25 +31,25 @@ const complexRuleProps: RuleProperties = { { any: [ { - all: [] + all: [], }, { fact: "foo", operator: "equal", - value: "bar" - } - ] - } - ] + value: "bar", + }, + ], + }, + ], }, event: { - type: "message" - } + type: "message", + }, }; // path resolver -const pathResolver = function(value: object, path: string): any {} -expectType(pathResolver) +const pathResolver = function (value: object, path: string): any {}; +expectType(pathResolver); // default export test expectType(rulesEngine([ruleProps])); @@ -72,17 +74,23 @@ const operatorEvaluator: OperatorEvaluator = ( a: number, b: number ) => a === b; -expectType>( - engine.addOperator("test", operatorEvaluator) -); +expectType(engine.addOperator("test", operatorEvaluator)); const operator: Operator = new Operator( "test", operatorEvaluator, (num: number) => num > 0 ); -expectType>(engine.addOperator(operator)); +expectType(engine.addOperator(operator)); expectType(engine.removeOperator(operator)); +// Pipe tests +const pipeEvaluator: PipeEvaluator = (a: number, b: number) => + a * b; +expectType(engine.addPipe("test", pipeEvaluator)); +const pipe: Pipe = new Pipe("test", pipeEvaluator, (num: number) => num > 0); +expectType(engine.addPipe(pipe)); +expectType(engine.removePipe(pipe)); + // Fact tests const fact = new Fact("test-fact", 3); const dynamicFact = new Fact("test-fact", () => [42]);