Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/engine-default-operator-decorators.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import OperatorDecorator from './operator-decorator'

const OperatorDecorators = []

OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray))
OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))))
OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray))
OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))))
OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)))
OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue)))
const OperatorDecorators = Object.freeze([
new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray),
new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))),
new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray),
new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))),
new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)),
new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue))
])

export default OperatorDecorators
26 changes: 13 additions & 13 deletions src/engine-default-operators.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

import Operator from './operator'

const Operators = []
Operators.push(new Operator('equal', (a, b) => a === b))
Operators.push(new Operator('notEqual', (a, b) => a !== b))
Operators.push(new Operator('in', (a, b) => b.indexOf(a) > -1))
Operators.push(new Operator('notIn', (a, b) => b.indexOf(a) === -1))

Operators.push(new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray))
Operators.push(new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray))

function numberValidator (factValue) {
return Number.parseFloat(factValue).toString() !== 'NaN'
}
Operators.push(new Operator('lessThan', (a, b) => a < b, numberValidator))
Operators.push(new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator))
Operators.push(new Operator('greaterThan', (a, b) => a > b, numberValidator))
Operators.push(new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator))

const Operators = Object.freeze([
new Operator('equal', (a, b) => a === b),
new Operator('notEqual', (a, b) => a !== b),
new Operator('in', (a, b) => b.indexOf(a) > -1),
new Operator('notIn', (a, b) => b.indexOf(a) === -1),
new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray),
new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray),
new Operator('lessThan', (a, b) => a < b, numberValidator),
new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator),
new Operator('greaterThan', (a, b) => a > b, numberValidator),
new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator)
])

export default Operators
110 changes: 107 additions & 3 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,11 @@ class Engine extends EventEmitter {
this.allowUndefinedConditions = options.allowUndefinedConditions || false
this.replaceFactsInEventParams = options.replaceFactsInEventParams || false
this.pathResolver = options.pathResolver
this.operators = new OperatorMap()
this.operators = new OperatorMap({ parent: OperatorMap.shared() })
this.facts = new Map()
this.conditions = new Map()
this.status = READY
rules.map(r => this.addRule(r))
defaultOperators.map(o => this.addOperator(o))
defaultDecorators.map(d => this.addOperatorDecorator(d))
}

/**
Expand Down Expand Up @@ -319,6 +317,112 @@ class Engine extends EventEmitter {
}).catch(reject)
})
}

/**
* Lightweight static evaluation of conditions against facts using the shared operator registry.
* Avoids EventEmitter, Rule, and full Almanac overhead for high-throughput bulk evaluation.
*
* @param {Object} conditions - condition tree with all/any/not structure
* @param {Object} facts - plain object of fact key-value pairs
* @param {Object} [options] - evaluation options
* @param {boolean} [options.allowUndefinedFacts=false] - allow undefined fact references
* @param {Function} [options.pathResolver] - custom path resolver for nested properties
* @param {OperatorMap} [options.operatorMap] - custom operator map (defaults to shared)
* @returns {Promise<{results: Array, failureResults: Array, events: Array, failureEvents: Array}>}
*/
static evaluate (conditions, facts, options = {}) {
const operatorMap = options.operatorMap || OperatorMap.shared()

const almanac = new Almanac({
allowUndefinedFacts: options.allowUndefinedFacts || false,
pathResolver: options.pathResolver
})

for (const factId in facts) {
almanac.addFact(new Fact(factId, facts[factId]))
}

const rootCondition = new Condition(conditions)

const evaluateCondition = (condition) => {
if (condition.isBooleanOperator()) {
const subConditions = condition[condition.operator]
let comparisonPromise
if (condition.operator === 'all') {
comparisonPromise = all(subConditions)
} else if (condition.operator === 'any') {
comparisonPromise = any(subConditions)
} else {
comparisonPromise = notOp(subConditions)
}
return comparisonPromise.then((comparisonValue) => {
condition.result = comparisonValue === true
return condition.result
})
} else {
return condition
.evaluate(almanac, operatorMap)
.then((evaluationResult) => {
condition.factResult = evaluationResult.leftHandSideValue
condition.valueResult = evaluationResult.rightHandSideValue
condition.result = evaluationResult.result
return evaluationResult.result
})
}
}

const evaluateConditions = (conds, method) => {
if (!Array.isArray(conds)) conds = [conds]
return Promise.all(
conds.map((c) => evaluateCondition(c))
).then((results) => {
return method.call(results, (r) => r === true)
})
}

const prioritizeAndRun = (conds, operator) => {
if (conds.length === 0) return Promise.resolve(operator === 'all')
if (conds.length === 1) return evaluateCondition(conds[0])
// No priority sorting in static evaluate — evaluate all at once
return operator === 'any'
? evaluateConditions(conds, Array.prototype.some)
: evaluateConditions(conds, Array.prototype.every)
}

const any = (conds) => prioritizeAndRun(conds, 'any')
const all = (conds) => prioritizeAndRun(conds, 'all')
const notOp = (cond) => prioritizeAndRun([cond], 'not').then((r) => !r)

let rootPromise
if (rootCondition.any) {
rootPromise = any(rootCondition.any)
} else if (rootCondition.all) {
rootPromise = all(rootCondition.all)
} else if (rootCondition.not) {
rootPromise = notOp(rootCondition.not)
} else {
rootPromise = evaluateCondition(rootCondition)
}

return rootPromise.then((result) => {
const event = { type: 'evaluate' }
if (result) {
return {
results: [{ conditions: rootCondition, event, result: true }],
failureResults: [],
events: [event],
failureEvents: []
}
} else {
return {
results: [],
failureResults: [{ conditions: rootCondition, event, result: false }],
events: [],
failureEvents: [event]
}
}
})
}
}

export default Engine
59 changes: 55 additions & 4 deletions src/operator-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,31 @@

import Operator from './operator'
import OperatorDecorator from './operator-decorator'
import defaultOperators from './engine-default-operators'
import defaultDecorators from './engine-default-operator-decorators'
import debug from './debug'

let sharedInstance = null

export default class OperatorMap {
constructor () {
constructor (options = {}) {
this.operators = new Map()
this.decorators = new Map()
this.parent = options.parent || null
}

/**
* Returns a shared OperatorMap pre-populated with default operators and decorators.
* This singleton is reused across Engine instances to avoid redundant allocations.
* @returns {OperatorMap}
*/
static shared () {
if (!sharedInstance) {
sharedInstance = new OperatorMap()
defaultOperators.forEach(o => sharedInstance.addOperator(o))
defaultDecorators.forEach(d => sharedInstance.addOperatorDecorator(d))
}
return sharedInstance
}

/**
Expand Down Expand Up @@ -95,20 +114,31 @@ export default class OperatorMap {

/**
* Get the Operator, or null applies decorators as needed
* Checks local operators first, then falls back to parent if set.
* @param {string} name - the name of the operator including any decorators
* @returns an operator or null
*/
get (name) {
// Fast path: check local cache first
if (this.operators.has(name)) {
return this.operators.get(name)
}

// Check parent for cached decorated operators
if (this.parent && this.parent.operators.has(name)) {
return this.parent.operators.get(name)
}

const decorators = []
let opName = name
// while we don't already have this operator
while (!this.operators.has(opName)) {
while (!this._hasOperator(opName)) {
// try splitting on the decorator symbol (:)
const firstDecoratorIndex = opName.indexOf(':')
if (firstDecoratorIndex > 0) {
// if there is a decorator, and it's a valid decorator
const decoratorName = opName.slice(0, firstDecoratorIndex)
const decorator = this.decorators.get(decoratorName)
const decorator = this._getDecorator(decoratorName)
if (!decorator) {
debug('operatorMap::get invalid decorator', { name: decoratorName })
return null
Expand All @@ -123,7 +153,7 @@ export default class OperatorMap {
}
}

let op = this.operators.get(opName)
let op = this._getOperator(opName)
// apply all the decorators
for (let i = 0; i < decorators.length; i++) {
op = decorators[i].decorate(op)
Expand All @@ -134,4 +164,25 @@ export default class OperatorMap {
// return the operation
return op
}

/**
* Check if operator exists locally or in parent
*/
_hasOperator (name) {
return this.operators.has(name) || (this.parent && this.parent.operators.has(name))
}

/**
* Get operator from local map or parent
*/
_getOperator (name) {
return this.operators.get(name) || (this.parent && this.parent.operators.get(name))
}

/**
* Get decorator from local map or parent
*/
_getDecorator (name) {
return this.decorators.get(name) || (this.parent && this.parent.decorators.get(name))
}
}
Loading