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
14 changes: 14 additions & 0 deletions src/almanac.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,18 @@ export default class Almanac {
}
return Promise.resolve(value)
}

/**
* Resolves a path - only valid in scoped context (ScopedAlmanac)
* This method exists to provide a clear error when path-only conditions
* are used outside of a nested condition context
* @param {string} path - the path to resolve
* @return {Promise} rejects with an error
*/
resolvePath (path) {
return Promise.reject(new Error(
`Almanac::resolvePath - path-only conditions (path: "${path}") can only be used inside nested conditions. ` +
'Ensure this condition is within a "some" or "every" operator block.'
))
}
}
119 changes: 116 additions & 3 deletions src/condition.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,40 @@ export default class Condition {
this[booleanOperator] = new Condition(subConditions)
}
} else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
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') }
const hasFact = Object.prototype.hasOwnProperty.call(properties, 'fact')
const hasOperator = Object.prototype.hasOwnProperty.call(properties, 'operator')
const hasValue = Object.prototype.hasOwnProperty.call(properties, 'value')

// Check if this is a nested condition (operator: 'some' with conditions property)
if (this.isNestedCondition()) {
// Nested array conditions require fact (to get the array)
if (!hasFact) {
throw new Error('Condition: constructor "fact" property required for nested conditions')
}
// Parse the nested conditions tree
this.conditions = new Condition(properties.conditions)
} else if (this.isScopedCondition()) {
// Scoped conditions (path only, no fact) - used inside nested conditions
// where the "fact" is implicitly the current array item
if (!hasOperator) {
throw new Error('Condition: constructor "operator" property required')
}
if (!hasValue) {
throw new Error('Condition: constructor "value" property required')
}
// path is already validated by isScopedCondition()
} else {
// Regular fact-based condition
if (!hasFact) {
throw new Error('Condition: constructor "fact" property required')
}
if (!hasOperator) {
throw new Error('Condition: constructor "operator" property required')
}
if (!hasValue) {
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
Expand Down Expand Up @@ -55,6 +86,39 @@ export default class Condition {
}
} else if (this.isConditionReference()) {
props.condition = this.condition
} else if (this.isNestedCondition()) {
props.operator = this.operator
props.fact = this.fact
props.conditions = this.conditions.toJSON(false)
if (this.factResult !== undefined) {
props.factResult = this.factResult
}
if (this.result !== undefined) {
props.result = this.result
}
if (this.params) {
props.params = this.params
}
if (this.path) {
props.path = this.path
}
} else if (this.isScopedCondition()) {
// Scoped condition (path only, no fact)
props.operator = this.operator
props.value = this.value
props.path = this.path
if (this.factResult !== undefined) {
props.factResult = this.factResult
}
if (this.valueResult !== undefined) {
props.valueResult = this.valueResult
}
if (this.result !== undefined) {
props.result = this.result
}
if (this.params) {
props.params = this.params
}
} else {
props.operator = this.operator
props.value = this.value
Expand Down Expand Up @@ -98,6 +162,33 @@ export default class Condition {
const op = operatorMap.get(this.operator)
if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) }

// Handle scoped conditions (path only, no fact)
// These are used inside nested conditions where the almanac is a ScopedAlmanac
if (this.isScopedCondition()) {
return Promise.all([
almanac.getValue(this.value),
almanac.resolvePath(this.path)
]).then(([rightHandSideValue, leftHandSideValue]) => {
const result = op.evaluate(leftHandSideValue, rightHandSideValue)
debug(
'condition::evaluate (scoped)', {
path: this.path,
leftHandSideValue,
operator: this.operator,
rightHandSideValue,
result
}
)
return {
result,
leftHandSideValue,
rightHandSideValue,
operator: this.operator
}
})
}

// Regular fact-based condition
return Promise.all([
almanac.getValue(this.value),
almanac.factValue(this.fact, this.params, this.path)
Expand Down Expand Up @@ -159,4 +250,26 @@ export default class Condition {
isConditionReference () {
return Object.prototype.hasOwnProperty.call(this, 'condition')
}

/**
* Whether the condition is a nested condition (operator: 'some' with 'conditions' property)
* @returns {Boolean}
*/
isNestedCondition () {
return this.operator === 'some' &&
Object.prototype.hasOwnProperty.call(this, 'conditions')
}

/**
* Whether this is a scoped condition (path only, no fact)
* Used inside nested conditions where the "fact" is the current array item
* The path is resolved directly against the scoped item
* @returns {Boolean}
*/
isScopedCondition () {
return !Object.prototype.hasOwnProperty.call(this, 'fact') &&
Object.prototype.hasOwnProperty.call(this, 'path') &&
Object.prototype.hasOwnProperty.call(this, 'operator') &&
!this.isBooleanOperator()
}
}
3 changes: 2 additions & 1 deletion src/json-rules-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import Fact from './fact'
import Rule from './rule'
import Operator from './operator'
import Almanac from './almanac'
import ScopedAlmanac from './scoped-almanac'
import OperatorDecorator from './operator-decorator'

export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator }
export { Fact, Rule, Operator, Engine, Almanac, ScopedAlmanac, OperatorDecorator }
export default function (rules, options) {
return new Engine(rules, options)
}
99 changes: 79 additions & 20 deletions src/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Condition from './condition'
import RuleResult from './rule-result'
import ScopedAlmanac from './scoped-almanac'
import debug from './debug'
import deepClone from 'clone'
import EventEmitter from 'eventemitter2'
Expand Down Expand Up @@ -207,28 +208,31 @@ class Rule extends EventEmitter {
* @param {Condition} condition - condition to evaluate
* @return {Promise(true|false)} - resolves with the result of the condition evaluation
*/
const evaluateCondition = (condition) => {
const evaluateCondition = (condition, currentAlmanac = almanac) => {
if (condition.isConditionReference()) {
return realize(condition)
return realize(condition, currentAlmanac)
} else if (condition.isBooleanOperator()) {
const subConditions = condition[condition.operator]
let comparisonPromise
if (condition.operator === 'all') {
comparisonPromise = all(subConditions)
comparisonPromise = all(subConditions, currentAlmanac)
} else if (condition.operator === 'any') {
comparisonPromise = any(subConditions)
comparisonPromise = any(subConditions, currentAlmanac)
} else {
comparisonPromise = not(subConditions)
comparisonPromise = not(subConditions, currentAlmanac)
}
// for booleans, rule passing is determined by the all/any/not result
return comparisonPromise.then((comparisonValue) => {
const passes = comparisonValue === true
condition.result = passes
return passes
})
} else if (condition.isNestedCondition()) {
// Handle nested conditions (operator: 'some')
return evaluateNestedCondition(condition, currentAlmanac)
} else {
return condition
.evaluate(almanac, this.engine.operators)
.evaluate(currentAlmanac, this.engine.operators)
.then((evaluationResult) => {
const passes = evaluationResult.result
condition.factResult = evaluationResult.leftHandSideValue
Expand All @@ -239,17 +243,67 @@ class Rule extends EventEmitter {
}
}

/**
* Evaluates a nested condition (operator: 'some')
* @param {Condition} condition - the nested condition to evaluate
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @return {Promise(boolean)} - resolves with true if any array item matches
*/
const evaluateNestedCondition = (condition, currentAlmanac) => {
// Resolve the array fact
return currentAlmanac.factValue(condition.fact, condition.params, condition.path)
.then((arrayValue) => {
if (!Array.isArray(arrayValue)) {
debug('rule::evaluateNestedCondition fact is not an array', { fact: condition.fact, value: arrayValue })
condition.result = false
condition.factResult = arrayValue
return false
}

debug('rule::evaluateNestedCondition evaluating', { fact: condition.fact, arrayLength: arrayValue.length })

// For 'some' operator: return true if any item matches all nested conditions
// Use sequential evaluation to check items one by one
const evaluateItemsSequentially = (items, index) => {
if (index >= items.length) {
// No items matched
debug('rule::evaluateNestedCondition no matching items found')
condition.result = false
condition.factResult = arrayValue
return false
}

const item = items[index]
const scopedAlmanac = new ScopedAlmanac(currentAlmanac, item)
return evaluateCondition(condition.conditions, scopedAlmanac)
.then((result) => {
if (result) {
debug('rule::evaluateNestedCondition found matching item', { item })
condition.result = true
condition.factResult = arrayValue
return true
}
// Try next item
return evaluateItemsSequentially(items, index + 1)
})
}

return evaluateItemsSequentially(arrayValue, 0)
})
}

/**
* Evalutes an array of conditions, using an 'every' or 'some' array operation
* @param {Condition[]} conditions
* @param {string(every|some)} array method to call for determining result
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @return {Promise(boolean)} whether conditions evaluated truthy or falsey based on condition evaluation + method
*/
const evaluateConditions = (conditions, method) => {
const evaluateConditions = (conditions, method, currentAlmanac = almanac) => {
if (!Array.isArray(conditions)) conditions = [conditions]

return Promise.all(
conditions.map((condition) => evaluateCondition(condition))
conditions.map((condition) => evaluateCondition(condition, currentAlmanac))
).then((conditionResults) => {
debug('rule::evaluateConditions', { results: conditionResults })
return method.call(conditionResults, (result) => result === true)
Expand All @@ -264,17 +318,18 @@ class Rule extends EventEmitter {
* it will short-circuit and not bother evaluating any additional rules
* @param {Condition[]} conditions - conditions to be evaluated
* @param {string('all'|'any'|'not')} operator
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @return {Promise(boolean)} rule evaluation result
*/
const prioritizeAndRun = (conditions, operator) => {
const prioritizeAndRun = (conditions, operator, currentAlmanac = almanac) => {
if (conditions.length === 0) {
return Promise.resolve(true)
}
if (conditions.length === 1) {
// no prioritizing is necessary, just evaluate the single condition
// 'all' and 'any' will give the same results with a single condition so no method is necessary
// this also covers the 'not' case which should only ever have a single condition
return evaluateCondition(conditions[0])
return evaluateCondition(conditions[0], currentAlmanac)
}
const orderedSets = this.prioritizeConditions(conditions)
let cursor = Promise.resolve(operator === 'all')
Expand All @@ -284,8 +339,8 @@ class Rule extends EventEmitter {
cursor = cursor.then((setResult) => {
// rely on the short-circuiting behavior of || and && to avoid evaluating subsequent conditions
return operator === 'any'
? (setResult || evaluateConditions(set, Array.prototype.some))
: (setResult && evaluateConditions(set, Array.prototype.every))
? (setResult || evaluateConditions(set, Array.prototype.some, currentAlmanac))
: (setResult && evaluateConditions(set, Array.prototype.every, currentAlmanac))
})
}
return cursor
Expand All @@ -294,36 +349,40 @@ class Rule extends EventEmitter {
/**
* Runs an 'any' boolean operator on an array of conditions
* @param {Condition[]} conditions to be evaluated
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @return {Promise(boolean)} condition evaluation result
*/
const any = (conditions) => {
return prioritizeAndRun(conditions, 'any')
const any = (conditions, currentAlmanac = almanac) => {
return prioritizeAndRun(conditions, 'any', currentAlmanac)
}

/**
* Runs an 'all' boolean operator on an array of conditions
* @param {Condition[]} conditions to be evaluated
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @return {Promise(boolean)} condition evaluation result
*/
const all = (conditions) => {
return prioritizeAndRun(conditions, 'all')
const all = (conditions, currentAlmanac = almanac) => {
return prioritizeAndRun(conditions, 'all', currentAlmanac)
}

/**
* Runs a 'not' boolean operator on a single condition
* @param {Condition} condition to be evaluated
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @return {Promise(boolean)} condition evaluation result
*/
const not = (condition) => {
return prioritizeAndRun([condition], 'not').then((result) => !result)
const not = (condition, currentAlmanac = almanac) => {
return prioritizeAndRun([condition], 'not', currentAlmanac).then((result) => !result)
}

/**
* Dereferences the condition reference and then evaluates it.
* @param {Condition} conditionReference
* @param {Almanac} currentAlmanac - the almanac to use for fact resolution
* @returns {Promise(boolean)} condition evaluation result
*/
const realize = (conditionReference) => {
const realize = (conditionReference, currentAlmanac = almanac) => {
const condition = this.engine.conditions.get(conditionReference.condition)
if (!condition) {
if (this.engine.allowUndefinedConditions) {
Expand All @@ -339,7 +398,7 @@ class Rule extends EventEmitter {
// project the referenced condition onto reference object and evaluate it.
delete conditionReference.condition
Object.assign(conditionReference, deepClone(condition))
return evaluateCondition(conditionReference)
return evaluateCondition(conditionReference, currentAlmanac)
}
}

Expand Down
Loading