UNPKG

rule-engine-js

Version:

A high-performance, secure rule engine with dynamic field comparison support

1,721 lines (1,557 loc) 53.5 kB
/** * 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() {