UNPKG

@vqp/evaluation-jsonlogic

Version:

JSONLogic evaluation adapter for VQP

474 lines 16.8 kB
/** * JSONLogic Evaluation Adapter * Implements QueryEvaluationPort using optimized JSONLogic engine */ /** * Optimized JSONLogic Implementation * Based on performance testing, this provides significant improvements over standard json-logic-js */ class OptimizedJSONLogic { logicCache = new Map(); varCache = new Map(); pathCache = new Map(); maxCacheSize; constructor(maxCacheSize = 1000) { this.maxCacheSize = maxCacheSize; } /** * Main evaluation function with optimizations */ apply(logic, data = {}) { if (logic === null || logic === undefined) return logic; if (typeof logic !== 'object') return logic; if (Array.isArray(logic)) return logic.map((item) => this.apply(item, data)); // Cache key for repeated evaluations const cacheKey = JSON.stringify(logic); if (this.logicCache.has(cacheKey)) { const cachedLogic = this.logicCache.get(cacheKey); return this.evaluateWithData(cachedLogic, data); } // Cache management if (this.logicCache.size >= this.maxCacheSize) { const firstKey = this.logicCache.keys().next().value; if (firstKey !== undefined) { this.logicCache.delete(firstKey); } } this.logicCache.set(cacheKey, logic); return this.evaluateWithData(logic, data); } evaluateWithData(logic, data) { const keys = Object.keys(logic); if (keys.length === 0) return logic; const operator = keys[0]; if (!operator) return logic; const args = logic[operator]; switch (operator) { case 'var': return this.getVariable(args, data); case '==': return this.strictEqual(args, data); case '!=': return !this.strictEqual(args, data); case '>': return this.compare(args, data, (a, b) => a > b); case '>=': return this.compare(args, data, (a, b) => a >= b); case '<': return this.compare(args, data, (a, b) => a < b); case '<=': return this.compare(args, data, (a, b) => a <= b); case 'and': return this.evaluateAnd(args, data); case 'or': return this.evaluateOr(args, data); case 'not': return !this.isTruthy(this.apply(args[0], data)); case '!': return !this.isTruthy(this.apply(args[0], data)); case '+': return this.arithmetic(args, data, (a, b) => a + b); case '-': return this.arithmetic(args, data, (a, b) => a - b); case '*': return this.arithmetic(args, data, (a, b) => a * b); case '/': return this.divide(args, data); case 'in': return this.evaluateIn(args, data); case 'if': return this.evaluateIf(args, data); case 'filter': return this.evaluateFilter(args, data); case 'map': return this.evaluateMap(args, data); case 'reduce': return this.evaluateReduce(args, data); case 'some': return this.evaluateSome(args, data); case 'all': return this.evaluateAll(args, data); default: throw new Error(`Unknown operator: ${operator}`); } } getVariable(path, data) { if (path === null || path === undefined || path === '') return data; const pathStr = Array.isArray(path) ? path.join('.') : String(path); // Ultra-fast path for simple property access if (!pathStr.includes('.')) { return data[pathStr]; } // FIRST: Check if the literal property name exists (for keys with dots) // This handles cases like {"profile.level": "senior"} where the key literally contains a dot if (data && Object.prototype.hasOwnProperty.call(data, pathStr)) { return data[pathStr]; } // SECOND: If literal property doesn't exist, try nested path navigation // This handles cases like {profile: {level: "senior"}} accessed as "profile.level" // Check variable cache const cacheKey = `${pathStr}:${typeof data}`; if (this.varCache.has(cacheKey)) { const cachedResult = this.varCache.get(cacheKey); return this.extractFromCachedPath(data, cachedResult.path); } // Parse and cache path let pathSegments; if (this.pathCache.has(pathStr)) { pathSegments = this.pathCache.get(pathStr); } else { pathSegments = pathStr.split('.'); if (this.pathCache.size >= this.maxCacheSize) { const firstKey = this.pathCache.keys().next().value; if (firstKey !== undefined) { this.pathCache.delete(firstKey); } } this.pathCache.set(pathStr, pathSegments); } // Navigate path let current = data; for (const segment of pathSegments) { if (current === null || current === undefined) return null; current = current[segment]; } // Cache result if (this.varCache.size >= this.maxCacheSize) { const firstKey = this.varCache.keys().next().value; if (firstKey !== undefined) { this.varCache.delete(firstKey); } } this.varCache.set(cacheKey, { path: pathSegments, value: current }); return current; } extractFromCachedPath(data, pathSegments) { let current = data; for (const segment of pathSegments) { if (current === null || current === undefined) return null; current = current[segment]; } return current; } strictEqual(args, data) { if (args.length !== 2) throw new Error('== requires exactly 2 arguments'); const left = this.apply(args[0], data); const right = this.apply(args[1], data); // Fast path for common types if (typeof left === typeof right) { return left === right; } // Type coercion for numbers and strings if ((typeof left === 'number' && typeof right === 'string') || (typeof left === 'string' && typeof right === 'number')) { return Number(left) === Number(right); } return left == right; // Intentional == for type coercion } compare(args, data, compareFn) { if (args.length !== 2) throw new Error('Comparison requires exactly 2 arguments'); const left = this.apply(args[0], data); const right = this.apply(args[1], data); if (left === null || left === undefined || right === null || right === undefined) { return false; } return compareFn(Number(left), Number(right)); } evaluateAnd(args, data) { for (const arg of args) { if (!this.isTruthy(this.apply(arg, data))) { return false; } } return true; } evaluateOr(args, data) { for (const arg of args) { if (this.isTruthy(this.apply(arg, data))) { return true; } } return false; } arithmetic(args, data, operation) { if (args.length < 2) throw new Error('Arithmetic operations require at least 2 arguments'); let result = Number(this.apply(args[0], data)); for (let i = 1; i < args.length; i++) { result = operation(result, Number(this.apply(args[i], data))); } return result; } divide(args, data) { if (args.length !== 2) throw new Error('Division requires exactly 2 arguments'); const left = Number(this.apply(args[0], data)); const right = Number(this.apply(args[1], data)); if (right === 0) { throw new Error('Division by zero'); } return left / right; } evaluateIn(args, data) { if (args.length !== 2) throw new Error('in requires exactly 2 arguments'); const needle = this.apply(args[0], data); const haystack = this.apply(args[1], data); if (!Array.isArray(haystack)) return false; // Optimized search for small arrays if (haystack.length < 50) { return haystack.includes(needle); } // Use Set for larger arrays const haystackSet = new Set(haystack); return haystackSet.has(needle); } evaluateIf(args, data) { if (args.length < 2) throw new Error('if requires at least 2 arguments'); for (let i = 0; i < args.length - 1; i += 2) { if (this.isTruthy(this.apply(args[i], data))) { return this.apply(args[i + 1], data); } } // Default case (else) if (args.length % 2 === 1) { return this.apply(args[args.length - 1], data); } return null; } evaluateFilter(args, data) { if (args.length !== 2) throw new Error('filter requires exactly 2 arguments'); const array = this.apply(args[0], data); if (!Array.isArray(array)) return []; const condition = args[1]; return array.filter((item) => this.isTruthy(this.apply(condition, item))); } evaluateMap(args, data) { if (args.length !== 2) throw new Error('map requires exactly 2 arguments'); const array = this.apply(args[0], data); if (!Array.isArray(array)) return []; const transform = args[1]; return array.map((item) => this.apply(transform, item)); } evaluateReduce(args, data) { if (args.length < 2 || args.length > 3) throw new Error('reduce requires 2 or 3 arguments'); const array = this.apply(args[0], data); if (!Array.isArray(array)) return null; const operation = args[1]; let accumulator = args.length === 3 ? this.apply(args[2], data) : array.length > 0 ? array[0] : null; const startIndex = args.length === 3 ? 0 : 1; for (let i = startIndex; i < array.length; i++) { const reduceData = { current: array[i], accumulator: accumulator, index: i, }; accumulator = this.apply(operation, reduceData); } return accumulator; } evaluateSome(args, data) { if (args.length !== 2) throw new Error('some requires exactly 2 arguments'); const array = this.apply(args[0], data); if (!Array.isArray(array)) return false; const condition = args[1]; return array.some((item) => this.isTruthy(this.apply(condition, item))); } evaluateAll(args, data) { if (args.length !== 2) throw new Error('all requires exactly 2 arguments'); const array = this.apply(args[0], data); if (!Array.isArray(array)) return true; // Empty array returns true const condition = args[1]; return array.every((item) => this.isTruthy(this.apply(condition, item))); } isTruthy(value) { if (value === null || value === undefined) return false; if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; if (typeof value === 'string') return value.length > 0; if (Array.isArray(value)) return value.length > 0; return true; } /** * Extract variables used in an expression */ uses_data(logic) { const variables = new Set(); const extractVars = (obj) => { if (obj === null || obj === undefined) return; if (Array.isArray(obj)) { obj.forEach(extractVars); return; } if (typeof obj === 'object') { for (const [key, value] of Object.entries(obj)) { if (key === 'var' && typeof value === 'string') { variables.add(value); } else { extractVars(value); } } } }; extractVars(logic); return Array.from(variables); } /** * Get cache statistics */ getCacheStats() { return { logicCache: this.logicCache.size, varCache: this.varCache.size, pathCache: this.pathCache.size, }; } /** * Clear all caches */ clearCache() { this.logicCache.clear(); this.varCache.clear(); this.pathCache.clear(); } } /** * JSONLogic adapter for query evaluation * This adapter provides safe evaluation of JSONLogic expressions using optimized engine */ export class JSONLogicAdapter { config; dangerousOperations = ['eval', 'function', 'constructor', '__proto__', 'prototype']; optimizedLogic; constructor(config = {}) { this.config = { allowCustomOperations: false, securityMode: 'strict', enableCache: true, maxCacheSize: 1000, ...config, }; this.optimizedLogic = new OptimizedJSONLogic(this.config.maxCacheSize || 1000); } /** * Evaluate JSONLogic expression against data */ async evaluate(expression, data = {}) { try { // Validate expression first if (!(await this.isValidExpression(expression))) { throw new Error('Invalid JSONLogic expression'); } // Sanitize in strict mode if (this.config.securityMode === 'strict') { expression = await this.sanitizeExpression(expression); } // Evaluate using optimized engine const result = this.optimizedLogic.apply(expression, data); return result; } catch (error) { throw new Error(`JSONLogic evaluation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Check if expression is valid JSONLogic */ async isValidExpression(expression) { try { if (typeof expression !== 'object' || expression === null) { return false; } // Try to apply with empty data to check for syntax errors this.optimizedLogic.apply(expression, {}); return true; } catch (error) { return false; } } /** * Extract variables used in expression */ async extractVariables(expression) { try { const variables = this.optimizedLogic.uses_data(expression); return Array.isArray(variables) ? variables : []; } catch (error) { throw new Error(`Failed to extract variables: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Sanitize expression for security * Removes potentially dangerous operations */ async sanitizeExpression(expression) { const sanitizeRecursive = (obj) => { if (Array.isArray(obj)) { return obj.map(sanitizeRecursive); } else if (typeof obj === 'object' && obj !== null) { const sanitized = {}; for (const [key, value] of Object.entries(obj)) { // Filter out dangerous operations if (!this.dangerousOperations.includes(key)) { sanitized[key] = sanitizeRecursive(value); } } return sanitized; } return obj; }; return sanitizeRecursive(expression); } /** * Get configuration */ getConfig() { return { ...this.config }; } /** * Get cache statistics (for monitoring) */ getCacheStats() { return this.optimizedLogic.getCacheStats(); } /** * Clear caches (for memory management) */ clearCache() { this.optimizedLogic.clearCache(); } } //# sourceMappingURL=jsonlogic-adapter.js.map