diff --git a/src/almanac.js b/src/almanac.js index effc732..dc298bb 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -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.' + )) + } } diff --git a/src/condition.js b/src/condition.js index 5668959..cdb9c50 100644 --- a/src/condition.js +++ b/src/condition.js @@ -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 @@ -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 @@ -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) @@ -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() + } } diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js index bed371d..27c22ba 100644 --- a/src/json-rules-engine.js +++ b/src/json-rules-engine.js @@ -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) } diff --git a/src/rule.js b/src/rule.js index 98007e8..84d7ed4 100644 --- a/src/rule.js +++ b/src/rule.js @@ -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' @@ -207,18 +208,18 @@ 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) => { @@ -226,9 +227,12 @@ class Rule extends EventEmitter { 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 @@ -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) @@ -264,9 +318,10 @@ 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) } @@ -274,7 +329,7 @@ class Rule extends EventEmitter { // 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') @@ -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 @@ -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) { @@ -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) } } diff --git a/src/scoped-almanac.js b/src/scoped-almanac.js new file mode 100644 index 0000000..9331585 --- /dev/null +++ b/src/scoped-almanac.js @@ -0,0 +1,85 @@ +'use strict' + +import debug from './debug' + +/** + * Scoped Almanac for nested condition evaluation + * Wraps a parent almanac but prioritizes item properties for fact resolution + */ +export default class ScopedAlmanac { + constructor (parentAlmanac, item) { + this.parentAlmanac = parentAlmanac + this.item = item + } + + /** + * Resolves a path directly on the current scoped item + * Used by scoped conditions that have path but no fact + * @param {string} path - JSONPath to resolve on the item (e.g., '$.state' or '$.nested.property') + * @return {Promise} resolves with the value at the path + */ + resolvePath (path) { + if (this.item == null) { + debug('scoped-almanac::resolvePath item is null') + return Promise.resolve(undefined) + } + + const value = this.parentAlmanac.pathResolver(this.item, path) + debug('scoped-almanac::resolvePath', { path, value, item: this.item }) + return Promise.resolve(value) + } + + /** + * Retrieves a fact value, first checking if it's a property on the current item + * @param {string} factId - fact identifier + * @param {Object} params - parameters to feed into the fact + * @param {string} path - object path + * @return {Promise} resolves with the fact value + */ + factValue (factId, params = {}, path = '') { + // First check if factId is a property on the current item + if (this.item != null && typeof this.item === 'object' && + Object.prototype.hasOwnProperty.call(this.item, factId)) { + let value = this.item[factId] + debug('scoped-almanac::factValue found property on item', { factId, value }) + // Apply path if provided + if (path) { + value = this.parentAlmanac.pathResolver(value, path) + debug('scoped-almanac::factValue resolved path', { path, value }) + } + return Promise.resolve(value) + } + // Fall back to parent almanac + debug('scoped-almanac::factValue falling back to parent almanac', { factId }) + return this.parentAlmanac.factValue(factId, params, path) + } + + /** + * Interprets value as either a primitive, or if a fact, retrieves the fact value + * Handles both fact references and path-only references for scoped conditions + * @param {*} value - the value to interpret + * @return {Promise} resolves with the value + */ + getValue (value) { + if (value != null && typeof value === 'object') { + // If value references a fact, resolve through scoped factValue + if (Object.prototype.hasOwnProperty.call(value, 'fact')) { + return this.factValue(value.fact, value.params, value.path) + } + // If value only has a path (for scoped comparisons), resolve directly on item + if (Object.prototype.hasOwnProperty.call(value, 'path') && + !Object.prototype.hasOwnProperty.call(value, 'fact')) { + return this.resolvePath(value.path) + } + } + return Promise.resolve(value) + } + + /** + * Expose pathResolver from parent almanac + * @returns {Function} the path resolver function + */ + get pathResolver () { + return this.parentAlmanac.pathResolver + } +} diff --git a/test/condition.test.js b/test/condition.test.js index cd1d8f5..fb56aba 100644 --- a/test/condition.test.js +++ b/test/condition.test.js @@ -323,12 +323,20 @@ describe('Condition', () => { expect(() => new Condition(conditions)).to.throw(/Condition: constructor "operator" property required/) }) - it('throws for a missing "fact"', () => { + it('throws for a missing "fact" when no path provided', () => { const conditions = condition() delete conditions.all[0].fact + delete conditions.all[0].path expect(() => new Condition(conditions)).to.throw(/Condition: constructor "fact" property required/) }) + it('allows path-only condition (scoped condition)', () => { + const conditions = condition() + delete conditions.all[0].fact + // path, operator, and value are present - this is a valid scoped condition + expect(() => new Condition(conditions)).to.not.throw() + }) + it('throws for a missing "value"', () => { const conditions = condition() delete conditions.all[0].value diff --git a/test/engine-nested-conditions.test.js b/test/engine-nested-conditions.test.js new file mode 100644 index 0000000..8939696 --- /dev/null +++ b/test/engine-nested-conditions.test.js @@ -0,0 +1,1138 @@ +'use strict' + +import sinon from 'sinon' +import engineFactory from '../src/index' + +describe('Engine: nested conditions', () => { + let engine + let sandbox + + before(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('basic nested condition with "some" operator', () => { + const event = { + type: 'bonus-threshold-met' + } + + beforeEach(() => { + engine = engineFactory() + }) + + it('emits when at least one array item matches all nested conditions', async () => { + const conditions = { + all: [{ + fact: 'payrollItems', + operator: 'some', + conditions: { + all: [ + { fact: 'type', operator: 'equal', value: 'bonus' }, + { fact: 'amount', operator: 'greaterThan', value: 300 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('payrollItems', [ + { type: 'bonus', amount: 500 }, + { type: 'salary', amount: 1000 }, + { type: 'bonus', amount: 200 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('does not emit when no array item matches all nested conditions', async () => { + const conditions = { + all: [{ + fact: 'payrollItems', + operator: 'some', + conditions: { + all: [ + { fact: 'type', operator: 'equal', value: 'bonus' }, + { fact: 'amount', operator: 'greaterThan', value: 300 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('payrollItems', [ + { type: 'bonus', amount: 100 }, + { type: 'salary', amount: 1000 }, + { type: 'bonus', amount: 200 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('nested condition with "all" inside conditions', () => { + const event = { type: 'all-conditions-match' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('passes when all conditions inside match for at least one item', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'status', operator: 'equal', value: 'active' }, + { fact: 'count', operator: 'greaterThan', value: 5 }, + { fact: 'enabled', operator: 'equal', value: true } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { status: 'inactive', count: 10, enabled: true }, + { status: 'active', count: 10, enabled: true }, + { status: 'active', count: 3, enabled: true } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('nested condition with "any" inside conditions', () => { + const event = { type: 'any-condition-match' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('passes when any condition inside matches for at least one item', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + any: [ + { fact: 'status', operator: 'equal', value: 'premium' }, + { fact: 'level', operator: 'greaterThan', value: 10 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { status: 'basic', level: 1 }, + { status: 'basic', level: 15 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('fails when no item matches any condition', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + any: [ + { fact: 'status', operator: 'equal', value: 'premium' }, + { fact: 'level', operator: 'greaterThan', value: 10 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { status: 'basic', level: 1 }, + { status: 'standard', level: 5 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('nested condition with "not" inside conditions', () => { + const event = { type: 'not-condition-match' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('passes when negated condition is false for at least one item', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + not: { + fact: 'disabled', + operator: 'equal', + value: true + } + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { disabled: true }, + { disabled: false } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('fails when negated condition is true for all items', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + not: { + fact: 'disabled', + operator: 'equal', + value: true + } + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { disabled: true }, + { disabled: true } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('recursive nesting (nested within nested)', () => { + const event = { type: 'recursive-nested-match' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('supports nested conditions within nested conditions', async () => { + const conditions = { + all: [{ + fact: 'departments', + operator: 'some', + conditions: { + all: [ + { fact: 'name', operator: 'equal', value: 'Engineering' }, + { + fact: 'employees', + operator: 'some', + conditions: { + all: [ + { fact: 'role', operator: 'equal', value: 'developer' }, + { fact: 'yearsExperience', operator: 'greaterThan', value: 5 } + ] + } + } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('departments', [ + { + name: 'Sales', + employees: [ + { role: 'manager', yearsExperience: 10 } + ] + }, + { + name: 'Engineering', + employees: [ + { role: 'developer', yearsExperience: 2 }, + { role: 'developer', yearsExperience: 8 } + ] + } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('fails when inner nested condition does not match', async () => { + const conditions = { + all: [{ + fact: 'departments', + operator: 'some', + conditions: { + all: [ + { fact: 'name', operator: 'equal', value: 'Engineering' }, + { + fact: 'employees', + operator: 'some', + conditions: { + all: [ + { fact: 'role', operator: 'equal', value: 'developer' }, + { fact: 'yearsExperience', operator: 'greaterThan', value: 5 } + ] + } + } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('departments', [ + { + name: 'Engineering', + employees: [ + { role: 'developer', yearsExperience: 2 }, + { role: 'developer', yearsExperience: 3 } + ] + } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('empty array returns false', () => { + const event = { type: 'empty-array-test' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('returns false when array is empty', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'value', operator: 'greaterThan', value: 0 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', []) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('non-array fact returns false', () => { + const event = { type: 'non-array-test' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('returns false when fact is not an array (object)', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'value', operator: 'greaterThan', value: 0 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', { value: 100 }) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + + it('returns false when fact is null', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'value', operator: 'greaterThan', value: 0 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', null) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + + it('returns false when fact is a primitive', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'value', operator: 'greaterThan', value: 0 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', 'string value') + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('path support in parent condition', () => { + const event = { type: 'path-support-test' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('supports path on the parent condition to extract array', async () => { + const conditions = { + all: [{ + fact: 'data', + path: '$.items', + operator: 'some', + conditions: { + all: [ + { fact: 'type', operator: 'equal', value: 'special' } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('data', { + items: [ + { type: 'regular' }, + { type: 'special' } + ] + }) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('path support in nested conditions', () => { + const event = { type: 'nested-path-test' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('supports path on nested condition facts', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'metadata', path: '$.status', operator: 'equal', value: 'active' } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { metadata: { status: 'inactive' } }, + { metadata: { status: 'active' } } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('nested conditions with fact params', () => { + const event = { type: 'fact-params-test' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('supports params on the parent condition fact', async () => { + const conditions = { + all: [{ + fact: 'getData', + params: { category: 'electronics' }, + operator: 'some', + conditions: { + all: [ + { fact: 'price', operator: 'lessThan', value: 100 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('getData', (params) => { + if (params.category === 'electronics') { + return [ + { name: 'phone', price: 500 }, + { name: 'cable', price: 50 } + ] + } + return [] + }) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('fallback to parent almanac for non-item properties', () => { + const event = { type: 'parent-almanac-fallback' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('uses parent almanac for facts not on the current item', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'type', operator: 'equal', value: 'bonus' }, + { fact: 'minimumAmount', operator: 'lessThan', value: { fact: 'amount' } } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { type: 'bonus', amount: 500 }, + { type: 'salary', amount: 1000 } + ]) + engine.addFact('minimumAmount', 300) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('toJSON serialization', () => { + it('correctly serializes nested conditions', () => { + engine = engineFactory() + + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'type', operator: 'equal', value: 'special' }, + { fact: 'count', operator: 'greaterThan', value: 10 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event: { type: 'test' } }) + engine.addRule(rule) + + const json = engine.rules[0].toJSON(false) + expect(json.conditions.all[0].operator).to.equal('some') + expect(json.conditions.all[0].fact).to.equal('items') + expect(json.conditions.all[0].conditions.all).to.have.length(2) + expect(json.conditions.all[0].conditions.all[0].fact).to.equal('type') + expect(json.conditions.all[0].conditions.all[1].fact).to.equal('count') + }) + }) + + describe('nested conditions with multiple nested conditions at same level', () => { + const event = { type: 'multiple-nested' } + + beforeEach(() => { + engine = engineFactory() + }) + + it('evaluates multiple nested conditions correctly', async () => { + const conditions = { + all: [ + { + fact: 'orders', + operator: 'some', + conditions: { + all: [ + { fact: 'status', operator: 'equal', value: 'completed' } + ] + } + }, + { + fact: 'payments', + operator: 'some', + conditions: { + all: [ + { fact: 'verified', operator: 'equal', value: true } + ] + } + } + ] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('orders', [ + { status: 'pending' }, + { status: 'completed' } + ]) + engine.addFact('payments', [ + { verified: false }, + { verified: true } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('fails when one nested condition fails', async () => { + const conditions = { + all: [ + { + fact: 'orders', + operator: 'some', + conditions: { + all: [ + { fact: 'status', operator: 'equal', value: 'completed' } + ] + } + }, + { + fact: 'payments', + operator: 'some', + conditions: { + all: [ + { fact: 'verified', operator: 'equal', value: true } + ] + } + } + ] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('orders', [ + { status: 'pending' }, + { status: 'completed' } + ]) + engine.addFact('payments', [ + { verified: false }, + { verified: false } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('result tracking in nested conditions', () => { + beforeEach(() => { + engine = engineFactory() + }) + + it('sets factResult and result on the nested condition', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'value', operator: 'greaterThan', value: 50 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event: { type: 'test' } }) + engine.addRule(rule) + + const items = [ + { value: 30 }, + { value: 100 } + ] + engine.addFact('items', items) + + const results = await engine.run() + + const nestedCondition = results.results[0].conditions.all[0] + expect(nestedCondition.result).to.equal(true) + expect(nestedCondition.factResult).to.deep.equal(items) + }) + + it('sets result to false when no items match', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { fact: 'value', operator: 'greaterThan', value: 200 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event: { type: 'test' } }) + engine.addRule(rule) + + const items = [ + { value: 30 }, + { value: 100 } + ] + engine.addFact('items', items) + + const results = await engine.run() + + const nestedCondition = results.failureResults[0].conditions.all[0] + expect(nestedCondition.result).to.equal(false) + expect(nestedCondition.factResult).to.deep.equal(items) + }) + }) + + describe('path-only scoped conditions', () => { + const event = { type: 'path-only-scoped-test' } + + beforeEach(() => { + engine = engineFactory() + }) + + describe('basic path-only conditions inside nested block', () => { + it('matches when path-only conditions evaluate true on at least one array item', async () => { + const conditions = { + all: [{ + fact: 'workersCompData', + path: '$.payrollData', + operator: 'some', + conditions: { + all: [ + { path: '$.state', operator: 'equal', value: 'CA' }, + { path: '$.ncciCode', operator: 'equal', value: '8810' } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('workersCompData', { + payrollData: [ + { state: 'NY', ncciCode: '8810', payroll: 50000 }, + { state: 'CA', ncciCode: '8810', payroll: 75000 }, + { state: 'TX', ncciCode: '9999', payroll: 30000 } + ] + }) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('does not match when no array item satisfies all path-only conditions', async () => { + const conditions = { + all: [{ + fact: 'workersCompData', + path: '$.payrollData', + operator: 'some', + conditions: { + all: [ + { path: '$.state', operator: 'equal', value: 'CA' }, + { path: '$.ncciCode', operator: 'equal', value: '8810' } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('workersCompData', { + payrollData: [ + { state: 'NY', ncciCode: '8810', payroll: 50000 }, + { state: 'CA', ncciCode: '9999', payroll: 75000 }, + { state: 'TX', ncciCode: '5555', payroll: 30000 } + ] + }) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('path-only with "any" boolean operator', () => { + it('matches when at least one path-only condition matches on an item', async () => { + const conditions = { + all: [{ + fact: 'payrollItems', + operator: 'some', + conditions: { + any: [ + { path: '$.state', operator: 'equal', value: 'CA' }, + { path: '$.payroll', operator: 'greaterThan', value: 100000 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('payrollItems', [ + { state: 'NY', payroll: 50000 }, + { state: 'TX', payroll: 150000 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('path-only with nested object paths', () => { + it('resolves deeply nested paths on array items', async () => { + const conditions = { + all: [{ + fact: 'employees', + operator: 'some', + conditions: { + all: [ + { path: '$.department.name', operator: 'equal', value: 'Engineering' }, + { path: '$.department.budget', operator: 'greaterThan', value: 50000 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('employees', [ + { name: 'Alice', department: { name: 'Sales', budget: 30000 } }, + { name: 'Bob', department: { name: 'Engineering', budget: 100000 } } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('path-only with dynamic value comparison', () => { + it('compares path-only left side to path-only right side (same row)', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { path: '$.actual', operator: 'greaterThan', value: { path: '$.expected' } } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { actual: 50, expected: 100 }, + { actual: 150, expected: 100 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + + it('fails when no row satisfies the path comparison', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { path: '$.actual', operator: 'greaterThan', value: { path: '$.expected' } } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { actual: 50, expected: 100 }, + { actual: 80, expected: 100 } + ]) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.not.have.been.called() + }) + }) + + describe('mixing path-only and fact-based conditions in nested block', () => { + it('supports both path-only and fact-based conditions together', async () => { + const conditions = { + all: [{ + fact: 'items', + operator: 'some', + conditions: { + all: [ + { path: '$.status', operator: 'equal', value: 'active' }, + { fact: 'minThreshold', operator: 'lessThan', value: { path: '$.count' } } + ] + } + }] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('items', [ + { status: 'inactive', count: 20 }, + { status: 'active', count: 15 } + ]) + engine.addFact('minThreshold', 10) + + const eventSpy = sandbox.spy() + engine.on('success', eventSpy) + + await engine.run() + expect(eventSpy).to.have.been.calledWith(event) + }) + }) + + describe('toJSON serialization for path-only conditions', () => { + it('correctly serializes path-only scoped conditions', () => { + engine = engineFactory() + + const conditions = { + all: [{ + fact: 'data', + path: '$.items', + operator: 'some', + conditions: { + all: [ + { path: '$.state', operator: 'equal', value: 'CA' }, + { path: '$.code', operator: 'equal', value: '123' } + ] + } + }] + } + + const rule = factories.rule({ conditions, event: { type: 'test' } }) + engine.addRule(rule) + + const json = engine.rules[0].toJSON(false) + expect(json.conditions.all[0].operator).to.equal('some') + expect(json.conditions.all[0].fact).to.equal('data') + expect(json.conditions.all[0].path).to.equal('$.items') + expect(json.conditions.all[0].conditions.all).to.have.length(2) + expect(json.conditions.all[0].conditions.all[0].path).to.equal('$.state') + expect(json.conditions.all[0].conditions.all[0]).to.not.have.property('fact') + expect(json.conditions.all[0].conditions.all[1].path).to.equal('$.code') + expect(json.conditions.all[0].conditions.all[1]).to.not.have.property('fact') + }) + }) + + describe('error handling for path-only conditions outside nested context', () => { + it('throws error when path-only condition is used outside nested block', async () => { + const conditions = { + all: [ + { path: '$.state', operator: 'equal', value: 'CA' } + ] + } + + const rule = factories.rule({ conditions, event }) + engine.addRule(rule) + + engine.addFact('someData', { state: 'CA' }) + + try { + await engine.run() + expect.fail('Should have thrown an error') + } catch (error) { + expect(error.message).to.include('path-only conditions') + expect(error.message).to.include('can only be used inside nested conditions') + } + }) + }) + + describe('result tracking for path-only scoped conditions', () => { + it('tracks factResult and result on path-only nested conditions', async () => { + const conditions = { + all: [{ + fact: 'data', + path: '$.records', + operator: 'some', + conditions: { + all: [ + { path: '$.value', operator: 'greaterThan', value: 50 } + ] + } + }] + } + + const rule = factories.rule({ conditions, event: { type: 'test' } }) + engine.addRule(rule) + + engine.addFact('data', { + records: [ + { value: 30 }, + { value: 100 } + ] + }) + + const results = await engine.run() + + const nestedCondition = results.results[0].conditions.all[0] + expect(nestedCondition.result).to.equal(true) + expect(nestedCondition.factResult).to.deep.equal([ + { value: 30 }, + { value: 100 } + ]) + }) + }) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index aaa22f8..310266f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -94,6 +94,11 @@ export class Almanac { params?: Record, path?: string ): Promise; + /** + * Resolves a path - only valid in scoped context (ScopedAlmanac) + * Throws error when called on regular Almanac to catch misuse of scoped conditions + */ + resolvePath(path: string): Promise; addFact(fact: Fact): this; addFact( id: string, @@ -103,6 +108,25 @@ export class Almanac { addRuntimeFact(factId: string, value: any): void; } +/** + * Scoped Almanac for nested condition evaluation + * Wraps a parent almanac but prioritizes item properties for fact resolution + */ +export class ScopedAlmanac { + constructor(parentAlmanac: Almanac, item: any); + /** + * Resolves a path directly on the current scoped item + * Used by scoped conditions that have path but no fact + */ + resolvePath(path: string): Promise; + factValue( + factId: string, + params?: Record, + path?: string + ): Promise; + getValue(value: any): Promise; +} + export type FactOptions = { cache?: boolean; priority?: number; @@ -209,10 +233,44 @@ interface ConditionProperties { name?: string; } +/** + * Scoped condition that evaluates a path directly on the current array item + * Used inside nested conditions where the "fact" is implicitly the current array item + * The path is resolved using JSONPath against the scoped item + */ +interface ScopedConditionProperties { + path: string; + operator: string; + value: { path: string } | any; + priority?: number; + params?: Record; + name?: string; +} + +interface ScopedConditionPropertiesResult extends ScopedConditionProperties, ConditionResultProperties {} + +/** + * Nested condition that evaluates conditions against array items + * Uses the 'some' operator to check if at least one array item matches + */ +interface NestedConditionProperties { + fact: string; + operator: 'some'; + conditions: TopLevelCondition; + path?: string; + priority?: number; + params?: Record; + name?: string; +} + +interface NestedConditionPropertiesResult extends NestedConditionProperties, ConditionResultProperties { + conditions: TopLevelConditionResult; +} + type ConditionPropertiesResult = ConditionProperties & ConditionResultProperties -type NestedCondition = ConditionProperties | TopLevelCondition; -type NestedConditionResult = ConditionPropertiesResult | TopLevelConditionResult; +type NestedCondition = ConditionProperties | NestedConditionProperties | ScopedConditionProperties | TopLevelCondition; +type NestedConditionResult = ConditionPropertiesResult | NestedConditionPropertiesResult | ScopedConditionPropertiesResult | TopLevelConditionResult; type AllConditions = { all: NestedCondition[]; name?: string;