rule-engine-js
Version:
A high-performance, secure rule engine with dynamic field comparison support
1,721 lines (1,557 loc) • 53.5 kB
JavaScript
/**
* Rule Engine JS v1.0.2
* A high-performance, secure rule engine with dynamic field comparison support
*
* @license MIT
* @author Crafts 69 Guy <crafts69.guy@gmail.com>
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.RuleEngineJS = {}));
})(this, (function (exports) { 'use strict';
/**
* Secure path resolver with caching and prototype pollution protection
*/
class PathResolver {
constructor(options = {}) {
this.allowPrototypeAccess = options.allowPrototypeAccess || false;
this.enableCache = options.enableCache !== false;
this.maxCacheSize = options.maxCacheSize || 1000;
// Cache for resolved paths
this.cache = this.enableCache ? new Map() : null;
// Pre-create constants for performance
this.NOT_FOUND = Symbol('PATH_NOT_FOUND');
this.PROTOTYPE_PROPS = new Set(['__proto__', 'constructor', 'prototype']);
}
/**
* Resolve a path in the given context
* @param {Object} context - The context object to resolve paths in
* @param {string} path - The dot-notation path (e.g., 'user.profile.name')
* @param {any} defaultValue - Value to return if path is not found
* @returns {any} The resolved value or defaultValue
*/
resolve(context, path, defaultValue = undefined) {
// Input validation
if (!this._isValidContext(context) || !this._isValidPath(path)) {
return defaultValue;
}
// Check cache first
if (this.cache) {
const cacheKey = this._createCacheKey(context, path);
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
}
try {
const result = this._resolvePath(context, path);
const finalResult = result === this.NOT_FOUND ? defaultValue : result;
// Cache the result
this._cacheResult(context, path, finalResult);
return finalResult;
} catch (error) {
// eslint-disable-next-line no-console
console.error('error', error);
// Return default value on any error
return defaultValue;
}
}
/**
* Smart value resolver - handles both paths and literals
* This is the core method for dynamic comparison support
* @param {Object} context - The context object
* @param {any} value - Either a path string or literal value
* @param {any} defaultValue - Default value if path resolution fails (optional)
* @returns {any} Resolved value, original value if not a path, or defaultValue if path fails
*/
resolveValue(context, value, defaultValue = undefined) {
// Non-string values are returned as-is (numbers, booleans, objects, arrays)
if (typeof value !== 'string') {
return value;
}
// Try path resolution first
const resolved = this.resolve(context, value, this.NOT_FOUND);
// If path resolution succeeds, return resolved value
if (resolved !== this.NOT_FOUND) {
return resolved;
}
// If defaultValue is provided and path resolution failed, use defaultValue
// Otherwise, treat as literal string value
return defaultValue !== undefined ? defaultValue : value;
}
/**
* Resolve value with literal fallback behavior
* If path resolution fails, treats the value as a literal string
* This is ideal for dynamic field comparison where strings can be either paths or literals
*
* @param {Object} context - The context object
* @param {any} value - Either a path string or literal value
* @returns {any} Resolved path value or original value as literal
*
* @example
* // Path exists
* resolveValueOrLiteral(context, 'user.name') // Returns: resolved value
*
* // Path doesn't exist
* resolveValueOrLiteral(context, 'hello') // Returns: 'hello' (as literal string)
*/
resolveValueOrLiteral(context, value) {
return this.resolveValue(context, value, value);
}
/**
* Resolve value with explicit default behavior
* If path resolution fails, returns the specified defaultValue
* This is ideal when you want predictable fallback values
*
* @param {Object} context - The context object
* @param {any} value - Either a path string or literal value
* @param {any} defaultValue - Value to return if path resolution fails
* @returns {any} Resolved path value or defaultValue
*
* @example
* // Path exists
* resolveValueOrDefault(context, 'user.age', 0) // Returns: resolved age value
*
* // Path doesn't exist
* resolveValueOrDefault(context, 'user.missing', 0) // Returns: 0
*/
resolveValueOrDefault(context, value, defaultValue) {
return this.resolveValue(context, value, defaultValue);
}
/**
* Clear the cache
*/
clearCache() {
if (this.cache) {
this.cache.clear();
}
}
/**
* Get cache statistics for monitoring
* @returns {Object|null} Cache stats or null if caching disabled
*/
getCacheStats() {
return this.cache ? {
size: this.cache.size,
maxSize: this.maxCacheSize,
hitRate: this._calculateHitRate()
} : null;
}
// Private methods
/**
* Validate that context is a valid object
* @private
*/
_isValidContext(context) {
return context !== null && typeof context === 'object' && !Array.isArray(context);
}
/**
* Validate that path is a valid string
* @private
*/
_isValidPath(path) {
return typeof path === 'string' && path.length > 0 && !path.startsWith('.') && !path.endsWith('.');
}
/**
* Securely resolve a path through the context object
* @private
*/
_resolvePath(context, path) {
const keys = path.split('.');
let current = context;
for (const key of keys) {
if (current === null || current === undefined) {
return this.NOT_FOUND;
}
// Security: Prevent prototype pollution
if (!this.allowPrototypeAccess && this.PROTOTYPE_PROPS.has(key)) {
return this.NOT_FOUND;
}
// Security: Only access own properties (not inherited)
if (typeof current === 'object' && !Object.prototype.hasOwnProperty.call(current, key)) {
return this.NOT_FOUND;
}
// Additional security: prevent access to functions unless explicitly allowed
if (typeof current[key] === 'function' && !this.allowPrototypeAccess) {
return this.NOT_FOUND;
}
current = current[key];
}
return current;
}
/**
* Create a cache key for the given context and path
* @private
*/
_createCacheKey(context, path) {
// Use a simple but effective cache key strategy
const contextId = this._getContextId(context);
return `${contextId}:${path}`;
}
/**
* Generate a unique identifier for the context object
* @private
*/
_getContextId(context) {
// Try to find existing identifiers first
if (context._id) {
return String(context._id);
}
if (context.id) {
return String(context.id);
}
// Generate a simple hash based on object structure
const keys = Object.keys(context).sort();
const keyString = keys.join(',');
const valueTypes = keys.map(key => typeof context[key]).join(',');
return `${keyString}:${valueTypes}:${keys.length}`;
}
/**
* Cache a resolved result with LRU eviction
* @private
*/
_cacheResult(context, path, result) {
if (!this.cache) {
return;
}
// LRU eviction: remove oldest entry if cache is full
if (this.cache.size >= this.maxCacheSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
const cacheKey = this._createCacheKey(context, path);
this.cache.set(cacheKey, result);
}
/**
* Calculate cache hit rate for monitoring
* @private
*/
_calculateHitRate() {
// This is a placeholder - in a real implementation you'd track hits/misses
// For now, return 0 as we're not tracking detailed metrics yet
return 0;
}
}
/**
* Base rule engine error class
*/
class RuleEngineError extends Error {
constructor(message, operator = null, context = {}, originalError = null) {
super(message);
this.name = 'RuleEngineError';
this.operator = operator;
this.context = context;
this.originalError = originalError;
this.timestamp = new Date().toISOString();
// Maintain stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, RuleEngineError);
}
}
toJSON() {
return {
name: this.name,
message: this.message,
operator: this.operator,
context: this.context,
timestamp: this.timestamp
};
}
}
/**
* Validation specific error
*/
class ValidationError extends RuleEngineError {
constructor(message, context = {}) {
super(message, null, context);
this.name = 'ValidationError';
}
}
/**
* Operator specific error
*/
class OperatorError extends RuleEngineError {
constructor(message, operator, context = {}, originalError = null) {
super(message, operator, context, originalError);
this.name = 'OperatorError';
}
}
/**
* Operator name constants
*/
const OPERATOR_NAMES = {
// Comparison operators
EQ: 'eq',
NEQ: 'neq',
GT: 'gt',
GTE: 'gte',
LT: 'lt',
LTE: 'lte',
// Array operators
IN: 'in',
NOT_IN: 'notIn',
// Logical operators
AND: 'and',
OR: 'or',
NOT: 'not',
// String operators
CONTAINS: 'contains',
STARTS_WITH: 'startsWith',
ENDS_WITH: 'endsWith',
REGEX: 'regex',
// Special operators
BETWEEN: 'between',
IS_NULL: 'isNull',
IS_NOT_NULL: 'isNotNull'
};
/**
* Default configuration
*/
const DEFAULT_CONFIG = {
maxDepth: 10,
maxOperators: 100,
maxCacheSize: 1000,
enableCache: true,
enableDebug: false,
strict: true,
allowPrototypeAccess: false
};
/**
* Main Rule Engine class
* Handles rule evaluation, operator management, and performance tracking
*/
class RuleEngine {
constructor(config = {}) {
this.config = {
...DEFAULT_CONFIG,
...config
};
this.pathResolver = new PathResolver(this.config);
this.operators = new Map();
// Performance metrics
this.metrics = {
evaluations: 0,
cacheHits: 0,
errors: 0,
totalTime: 0,
avgTime: 0
};
// Expression cache with LRU behavior
this.expressionCache = this.config.enableCache ? new Map() : null;
}
/**
* Evaluate a rule expression against a context
* @param {Object} expr - The rule expression to evaluate
* @param {Object} context - The data context for evaluation
* @param {number} depth - Current recursion depth (for internal use)
* @returns {Object} Evaluation result with success flag and details
*/
evaluateExpr(expr, context, depth = 0) {
// eslint-disable-next-line no-undef
const startTime = performance.now();
this.metrics.evaluations++;
try {
// Validate rule structure and depth
this._validateRule(expr, depth);
// Check expression cache for performance
const cacheResult = this._checkExpressionCache(expr, context);
if (cacheResult) {
this._updateMetrics(startTime, true);
return cacheResult;
}
// Evaluate the expression
const result = this._evaluateExpression(expr, context, depth);
// Cache successful results
this._cacheExpressionResult(expr, context, result);
this._updateMetrics(startTime, false);
return result;
} catch (error) {
this.metrics.errors++;
this._updateMetrics(startTime, false);
return this._createErrorResult(error, expr, context);
}
}
/**
* Register a custom operator
* @param {string} name - Operator name
* @param {Function} handler - Operator implementation function
* @param {Object} options - Registration options
*/
registerOperator(name, handler, options = {}) {
if (!options.allowOverwrite && this.operators.has(name)) {
throw new RuleEngineError(`Operator '${name}' already exists`, name);
}
if (typeof handler !== 'function') {
throw new RuleEngineError('Operator handler must be a function', name);
}
this.operators.set(name, handler);
}
/**
* Get all registered operators
* @returns {string[]} Array of operator names
*/
getOperators() {
return Array.from(this.operators.keys());
}
/**
* Get performance metrics
* @returns {Object} Current performance metrics
*/
getMetrics() {
return {
...this.metrics
};
}
/**
* Clear all caches
*/
clearCache() {
if (this.expressionCache) {
this.expressionCache.clear();
}
this.pathResolver.clearCache();
}
/**
* Get current configuration
* @returns {Object} Current configuration object
*/
getConfig() {
return {
...this.config
};
}
/**
* Get cache statistics
* @returns {Object} Cache statistics for monitoring
*/
getCacheStats() {
return {
expression: this.expressionCache ? {
size: this.expressionCache.size,
maxSize: this.config.maxCacheSize
} : null,
path: this.pathResolver.getCacheStats()
};
}
// Private methods
/**
* Validate rule structure and depth
* @private
*/
_validateRule(rule, depth) {
if (depth > this.config.maxDepth) {
throw new RuleEngineError(`Rule exceeds maximum depth of ${this.config.maxDepth}`, null, {
depth,
maxDepth: this.config.maxDepth
});
}
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
throw new RuleEngineError('Rule must be a non-null object', null, {
rule
});
}
const operators = Object.keys(rule);
if (operators.length === 0) {
throw new RuleEngineError('Rule must contain at least one operator', null, {
rule
});
}
// Validate operator count
const operatorCount = this._countOperators(rule);
if (operatorCount > this.config.maxOperators) {
throw new RuleEngineError(`Rule exceeds maximum operators of ${this.config.maxOperators}`, null, {
operatorCount,
maxOperators: this.config.maxOperators
});
}
}
/**
* Count total operators in a rule (including nested ones)
* @private
*/
_countOperators(rule, count = 0) {
if (!rule || typeof rule !== 'object') {
return count;
}
for (const [key, value] of Object.entries(rule)) {
if (this.operators.has(key)) {
count++;
if (Array.isArray(value)) {
for (const item of value) {
count = this._countOperators(item, count);
}
}
}
}
return count;
}
/**
* Check expression cache for existing result
* @private
*/
_checkExpressionCache(expr, context) {
if (!this.expressionCache) {
return null;
}
const cacheKey = this._createExpressionCacheKey(expr, context);
return this.expressionCache.get(cacheKey) || null;
}
/**
* Cache expression result with LRU eviction
* @private
*/
_cacheExpressionResult(expr, context, result) {
if (!this.expressionCache || !result.success) {
return;
}
const cacheKey = this._createExpressionCacheKey(expr, context);
// LRU eviction: remove oldest entry if cache is full
if (this.expressionCache.size >= this.config.maxCacheSize) {
const firstKey = this.expressionCache.keys().next().value;
this.expressionCache.delete(firstKey);
}
this.expressionCache.set(cacheKey, result);
}
/**
* Evaluate the actual expression
* @private
*/
_evaluateExpression(expr, context, depth) {
const operators = Object.keys(expr);
for (const operatorName of operators) {
const args = expr[operatorName];
const operator = this.operators.get(operatorName);
if (!operator) {
throw new RuleEngineError(`Unknown operator: ${operatorName}`, operatorName, {
args
});
}
if (!Array.isArray(args)) {
throw new RuleEngineError(`Invalid arguments for operator ${operatorName}`, operatorName, {
args,
type: typeof args
});
}
try {
const result = operator(args, context, this.evaluateExpr.bind(this), depth);
if (!result) {
return {
success: false,
operator: operatorName,
details: {
args,
context
}
};
}
} catch (error) {
throw new RuleEngineError(`Error in operator ${operatorName}: ${error.message}`, operatorName, {
args,
context
}, error);
}
}
return {
success: true
};
}
/**
* Create cache key for expression
* @private
*/
_createExpressionCacheKey(expr, context) {
try {
// Create a stable cache key
const exprStr = JSON.stringify(expr);
const contextId = this._getContextId(context);
return `expr:${exprStr}:ctx:${contextId}`;
} catch (error) {
// eslint-disable-next-line no-console
console.error('error', error);
// Fallback for circular references or other JSON issues
return `fallback:${Date.now()}:${Math.random()}`;
}
}
/**
* Get context identifier for caching
* @private
*/
_getContextId(context) {
if (context && typeof context === 'object') {
// Try explicit identifiers first
if (context._id) {
return String(context._id);
}
if (context.id) {
return String(context.id);
}
// Generate based on structure AND values
const keys = Object.keys(context).sort();
const structureId = `keys:${keys.join(',')}:count:${keys.length}`;
// Create a hash of the actual values for cache differentiation
const valuesHash = this._hashContextValues(context);
return `${structureId}:hash:${valuesHash}`;
}
return 'default';
}
/**
* Create a lightweight hash of context values for cache key differentiation
* Optimized for performance and readability
* @private
*/
_hashContextValues(obj, depth = 0, visited = new WeakSet()) {
// Prevent infinite recursion on circular references
if (depth > 4) {
return 'deep';
}
// Handle primitives
if (obj === null || obj === undefined) {
return 'null';
}
if (typeof obj !== 'object') {
// Truncate very long strings to keep cache keys manageable
const str = String(obj);
return str.length > 50 ? str.substring(0, 47) + '...' : str;
}
// Prevent circular references
if (visited.has(obj)) {
return 'circular';
}
visited.add(obj);
try {
if (Array.isArray(obj)) {
// For arrays, hash first few elements to keep keys reasonable
const elements = obj.slice(0, 5).map(item => this._hashContextValues(item, depth + 1, visited));
const suffix = obj.length > 5 ? `+${obj.length - 5}more` : '';
return `[${elements.join(',')}${suffix}]`;
}
// For objects, create a hash based on sorted key-value pairs
const entries = Object.keys(obj).sort().slice(0, 10) // Limit to first 10 keys for performance
.map(key => {
const value = this._hashContextValues(obj[key], depth + 1, visited);
return `${key}:${value}`;
});
const suffix = Object.keys(obj).length > 10 ? '+more' : '';
return `{${entries.join('|')}${suffix}}`;
} finally {
visited.delete(obj);
}
}
/**
* Create error result object
* @private
*/
_createErrorResult(error, expr, context) {
const ruleError = error instanceof RuleEngineError ? error : new RuleEngineError('Expression evaluation failed', null, {
expr,
context
}, error);
if (this.config.enableDebug) {
// eslint-disable-next-line no-console
console.error('Rule evaluation failed:', ruleError);
}
return {
success: false,
operator: ruleError.operator,
error: ruleError.message,
details: ruleError.context,
timestamp: ruleError.timestamp
};
}
/**
* Update performance metrics
* @private
*/
_updateMetrics(startTime, wasCacheHit) {
// eslint-disable-next-line no-undef
const duration = performance.now() - startTime;
this.metrics.totalTime += duration;
this.metrics.avgTime = this.metrics.totalTime / this.metrics.evaluations;
if (wasCacheHit) {
this.metrics.cacheHits++;
}
}
}
/**
* Type coercion and validation utilities
*/
class TypeUtils {
/**
* Coerce value to number
*/
static coerceToNumber(value, strict = false) {
if (strict) {
return typeof value === 'number' && !isNaN(value) ? value : null;
}
if (value === null || value === undefined || value === '') {
return null;
}
const num = parseFloat(value);
return isNaN(num) ? null : num;
}
/**
* Coerce value to string
*/
static coerceToString(value, strict = false) {
if (strict) {
return typeof value === 'string' ? value : null;
}
if (value === null || value === undefined) {
return null;
}
return String(value);
}
/**
* Coerce value to boolean
*/
static coerceToBoolean(value, strict = false) {
if (strict) {
return typeof value === 'boolean' ? value : null;
}
return Boolean(value);
}
/**
* Check equality with optional strict mode
*/
static isEqual(left, right, strict = false) {
if (strict) {
return left === right;
}
return left == right; // eslint-disable-line eqeqeq
}
/**
* Type checking utilities
*/
static isArray(value) {
return Array.isArray(value);
}
static isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
static isString(value) {
return typeof value === 'string';
}
static isNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
static isBoolean(value) {
return typeof value === 'boolean';
}
static isNull(value) {
return value === null || value === undefined;
}
}
/**
* Base class for all operators
* Provides common functionality and validation
*/
class BaseOperator {
constructor(pathResolver, config) {
this.pathResolver = pathResolver;
this.config = config;
}
/**
* Validate operator arguments with flexible length checking
* @param {Array} args - Operator arguments
* @param {number|Array} expectedLength - Expected number of arguments (or range [min, max])
* @param {string} operatorName - Name of the operator for error messages
*/
validateArgs(args, expectedLength, operatorName) {
if (!Array.isArray(args)) {
throw new OperatorError(`${operatorName} operator requires array arguments`, operatorName, {
args,
type: typeof args
});
}
if (expectedLength !== undefined) {
if (Array.isArray(expectedLength)) {
// Range validation [min, max]
const [min, max] = expectedLength;
if (args.length < min || args.length > max) {
throw new OperatorError(`${operatorName} operator requires ${min}-${max} arguments, got ${args.length}`, operatorName, {
args,
expectedRange: expectedLength,
actualLength: args.length
});
}
} else {
// Exact length validation
if (args.length !== expectedLength) {
throw new OperatorError(`${operatorName} operator requires ${expectedLength} arguments, got ${args.length}`, operatorName, {
args,
expectedLength,
actualLength: args.length
});
}
}
}
}
/**
* Resolve both operands using the appropriate strategy
*/
resolveOperands(context, left, right, strategy = 'literal', defaultValue = undefined) {
if (strategy === 'literal') {
return {
left: this.pathResolver.resolveValueOrLiteral(context, left),
right: this.pathResolver.resolveValueOrLiteral(context, right)
};
} else if (strategy === 'default') {
return {
left: this.pathResolver.resolveValueOrDefault(context, left, defaultValue),
right: this.pathResolver.resolveValueOrDefault(context, right, defaultValue)
};
} else {
throw new OperatorError('Invalid resolution strategy', null, {
strategy
});
}
}
/**
* Check if operation should use strict mode
*/
isStrictMode(options = {}) {
if (typeof options.strict === 'boolean') {
return options.strict;
}
return this.config.strict !== false;
}
/**
* Coerce operands to numbers for numeric operations
*/
coerceToNumbers(left, right, strict, operatorName) {
const leftNum = TypeUtils.coerceToNumber(left, strict);
const rightNum = TypeUtils.coerceToNumber(right, strict);
if (leftNum === null || rightNum === null) {
throw new OperatorError(`${operatorName} operator requires numeric operands`, operatorName, {
left,
right,
leftType: typeof left,
rightType: typeof right,
strict,
leftCoerced: leftNum,
rightCoerced: rightNum
});
}
return {
left: leftNum,
right: rightNum
};
}
}
/**
* Comparison operators (EQ, NEQ)
* Supports dynamic field comparison with both strict and loose modes
*/
class ComparisonOperators extends BaseOperator {
/**
* Register comparison operators with the engine
*/
register(engine) {
const {
EQ,
NEQ
} = OPERATOR_NAMES;
engine.registerOperator(EQ, this.createEqualityOperator(true).bind(this));
engine.registerOperator(NEQ, this.createEqualityOperator(false).bind(this));
}
/**
* Create equality operator (EQ or NEQ)
*/
createEqualityOperator(shouldEqual) {
return (args, context) => {
// Validate arguments - 2 or 3 arguments allowed
this.validateArgs(args, [2, 3], shouldEqual ? 'EQ' : 'NEQ');
const [left, right, options = {}] = args;
const strict = this.isStrictMode(options);
// Resolve both operands with literal fallback for dynamic comparison
const {
left: resolvedLeft,
right: resolvedRight
} = this.resolveOperands(context, left, right, 'literal');
// Perform equality check
const isEqual = TypeUtils.isEqual(resolvedLeft, resolvedRight, strict);
return shouldEqual ? isEqual : !isEqual;
};
}
}
/**
* Numeric comparison operators (GT, GTE, LT, LTE)
* Supports dynamic field comparison with automatic type coercion
*/
class NumericOperators extends BaseOperator {
/**
* Register numeric operators with the engine
*/
register(engine) {
const {
GT,
GTE,
LT,
LTE
} = OPERATOR_NAMES;
engine.registerOperator(GT, this.createNumericOperator('GT').bind(this));
engine.registerOperator(GTE, this.createNumericOperator('GTE').bind(this));
engine.registerOperator(LT, this.createNumericOperator('LT').bind(this));
engine.registerOperator(LTE, this.createNumericOperator('LTE').bind(this));
}
/**
* Create numeric comparison operator
*/
createNumericOperator(type) {
return (args, context) => {
// Validate arguments - 2 or 3 arguments allowed
this.validateArgs(args, [2, 3], type);
const [left, right, options = {}] = args;
const strict = this.isStrictMode(options);
// Resolve both operands with literal fallback for dynamic comparison
const {
left: resolvedLeft,
right: resolvedRight
} = this.resolveOperands(context, left, right, 'literal');
// Coerce to numbers
const {
left: leftNum,
right: rightNum
} = this.coerceToNumbers(resolvedLeft, resolvedRight, strict, type);
// Perform comparison
switch (type) {
case 'GT':
return leftNum > rightNum;
case 'GTE':
return leftNum >= rightNum;
case 'LT':
return leftNum < rightNum;
case 'LTE':
return leftNum <= rightNum;
default:
throw new Error(`Unknown numeric operator: ${type}`);
}
};
}
}
/**
* Logical operators (AND, OR, NOT)
* Handles rule composition and boolean logic
*/
class LogicalOperators extends BaseOperator {
/**
* Register logical operators with the engine
*/
register(engine) {
const {
AND,
OR,
NOT
} = OPERATOR_NAMES;
engine.registerOperator(AND, this.createAndOperator().bind(this));
engine.registerOperator(OR, this.createOrOperator().bind(this));
engine.registerOperator(NOT, this.createNotOperator().bind(this));
}
/**
* Create AND operator
*/
createAndOperator() {
return (args, context, evaluateExpr, depth) => {
if (!Array.isArray(args) || args.length === 0) {
throw new OperatorError('AND operator requires at least one argument', 'AND', {
args
});
}
// All expressions must be true
for (const expr of args) {
const result = evaluateExpr(expr, context, depth + 1);
if (!result.success) {
return false;
}
}
return true;
};
}
/**
* Create OR operator
*/
createOrOperator() {
return (args, context, evaluateExpr, depth) => {
if (!Array.isArray(args) || args.length === 0) {
throw new OperatorError('OR operator requires at least one argument', 'OR', {
args
});
}
// At least one expression must be true
for (const expr of args) {
const result = evaluateExpr(expr, context, depth + 1);
if (result.success) {
return true;
}
}
return false;
};
}
/**
* Create NOT operator
*/
createNotOperator() {
return (args, context, evaluateExpr, depth) => {
this.validateArgs(args, 1, 'NOT');
const [expr] = args;
const result = evaluateExpr(expr, context, depth + 1);
return !result.success;
};
}
}
/**
* String operators (CONTAINS, STARTS_WITH, ENDS_WITH)
* Supports dynamic field comparison with automatic type coercion
*/
class StringOperators extends BaseOperator {
/**
* Register string operators with the engine
*/
register(engine) {
const {
CONTAINS,
STARTS_WITH,
ENDS_WITH
} = OPERATOR_NAMES;
engine.registerOperator(CONTAINS, this.createStringOperator('CONTAINS').bind(this));
engine.registerOperator(STARTS_WITH, this.createStringOperator('STARTS_WITH').bind(this));
engine.registerOperator(ENDS_WITH, this.createStringOperator('ENDS_WITH').bind(this));
}
/**
* Create string operation operator
*/
createStringOperator(type) {
return (args, context) => {
// Validate arguments - 2 or 3 arguments allowed
this.validateArgs(args, [2, 3], type);
const [left, right, options = {}] = args;
const strict = this.isStrictMode(options);
// Resolve both operands with literal fallback for dynamic comparison
const {
left: resolvedLeft,
right: resolvedRight
} = this.resolveOperands(context, left, right, 'literal');
// Coerce to strings
const {
left: leftStr,
right: rightStr
} = this.coerceToStrings(resolvedLeft, resolvedRight, strict, type);
// Perform string operation
switch (type) {
case 'CONTAINS':
return leftStr.includes(rightStr);
case 'STARTS_WITH':
return leftStr.startsWith(rightStr);
case 'ENDS_WITH':
return leftStr.endsWith(rightStr);
default:
throw new Error(`Unknown string operator: ${type}`);
}
};
}
/**
* Coerce operands to strings for string operations
*/
coerceToStrings(left, right, strict, operatorName) {
const leftStr = TypeUtils.coerceToString(left, strict);
const rightStr = TypeUtils.coerceToString(right, strict);
if (leftStr === null || rightStr === null) {
throw new OperatorError(`${operatorName} operator requires string operands`, operatorName, {
left,
right,
leftType: typeof left,
rightType: typeof right,
strict
});
}
return {
left: leftStr,
right: rightStr
};
}
}
/**
* Regex operator with pattern caching for performance
*/
class RegexOperator extends BaseOperator {
constructor(pathResolver, config) {
super(pathResolver, config);
// Cache compiled regex patterns for performance
this.regexCache = new Map();
this.maxCacheSize = config.maxCacheSize || 1000;
}
/**
* Register regex operator with the engine
*/
register(engine) {
const {
REGEX
} = OPERATOR_NAMES;
engine.registerOperator(REGEX, this.createRegexOperator().bind(this));
}
/**
* Create regex operator
*/
createRegexOperator() {
return (args, context) => {
// Validate arguments - can be 2 or 3 args
if (!Array.isArray(args) || args.length < 2 || args.length > 3) {
throw new OperatorError('REGEX operator requires 2 or 3 arguments', 'REGEX', {
args,
actualLength: args.length
});
}
const [left, right, options = {}] = args;
// Resolve both operands
const {
left: resolvedLeft,
right: resolvedRight
} = this.resolveOperands(context, left, right, 'literal');
// Coerce to strings
const strict = this.isStrictMode(options);
const text = TypeUtils.coerceToString(resolvedLeft, strict);
const pattern = TypeUtils.coerceToString(resolvedRight, strict);
if (text === null || pattern === null) {
throw new OperatorError('REGEX operator requires valid text and pattern', 'REGEX', {
text: resolvedLeft,
pattern: resolvedRight
});
}
try {
// Get compiled regex from cache or create new one
const flags = options.flags || '';
const regex = this.getCompiledRegex(pattern, flags);
return regex.test(text);
} catch (error) {
throw new OperatorError(`Invalid regex pattern: ${pattern}`, 'REGEX', {
pattern,
text,
flags: options.flags
}, error);
}
};
}
/**
* Get compiled regex from cache or create new one
*/
getCompiledRegex(pattern, flags = '') {
const cacheKey = `${pattern}:::${flags}`;
// Check cache first
if (this.regexCache.has(cacheKey)) {
return this.regexCache.get(cacheKey);
}
// Create new regex
const regex = new RegExp(pattern, flags);
// Cache with LRU eviction
if (this.regexCache.size >= this.maxCacheSize) {
const firstKey = this.regexCache.keys().next().value;
this.regexCache.delete(firstKey);
}
this.regexCache.set(cacheKey, regex);
return regex;
}
/**
* Clear regex cache
*/
clearCache() {
this.regexCache.clear();
}
/**
* Get cache statistics
*/
getCacheStats() {
return {
size: this.regexCache.size,
maxSize: this.maxCacheSize
};
}
}
/**
* Array operators (IN, NOT_IN)
* Supports dynamic field comparison for both value and array
*/
class ArrayOperators extends BaseOperator {
/**
* Register array operators with the engine
*/
register(engine) {
const {
IN,
NOT_IN
} = OPERATOR_NAMES;
engine.registerOperator(IN, this.createArrayOperator('IN').bind(this));
engine.registerOperator(NOT_IN, this.createArrayOperator('NOT_IN').bind(this));
}
/**
* Create array membership operator
*/
createArrayOperator(type) {
return (args, context) => {
// Validate arguments - 2 or 3 arguments allowed
this.validateArgs(args, [2, 3], type);
const [left, right, options = {}] = args;
const strict = this.isStrictMode(options);
// Resolve both operands - array can also be dynamic
const {
left: resolvedLeft,
right: resolvedRight
} = this.resolveOperands(context, left, right, 'literal');
// Validate that right operand is an array
if (!Array.isArray(resolvedRight)) {
throw new OperatorError(`${type} operator requires array as right operand`, type, {
left: resolvedLeft,
right: resolvedRight,
rightType: typeof resolvedRight,
originalRight: right
});
}
// Check membership
const isInArray = resolvedRight.some(item => TypeUtils.isEqual(item, resolvedLeft, strict));
return type === 'IN' ? isInArray : !isInArray;
};
}
}
/**
* Special operators (BETWEEN, IS_NULL, IS_NOT_NULL)
* Handles range checking and null validation
*/
class SpecialOperators extends BaseOperator {
/**
* Register special operators with the engine
*/
register(engine) {
const {
BETWEEN,
IS_NULL,
IS_NOT_NULL
} = OPERATOR_NAMES;
engine.registerOperator(BETWEEN, this.createBetweenOperator().bind(this));
engine.registerOperator(IS_NULL, this.createNullCheckOperator('IS_NULL').bind(this));
engine.registerOperator(IS_NOT_NULL, this.createNullCheckOperator('IS_NOT_NULL').bind(this));
}
/**
* Create BETWEEN operator for range checking
*/
createBetweenOperator() {
return (args, context) => {
this.validateArgs(args, 2, 'BETWEEN');
const [left, range, options = {}] = args;
const strict = this.isStrictMode(options);
// Resolve value
const resolvedLeft = this.pathResolver.resolveValueOrLiteral(context, left);
// Resolve range - can be dynamic array
const resolvedRange = this.pathResolver.resolveValueOrLiteral(context, range);
// Validate range format
if (!Array.isArray(resolvedRange) || resolvedRange.length !== 2) {
throw new OperatorError('BETWEEN operator requires array of 2 values', 'BETWEEN', {
range: resolvedRange,
originalRange: range,
rangeType: typeof resolvedRange
});
}
const [min, max] = resolvedRange;
// Resolve min and max in case they are also paths
const resolvedMin = this.pathResolver.resolveValueOrLiteral(context, min);
const resolvedMax = this.pathResolver.resolveValueOrLiteral(context, max);
// Coerce all values to numbers
const valueNum = TypeUtils.coerceToNumber(resolvedLeft, strict);
const minNum = TypeUtils.coerceToNumber(resolvedMin, strict);
const maxNum = TypeUtils.coerceToNumber(resolvedMax, strict);
if (valueNum === null || minNum === null || maxNum === null) {
throw new OperatorError('BETWEEN operator requires numeric operands', 'BETWEEN', {
value: resolvedLeft,
min: resolvedMin,
max: resolvedMax,
strict,
valueCoerced: valueNum,
minCoerced: minNum,
maxCoerced: maxNum
});
}
return valueNum >= minNum && valueNum <= maxNum;
};
}
/**
* Create null check operators
*/
createNullCheckOperator(type) {
return (args, context) => {
this.validateArgs(args, 1, type);
const [left] = args;
// First, check if this is even a string (could be a literal value)
if (typeof left !== 'string') {
// If it's not a string, check if the literal value is null
const isNull = left === null || left === undefined;
return type === 'IS_NULL' ? isNull : !isNull;
}
// For string paths, use resolve with PathResolver's NOT_FOUND symbol
const resolved = this.pathResolver.resolve(context, left, this.pathResolver.NOT_FOUND);
// Check if path doesn't exist OR value is null/undefined
const isNull = resolved === this.pathResolver.NOT_FOUND || resolved === null || resolved === undefined;
return type === 'IS_NULL' ? isNull : !isNull;
};
}
}
/**
* Register all built-in operators with the rule engine
*/
function registerBuiltinOperators(engine, pathResolver, config) {
// Register comparison operators
const comparisonOps = new ComparisonOperators(pathResolver, config);
comparisonOps.register(engine);
// Register numeric operators
const numericOps = new NumericOperators(pathResolver, config);
numericOps.register(engine);
// Register logical operators
const logicalOps = new LogicalOperators(pathResolver, config);
logicalOps.register(engine);
// Register string operators
const stringOps = new StringOperators(pathResolver, config);
stringOps.register(engine);
// Register regex operator
const regexOp = new RegexOperator(pathResolver, config);
regexOp.register(engine);
// Register array operators
const arrayOps = new ArrayOperators(pathResolver, config);
arrayOps.register(engine);
// Register special operators
const specialOps = new SpecialOperators(pathResolver, config);
specialOps.register(engine);
}
/**
* Rule building helpers
* Makes it easy to construct rules without memorizing operator syntax
*/
class RuleHelpers {
constructor() {
this.ops = OPERATOR_NAMES;
// Initialize helper objects in constructor to avoid class field syntax issues
this._initializeFieldHelpers();
this._initializeValidationHelpers();
}
// ============================================================================
// COMPARISON OPERATORS
// ============================================================================
/**
* Equal to
*/
eq(left, right, options) {
// If options is explicitly passed (even if undefined), include it
return arguments.length > 2 ? {
[this.ops.EQ]: [left, right, options || {}]
} : {
[this.ops.EQ]: [left, right]
};
}
/**
* Not equal to
*/
neq(left, right, options) {
return arguments.length > 2 ? {
[this.ops.NEQ]: [left, right, options || {}]
} : {
[this.ops.NEQ]: [left, right]
};
}
/**
* Greater than
*/
gt(left, right, options) {
return arguments.length > 2 ? {
[this.ops.GT]: [left, right, options || {}]
} : {
[this.ops.GT]: [left, right]
};
}
/**
* Greater than or equal
*/
gte(left, right, options) {
return arguments.length > 2 ? {
[this.ops.GTE]: [left, right, options || {}]
} : {
[this.ops.GTE]: [left, right]
};
}
/**
* Less than
*/
lt(left, right, options) {
return arguments.length > 2 ? {
[this.ops.LT]: [left, right, options || {}]
} : {
[this.ops.LT]: [left, right]
};
}
/**
* Less than or equal
*/
lte(left, right, options) {
return arguments.length > 2 ? {
[this.ops.LTE]: [left, right, options || {}]
} : {
[this.ops.LTE]: [left, right]
};
}
// ============================================================================
// LOGICAL OPERATORS
// ============================================================================
/**
* Logical AND - all conditions must be true
*/
and(...expressions) {
return {
[this.ops.AND]: expressions
};
}
/**
* Logical OR - at least one condition must be true
*/
or(...expressions) {
return {
[this.ops.OR]: expressions
};
}
/**
* Logical NOT - negates the expression
*/
not(expression) {
return {
[this.ops.NOT]: [expression]
};
}
// ============================================================================
// STRING OPERATORS
// ============================================================================
/**
* String contains
*/
contains(left, right, options) {
return arguments.length > 2 ? {
[this.ops.CONTAINS]: [left, right, options || {}]
} : {
[this.ops.CONTAINS]: [left, right]
};
}
/**
* String starts with
*/
startsWith(left, right, options) {
return arguments.length > 2 ? {
[this.ops.STARTS_WITH]: [left, right, options || {}]
} : {
[this.ops.STARTS_WITH]: [left, right]
};
}
/**
* String ends with
*/
endsWith(left, right, options) {
return arguments.length > 2 ? {
[this.ops.ENDS_WITH]: [left, right, options || {}]
} : {
[this.ops.ENDS_WITH]: [left, right]
};
}
/**
* Regular expression match
*/
regex(left, right, options) {
return arguments.length > 2 ? {
[this.ops.REGEX]: [left, right, options || {}]
} : {
[this.ops.REGEX]: [left, right]
};
}
// ============================================================================
// ARRAY OPERATORS
// ============================================================================
/**
* Value is in array
*/
in(left, right, options) {
return arguments.length > 2 ? {
[this.ops.IN]: [left, right, options || {}]
} : {
[this.ops.IN]: [left, right]
};
}
/**
* Value is not in array
*/
notIn(left, right, options) {
return arguments.length > 2 ? {
[this.ops.NOT_IN]: [left, right, options || {}]
} : {
[this.ops.NOT_IN]: [left, right]
};
}
// ============================================================================
// SPECIAL OPERATORS
// ============================================================================
/**
* Value is between min and max (inclusive)
*/
between(value, range, options) {
return arguments.length > 2 ? {
[this.ops.BETWEEN]: [value, range, options || {}]
} : {
[this.ops.BETWEEN]: [value, range]
};
}
/**
* Value is null or undefined
*/
isNull(path) {
return {
[this.ops.IS_NULL]: [path]
};
}
/**
* Value is not null and not undefined
*/
isNotNull(path) {
return {
[this.ops.IS_NOT_NULL]: [path]
};
}
// ============================================================================
// CONVENIENCE METHODS
// ============================================================================
/**
* Field equals true
*/
isTrue(path) {
return this.eq(path, true);
}
/**
* Field equals false
*/
isFalse(path) {
return this.eq(path, false);
}
/**
* Field equals empty string
*/
isEmpty(path) {
return this.eq(path, '');
}
/**
* Field does not equal empty string
*/
isNotEmpty(path) {
return this.neq(path, '');
}
/**
* Field has any truthy value (exists and is not empty/false)
*/
exists(path) {
return this.and(this.isNotNull(path), this.neq(path, ''), this.neq(path, false));
}
// ============================================================================
// DYNAMIC FIELD COMPARISON - Initialize in constructor
// ============================================================================
/**
* Initialize field comparison helpers
*/
_initializeFieldHelpers() {