UNPKG

another-json-rules-engine

Version:

Another Json Rules Engine: a flexible rules engine for validating JSON objects in Node.js and browser.

820 lines (740 loc) 29.5 kB
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var objectPath$1 = {exports: {}}; (function (module) { (function (root, factory) { /*istanbul ignore next:cant test*/ { module.exports = factory(); } })(commonjsGlobal, function () { var toStr = Object.prototype.toString; function hasOwnProperty (obj, prop) { if (obj == null) { return false } //to handle objects with null prototypes (too edge case?) return Object.prototype.hasOwnProperty.call(obj, prop) } function isEmpty (value) { if (!value) { return true } if (isArray(value) && value.length === 0) { return true } else if (typeof value !== 'string') { for (var i in value) { if (hasOwnProperty(value, i)) { return false } } return true } return false } function toString (type) { return toStr.call(type) } function isObject (obj) { return typeof obj === 'object' && toString(obj) === '[object Object]' } var isArray = Array.isArray || function (obj) { /*istanbul ignore next:cant test*/ return toStr.call(obj) === '[object Array]' }; function isBoolean (obj) { return typeof obj === 'boolean' || toString(obj) === '[object Boolean]' } function getKey (key) { var intKey = parseInt(key); if (intKey.toString() === key) { return intKey } return key } function factory (options) { options = options || {}; var objectPath = function (obj) { return Object.keys(objectPath).reduce(function (proxy, prop) { if (prop === 'create') { return proxy } /*istanbul ignore else*/ if (typeof objectPath[prop] === 'function') { proxy[prop] = objectPath[prop].bind(objectPath, obj); } return proxy }, {}) }; var hasShallowProperty; if (options.includeInheritedProps) { hasShallowProperty = function () { return true }; } else { hasShallowProperty = function (obj, prop) { return (typeof prop === 'number' && Array.isArray(obj)) || hasOwnProperty(obj, prop) }; } function getShallowProperty (obj, prop) { if (hasShallowProperty(obj, prop)) { return obj[prop] } } var getShallowPropertySafely; if (options.includeInheritedProps) { getShallowPropertySafely = function (obj, currentPath) { if (typeof currentPath !== 'string' && typeof currentPath !== 'number') { currentPath = String(currentPath); } var currentValue = getShallowProperty(obj, currentPath); if (currentPath === '__proto__' || currentPath === 'prototype' || (currentPath === 'constructor' && typeof currentValue === 'function')) { throw new Error('For security reasons, object\'s magic properties cannot be set') } return currentValue }; } else { getShallowPropertySafely = function (obj, currentPath) { return getShallowProperty(obj, currentPath) }; } function set (obj, path, value, doNotReplace) { if (typeof path === 'number') { path = [path]; } if (!path || path.length === 0) { return obj } if (typeof path === 'string') { return set(obj, path.split('.').map(getKey), value, doNotReplace) } var currentPath = path[0]; var currentValue = getShallowPropertySafely(obj, currentPath); if (path.length === 1) { if (currentValue === void 0 || !doNotReplace) { obj[currentPath] = value; } return currentValue } if (currentValue === void 0) { //check if we assume an array if (typeof path[1] === 'number') { obj[currentPath] = []; } else { obj[currentPath] = {}; } } return set(obj[currentPath], path.slice(1), value, doNotReplace) } objectPath.has = function (obj, path) { if (typeof path === 'number') { path = [path]; } else if (typeof path === 'string') { path = path.split('.'); } if (!path || path.length === 0) { return !!obj } for (var i = 0; i < path.length; i++) { var j = getKey(path[i]); if ((typeof j === 'number' && isArray(obj) && j < obj.length) || (options.includeInheritedProps ? (j in Object(obj)) : hasOwnProperty(obj, j))) { obj = obj[j]; } else { return false } } return true }; objectPath.ensureExists = function (obj, path, value) { return set(obj, path, value, true) }; objectPath.set = function (obj, path, value, doNotReplace) { return set(obj, path, value, doNotReplace) }; objectPath.insert = function (obj, path, value, at) { var arr = objectPath.get(obj, path); at = ~~at; if (!isArray(arr)) { arr = []; objectPath.set(obj, path, arr); } arr.splice(at, 0, value); }; objectPath.empty = function (obj, path) { if (isEmpty(path)) { return void 0 } if (obj == null) { return void 0 } var value, i; if (!(value = objectPath.get(obj, path))) { return void 0 } if (typeof value === 'string') { return objectPath.set(obj, path, '') } else if (isBoolean(value)) { return objectPath.set(obj, path, false) } else if (typeof value === 'number') { return objectPath.set(obj, path, 0) } else if (isArray(value)) { value.length = 0; } else if (isObject(value)) { for (i in value) { if (hasShallowProperty(value, i)) { delete value[i]; } } } else { return objectPath.set(obj, path, null) } }; objectPath.push = function (obj, path /*, values */) { var arr = objectPath.get(obj, path); if (!isArray(arr)) { arr = []; objectPath.set(obj, path, arr); } arr.push.apply(arr, Array.prototype.slice.call(arguments, 2)); }; objectPath.coalesce = function (obj, paths, defaultValue) { var value; for (var i = 0, len = paths.length; i < len; i++) { if ((value = objectPath.get(obj, paths[i])) !== void 0) { return value } } return defaultValue }; objectPath.get = function (obj, path, defaultValue) { if (typeof path === 'number') { path = [path]; } if (!path || path.length === 0) { return obj } if (obj == null) { return defaultValue } if (typeof path === 'string') { return objectPath.get(obj, path.split('.'), defaultValue) } var currentPath = getKey(path[0]); var nextObj = getShallowPropertySafely(obj, currentPath); if (nextObj === void 0) { return defaultValue } if (path.length === 1) { return nextObj } return objectPath.get(obj[currentPath], path.slice(1), defaultValue) }; objectPath.del = function del (obj, path) { if (typeof path === 'number') { path = [path]; } if (obj == null) { return obj } if (isEmpty(path)) { return obj } if (typeof path === 'string') { return objectPath.del(obj, path.split('.')) } var currentPath = getKey(path[0]); getShallowPropertySafely(obj, currentPath); if (!hasShallowProperty(obj, currentPath)) { return obj } if (path.length === 1) { if (isArray(obj)) { obj.splice(currentPath, 1); } else { delete obj[currentPath]; } } else { return objectPath.del(obj[currentPath], path.slice(1)) } return obj }; return objectPath } var mod = factory(); mod.create = factory; mod.withInheritedProps = factory({includeInheritedProps: true}); return mod }); } (objectPath$1)); var objectPathExports = objectPath$1.exports; var objectPath = /*@__PURE__*/getDefaultExportFromCjs(objectPathExports); /* eslint-disable max-len */ /** * Validates a set of rules against a JSON document. * Now supports arrays referenced in both 'ref' and 'comparisonRef', * generating contexts for all possible combinations (cartesian product) * when necessary. * * Example with arrays in both fields: * ref: 'clients[].age', comparisonRef: 'references[].minAge' * => All combinations of clients and references will be evaluated. * * Example with only one array field: * ref: 'clients[].age', comparisonRef: 'minAge' * => Each client will be evaluated against the fixed minAge value. * * @param {object} documentJson The JSON document to validate. * @param {Array<object>} rules The array of rules to validate. * @param {object|null} contextObj Optional context object. If provided, rules can reference it using '_context.' in their paths. * @param {object} options Optional settings: { contextLimit: number (default 10000), timeLimit: number (seconds, default 200), returnAllContexts: boolean (default true) } * @return {Array<object>} An array of objects containing the IDs and types of the rules that passed, along with their contexts. */ function validateRules(documentJson, rules, contextObj = null, options = {}) { const rulesObj = JSON.parse(JSON.stringify(rules)); const contextLimit = options.contextLimit !== undefined ? options.contextLimit : 10000; const timeLimit = options.timeLimit !== undefined ? options.timeLimit : 200; // seconds const returnAllContexts = options.returnAllContexts !== undefined ? options.returnAllContexts : true; const results = []; const validRulesByDate = filterRulesByDate(rulesObj); const startTime = Date.now(); validRulesByDate.forEach((rule) => { try { const loops = []; rule.conditionResultContext = []; processConditionsForLoops(rule, loops); const sortedLoops = sortLoopsByPath(loops); if (!validateArrayReferences(sortedLoops, rule)) ; // CONTADOR DE CONTEXTOS E FLAGS DE LIMITE const contextCounter = { count: 0, limitReached: false, timeReached: false }; rule.contexts = explodeContexts(documentJson, rule.conditions, loops, [], contextLimit, startTime, timeLimit, contextCounter); rule.contextLimitReached = contextCounter.limitReached; rule.timeLimitReached = contextCounter.timeReached; if (rule.contexts.length === 0) { rule.conditionsResult = evaluateSimpleConditions(documentJson, rule.conditions, contextObj); } else { rule.conditionResultContext = evaluateConditionsInContexts(documentJson, rule, loops, contextObj, returnAllContexts); } addRuleToResults(rule, results); // Adiciona aviso se limite atingido if (rule.contextLimitReached || rule.timeLimitReached) { results.push({ id: rule.id, type: rule.type, message: rule.description, keyword: 'context_limit', errors: [ rule.contextLimitReached ? { cause: 'Context limit reached', context: `The number of contexts exceeded the limit (${contextLimit}).` } : null, rule.timeLimitReached ? { cause: 'Time limit reached', context: `The time limit of ${timeLimit} seconds was exceeded during context generation.` } : null, ].filter(Boolean), }); } } catch (error) { logError(`Error processing rule [${rule.id}]`, error); results.push({ id: rule.id, type: rule.type, message: rule.description, conditions: rule.conditionsResult?.items || [], keyword: 'conditional', errors: [ { cause: error.message, context: `Error occurred while processing rule [${rule.id}]`, }, ], }); } }); return results; } /** * Filters rules based on their validity dates. * @param {Array<object>} rules The array of rules. * @return {Array<object>} The filtered rules. */ function filterRulesByDate(rules) { return rules.filter((rule) => { const isAfterStartDate = rule.initialDate ? new Date(rule.initialDate) <= new Date() : true; const isBeforeEndDate = rule.endDate ? new Date(rule.endDate) >= new Date() : true; return isAfterStartDate && isBeforeEndDate; }); } /** * Identifies and expands loops (arrays) in a rule's conditions. * Now analyzes both the 'ref' and 'comparisonRef' fields, * ensuring all referenced arrays are considered in context generation. * * Example: If 'ref' references 'clients[]' and 'comparisonRef' references 'references[]', * both will be considered and the cartesian product of indices will be generated. * * @param {object} rule The rule being processed. * @param {Array<object>} loops The array where identified loops will be stored. */ function processConditionsForLoops(rule, loops) { rule.conditions.forEach((condition, conditionIndex) => { // Lista de campos a analisar: ref e comparisonRef ['ref', 'comparisonRef'].forEach((field) => { if (condition[field]) { let refValue = condition[field]; while (refValue.includes('[]')) { const {objectName, completeObjectPath} = extractLoopDetails(refValue); // Evita duplicidade de loops if (!loops.some(loop => loop.completeObjectPath === completeObjectPath)) { loops.push({objectName, completeObjectPath, parm: `@${loops.length}`}); } // Atualiza todos os campos ref e comparisonRef das condições rule.conditions.forEach((innerCondition) => { ['ref', 'comparisonRef'].forEach((innerField) => { if (innerCondition[innerField] && innerCondition[innerField].includes(`${objectName}[]`)) { innerCondition[innerField] = innerCondition[innerField].replaceAll( `${objectName}[]`, `${objectName}[@${loops.length - 1}]`, ); } }); }); // Atualiza o valor para continuar o while, se houver mais de um [] refValue = refValue.replace(`${objectName}[]`, `${objectName}[@${loops.length - 1}]`); } } }); }); } /** * Extracts details of a loop from a condition value. * @param {string} value The condition value. * @return {object} The extracted loop details. */ function extractLoopDetails(value) { const completeObjectPath = value.split('[]')[0]; const lastDotIndex = completeObjectPath.lastIndexOf('.'); const objectName = completeObjectPath.slice(lastDotIndex + 1); return {objectName, completeObjectPath}; } /** * Sorts loops by their complete object paths. * @param {Array<object>} loops The array of loops. * @return {Array<object>} The sorted loops. */ function sortLoopsByPath(loops) { return loops.sort((a, b) => a.completeObjectPath.localeCompare(b.completeObjectPath)); } /** * Validates array references in loops. * Now allows any combination of arrays (cartesian product), * only issuing a warning if there are arrays with the same name but different paths. * Never blocks the rule. * * @param {Array<object>} sortedLoops The sorted loops. * @param {object} rule The rule being validated. * @return {boolean} Always returns true. */ function validateArrayReferences(sortedLoops, rule) { // Permitir qualquer combinação de arrays (produto cartesiano) // Apenas emitir aviso se houver caminhos de array com o mesmo nome mas caminhos diferentes const paths = sortedLoops.map(l => l.completeObjectPath); const names = sortedLoops.map(l => l.objectName); const duplicates = names.filter((name, idx) => names.indexOf(name) !== idx); if (duplicates.length > 0) { console.warn( `Warning: Rule [${rule.id}] references multiple arrays with the same name (${duplicates.join(', ')}), but different paths: ${paths.join(', ')}` ); } return true; } /** * Generates all possible contexts of a JSON document based on conditions and loops. * Now always returns an array of contexts, even when there are no loops (returns [ [] ]). * This ensures rules with only one array field or different arrays are evaluated correctly. * * Example: * loops: [clients[], references[]] => contexts: [ [0,0], [0,1], [1,0], [1,1] ] * * @param {object} documentJson The JSON document to explode. * @param {Array<object>} conditions The conditions to apply. * @param {Array<object>} existingLoops The identified loops. * @param {Array<number>} currentTuple The current tuple of indices (for recursion). * @param {number} contextLimit The maximum number of contexts allowed. * @param {number} startTime The start time of the operation. * @param {number} timeLimit The maximum time allowed for the operation. * @param {object} contextCounter An object to track context count and limits. * @return {Array<Array<number>>} Array of contexts (each context is an array of indices). */ function explodeContexts(documentJson, conditions, existingLoops, currentTuple, contextLimit = 10000, startTime = Date.now(), timeLimit = 200, contextCounter = { count: 0, limitReached: false, timeReached: false }) { const loops = [...existingLoops]; const totalTuples = []; if (loops.length === 0) { contextCounter.count++; if (contextCounter.count > contextLimit) { contextCounter.limitReached = true; return []; } if ((Date.now() - startTime) / 1000 > timeLimit) { contextCounter.timeReached = true; return []; } return [currentTuple]; } const currentLoop = JSON.parse(JSON.stringify(loops.shift())); const loopItemsCount = objectPath.get(documentJson, currentLoop.completeObjectPath) ?? []; for (let i = 0; i < loopItemsCount.length; i++) { if (contextCounter.limitReached || contextCounter.timeReached) break; if (loops.length > 0) { const newLoops = loops.map((item) => { return { objectName: item.objectName, completeObjectPath: item.completeObjectPath.replace( `${currentLoop.completeObjectPath}[${currentLoop.parm}]`, `${currentLoop.completeObjectPath}.${i}`, ), }; }); explodeContexts(documentJson, conditions, [...newLoops], [...currentTuple, i], contextLimit, startTime, timeLimit, contextCounter).forEach((tuple) => { if (!contextCounter.limitReached && !contextCounter.timeReached) { totalTuples.push(tuple); } }); } else { contextCounter.count++; if (contextCounter.count > contextLimit) { contextCounter.limitReached = true; break; } if ((Date.now() - startTime) / 1000 > timeLimit) { contextCounter.timeReached = true; break; } totalTuples.push([...currentTuple, i]); } } return totalTuples; } /** * Evaluates simple conditions without contexts. * @param {object} documentJson The JSON document. * @param {Array<object>} conditions The conditions to evaluate. * @param {object|null} contextObj Optional context object. If provided, rules can reference it using '_context.' in their ref or comparisonRef paths. * @return {object} The result of the evaluation. */ function evaluateSimpleConditions(documentJson, conditions, contextObj = null) { return conditions.reduce( (result, condition) => { if (result.response) { try { // Suporte a comparisonRef if (condition.comparisonValue !== undefined && condition.comparisonRef !== undefined) { throw new Error('A condition cannot have both comparisonValue and comparisonRef.'); } const leftValue = getValueWithContext(documentJson, condition.ref, contextObj); let rightValue; if (condition.comparisonRef !== undefined) { rightValue = getValueWithContext(documentJson, condition.comparisonRef, contextObj); } else { rightValue = condition.comparisonValue; } const test = testCondition(leftValue, rightValue, condition.operator); if (test) { result.items.push({ instancePath: condition.ref, instancePathValue: leftValue, operator: condition.operator, comparisonValue: rightValue, }); } return {response: test, items: result.items}; } catch (error) { logError('Error in evaluateSimpleConditions', error); throw new Error(`Failed to evaluate conditions in one context.`); } } return {response: false, items: result.items}; }, {response: true, items: []}, ); } /** * Evaluates conditions in multiple contexts. * @param {object} documentJson The JSON document. * @param {object} rule The rule being evaluated. * @param {Array<object>} loops The loops to consider. * @param {object|null} contextObj Optional context object. If provided, rules can reference it using '_context.' in their ref or comparisonRef paths. * @param {boolean} returnAllContexts Whether to return all contexts or stop at the first valid one. * @return {Array<object>} The results of the evaluation for each context. */ function evaluateConditionsInContexts(documentJson, rule, loops, contextObj = null, returnAllContexts = true) { const results = []; for (const context of rule.contexts) { const conditionsInContext = rule.conditions.map((condition) => replaceContextValues(condition, loops, context), ); const evaluation = conditionsInContext.reduce( (prevItem, condition) => { if (prevItem.result) { try { if (condition.comparisonValue !== undefined && condition.comparisonRef !== undefined) { throw new Error('A condition cannot have both comparisonValue and comparisonRef.'); } const leftValue = getValueWithContext(documentJson, condition.ref, contextObj); let rightValue; if (condition.comparisonRef !== undefined) { rightValue = getValueWithContext(documentJson, condition.comparisonRef, contextObj); } else { rightValue = condition.comparisonValue; } const test = testCondition(leftValue, rightValue, condition.operator); if (test) { prevItem.conditionValues.push({ instancePath: condition.ref, instancePathValue: leftValue, operator: condition.operator, comparisonValue: rightValue, }); return {result: true, conditionValues: prevItem.conditionValues}; } return {result: false, conditionValues: prevItem.conditionValues}; } catch (error) { logError('Error in evaluateConditionsInContexts', error); throw new Error(`Failed to evaluate conditions in one context in rule [${rule.id}].`); } } return {result: false, conditionValues: prevItem.conditionValues}; }, {result: true, conditionValues: []}, ); if (evaluation.result) { results.push(evaluation); if (!returnAllContexts) { return results; } } } return results; } /** * Tests a condition against two values. * @param {any} leftValue The left-hand side value of the condition. * @param {any} rightValue The right-hand side value of the condition. * @param {string} operator The operator to use for the comparison. * @return {boolean} True if the condition is met, false otherwise. */ function testCondition(leftValue, rightValue, operator) { try { if (operator === 'exists') return typeof leftValue !== 'undefined'; if (operator === 'does_not_exists') return typeof leftValue === 'undefined'; if (typeof leftValue === 'undefined') return false; // Avoids error when leftValue is undefined if (operator === 'is_empty') return leftValue.length === 0; if (operator === 'is_not_empty') return leftValue.length > 0; if (typeof leftValue === 'string') leftValue = leftValue.toLowerCase(); if (typeof rightValue === 'string') rightValue = rightValue.toLowerCase(); if (operator === '=') return leftValue === rightValue; if (operator === '<>') return leftValue !== rightValue; if (operator === '<') return leftValue < rightValue; if (operator === '<=') return leftValue <= rightValue; if (operator === '>') return leftValue > rightValue; if (operator === '>=') return leftValue >= rightValue; if (operator === 'contains') return leftValue.includes(rightValue); if (operator === 'does_not_contains') return !leftValue.includes(rightValue); if (operator === 'is_contained') return rightValue.includes(leftValue); if (operator === 'in') return rightValue.includes(leftValue); if (operator === 'not_in') return !rightValue.includes(leftValue); throw new Error(`Unsupported operator: ${operator}`); } catch (error) { logError(`Error in testCondition with operator [${operator}]`, error); throw new Error(`Failed to test condition with operator [${operator}].`); } } /** * Replaces context indices in both 'ref' and 'comparisonRef'. * This ensures that, for each context, the paths are correct for lookup in the JSON. * * Example: * ref: 'clients[@0].age', comparisonRef: 'references[@1].minAge', context: [1,0] * => ref: 'clients.1.age', comparisonRef: 'references.0.minAge' * * @param {object} condition The condition to be adjusted. * @param {Array<object>} loops The considered loops. * @param {Array<number>} context The context of indices. * @return {object} The condition with adjusted paths. */ function replaceContextValues(condition, loops, context) { let adjustedRef = condition.ref; let adjustedComparisonRef = condition.comparisonRef; loops.forEach((loop, loopIndex) => { adjustedRef = adjustedRef?.replaceAll(`[@${loopIndex}]`, `.${context[loopIndex]}`); if (adjustedComparisonRef) { adjustedComparisonRef = adjustedComparisonRef.replaceAll(`[@${loopIndex}]`, `.${context[loopIndex]}`); } }); return { ref: adjustedRef, operator: condition.operator, comparisonValue: condition.comparisonValue, comparisonRef: adjustedComparisonRef, }; } /** * Adds a rule to the results if its conditions are met. * If an error occurs, it should be handled gracefully. * @param {object} rule The rule being processed. * @param {Array<object>} results The results array. */ function addRuleToResults(rule, results) { try { if (rule.conditionResultContext && rule.conditionResultContext.length > 0) { results.push({ id: rule.id, type: rule.type, message: rule.description, conditions: rule.conditionResultContext, keyword: 'conditional', }); } else if (rule.conditionsResult && rule.conditionsResult.response && rule.conditionsResult.items && rule.conditionsResult.items.length > 0) { results.push({ id: rule.id, type: rule.type, message: rule.description, conditions: {result: true, conditionValues: rule.conditionsResult.items}, keyword: 'conditional', }); } } catch (error) { logError(`Error adding rule [${rule.id}] to results`, error); results.push({ id: rule.id, type: rule.type, message: rule.description, conditions: [], keyword: 'conditional', errors: [ { cause: error.message, context: `Error occurred while adding rule [${rule.id}] to results`, }, ], }); } } /** * Logs an error with a detailed message. * @param {string} message The error message. * @param {Error} error The error object. */ function logError(message, error) { console.error(`[ERROR] ${message}:`, { message: error.message, stack: error.stack, }); } // Nova função utilitária para buscar valores considerando contextObj e _context prefix function getValueWithContext(documentJson, path, contextObj) { if (typeof path === 'string' && path.startsWith('_context.') && contextObj) { return objectPath.get(contextObj, path.replace('_context.', '')); } return objectPath.get(documentJson, path); } export { validateRules }; //# sourceMappingURL=index.js.map