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