UNPKG

dmn-processor

Version:

Evaluation of DMN 1.3 decision tables, limited to S-FEEL (Simple Friendly Enough Expression Language)

381 lines (359 loc) 15.5 kB
/* * ©2017-2018 HBT Hamburger Berater Team GmbH * All Rights Reserved. */ const DmnModdle = require('dmn-moddle/dist/index.js'); const logger = require('loglevel').getLogger('dmn-eval-js'); const moment = require('moment'); const feel = require('../../dist/feel'); function createModdle(additionalPackages, options) { return new DmnModdle(additionalPackages, options); } function readDmnXml(xml, opts, callback) { return createModdle().fromXML(xml, 'dmn:Definitions', opts, callback); } function parseRule(rule, idx) { const parsedRule = { number: idx + 1, input: [], inputValues: [], output: [], outputValues: [], }; if (rule.inputEntry) { rule.inputEntry.forEach((inputEntry) => { let { text } = inputEntry; if (text === '') { text = '-'; } try { parsedRule.input.push(feel.parse(text, { startRule: 'SimpleUnaryTests', })); parsedRule.inputValues.push(text); } catch (err) { throw new Error(`Failed to parse input entry: ${text} ${err}`); } }); } rule.outputEntry.forEach((outputEntry) => { if (!outputEntry.text) { parsedRule.output.push(null); parsedRule.outputValues.push(null); } else { try { parsedRule.output.push(feel.parse(outputEntry.text, { startRule: 'SimpleExpressions', })); } catch (err) { throw new Error(`Failed to parse output entry: ${outputEntry.text} ${err}`); } parsedRule.outputValues.push(outputEntry.text); } }); return parsedRule; } function parseDecisionLogic(decisionId, decisionLogic) { if ((decisionLogic.hitPolicy !== 'FIRST') && (decisionLogic.hitPolicy !== 'UNIQUE') && (decisionLogic.hitPolicy !== 'COLLECT') && (decisionLogic.hitPolicy !== 'RULE ORDER')) { throw new Error(`Unsupported hit policy ${decisionLogic.hitPolicy}`); } const parseddecisionLogic = { hitPolicy: decisionLogic.hitPolicy, rules: [], inputExpressions: [], parsedInputExpressions: [], outputNames: [], }; // parse rules (there may be none, though) if (decisionLogic.rule === undefined) { logger.warn(`The decision table for decision '${decisionId}' contains no rules.`); } else { decisionLogic.rule.forEach((rule, idx) => { parseddecisionLogic.rules.push(parseRule(rule, idx)); }); } // parse input expressions if (decisionLogic.input) { decisionLogic.input.forEach((input) => { let inputExpression; if (input.inputExpression && input.inputExpression.text) { inputExpression = input.inputExpression.text; } else if (input.inputVariable) { inputExpression = input.inputVariable; } else { throw new Error(`No input variable or expression set for input '${input.id}'`); } parseddecisionLogic.inputExpressions.push(inputExpression); try { parseddecisionLogic.parsedInputExpressions.push(feel.parse(inputExpression, { startRule: 'SimpleExpressions', })); } catch (err) { throw new Error(`Failed to parse input expression '${inputExpression}': ${err}`); } }); } // parse output names decisionLogic.output.forEach((output) => { if (output.name) { parseddecisionLogic.outputNames.push(output.name); } else { throw new Error(`No name set for output "${output.id}"`); } }); return parseddecisionLogic; } function parseDecisions(drgElements) { const parsedDecisions = []; // iterate over all decisions in the DMN drgElements.forEach((drgElement) => { if (drgElement.decisionLogic) { // parse the decision table... const decision = { decisionLogic: parseDecisionLogic(drgElement.id, drgElement.decisionLogic), requiredDecisions: [] }; // ...and collect the decisions on which the current decision depends if (drgElement.informationRequirement !== undefined) { drgElement.informationRequirement.forEach((req) => { if (req.requiredDecision !== undefined) { const requiredDecisionId = req.requiredDecision.href.replace('#', ''); decision.requiredDecisions.push(requiredDecisionId); } }); } parsedDecisions[drgElement.id] = decision; } }); return parsedDecisions; } function parseDmnXml(xml, opts) { return new Promise((resolve, reject) => { readDmnXml(xml, opts, (err, dmnContent) => { if (err) { reject(err); } else { try { const decisions = parseDecisions(dmnContent.drgElement); resolve(decisions); } catch (err) { reject(err); } } }); }); } function resolveExpression(expression, obj) { const parts = expression.split('.'); return parts.reduce((resolved, part) => (resolved === undefined ? undefined : resolved[part]), obj); } // Sets the given value to a nested property of the given object. The nested property is resolved from the given expression. // If the given nested property does not exist, it is added. If it exists, it is set (overwritten). If it exists and is // an array, the given value is added. // Examples: // setOrAddValue('foo.bar', { }, 10) returns { foo: { bar: 10 } } // setOrAddValue('foo.bar', { foo: { }, 10) returns { foo: { bar: 10 } } // setOrAddValue('foo.bar', { foo: { bar: 9 }, 10) returns { foo: { bar: 10 } } // setOrAddValue('foo.bar', { foo: { bar: [ ] }, 10) returns { foo: { bar: [ 10 ] } } // setOrAddValue('foo.bar', { foo: { bar: [ 9 ] }, 10) returns { foo: { bar: [9, 10 ] } } function setOrAddValue(expression, obj, value) { const indexOfDot = expression.indexOf('.'); if (indexOfDot < 0) { if (obj[expression] && Array.isArray(obj[expression])) { obj[expression].push(value); // eslint-disable-line no-param-reassign } else { obj[expression] = value; // eslint-disable-line no-param-reassign } } else { const first = expression.substr(0, indexOfDot); const remainder = expression.substr(indexOfDot + 1); if (obj[first]) { setOrAddValue(remainder, obj[first], value); } else { obj[first] = setOrAddValue(remainder, {}, value); // eslint-disable-line no-param-reassign } } return obj; } // merge the result of the required decision into the context so that it is available as input for the requested decision function mergeContext(context, additionalContent, aggregate = false) { if (Array.isArray(additionalContent)) { // additional content is the result of evaluation a rule table with multiple rule results additionalContent.forEach((ruleResult) => mergeContext(context, ruleResult, true)); } else { // additional content is the result of evaluation a rule table with a single rule result for (const prop in additionalContent) { // eslint-disable-line no-restricted-syntax if (additionalContent.hasOwnProperty(prop)) { const value = additionalContent[prop]; if (Array.isArray(context[prop])) { if (Array.isArray(value)) { context[prop] = context[prop].concat(value); // eslint-disable-line no-param-reassign } else if (value !== null && value !== undefined) { context[prop].push(value); // eslint-disable-line no-param-reassign } } else if ((typeof value === 'object') && (value !== null) && !moment.isMoment(value) && !moment.isDate(value) && !moment.isDuration(value)) { if ((context[prop] === undefined) || (context[prop] === null)) { context[prop] = {}; // eslint-disable-line no-param-reassign } mergeContext(context[prop], value, aggregate); } else if (aggregate) { context[prop] = []; // eslint-disable-line no-param-reassign context[prop].push(value); // eslint-disable-line no-param-reassign } else { context[prop] = value; // eslint-disable-line no-param-reassign } } } } } async function evaluateRule(rule, resolvedInputExpressions, outputNames, context) { for (let i = 0; i < rule.input.length; i += 1) { try { const { inputVariableName } = resolvedInputExpressions[i]; const inputFunction = await rule.input[i].build({ _inputVariableName: inputVariableName, ...context }); // eslint-disable-line no-await-in-loop if (!inputFunction(resolvedInputExpressions[i].value)) { return { matched: false, }; } } catch (err) { logger.error(err); throw new Error(`Failed to evaluate input condition in column ${i + 1}: '${rule.inputValues[i]}': ${err}`); } } const outputObject = {}; for (let i = 0; i < rule.output.length; i += 1) { if (rule.output[i] !== null) { const outputValue = await rule.output[i].build(context); // eslint-disable-line no-await-in-loop setOrAddValue(outputNames[i], outputObject, outputValue[0]); } else { setOrAddValue(outputNames[i], outputObject, undefined); } } return { matched: true, output: outputObject }; } async function evaluateDecision(decisionId, decisions, context, alreadyEvaluatedDecisions) { if (!alreadyEvaluatedDecisions) { alreadyEvaluatedDecisions = []; // eslint-disable-line no-param-reassign } const decision = decisions[decisionId]; if (decision === undefined) { throw new Error(`No such decision "${decisionId}"`); } // execute required decisions recursively first for (let i = 0; i < decision.requiredDecisions.length; i += 1) { const reqDecision = decision.requiredDecisions[i]; // check if the decision was already executed, to prevent unecessary evaluations if multiple decisions require the same decision if (!alreadyEvaluatedDecisions[reqDecision]) { logger.debug(`Need to evaluate required decision ${reqDecision}`); const requiredResult = await evaluateDecision(reqDecision, decisions, context, alreadyEvaluatedDecisions); // eslint-disable-line no-await-in-loop mergeContext(context, requiredResult); alreadyEvaluatedDecisions[reqDecision] = true; // eslint-disable-line no-param-reassign } } logger.info(`Evaluating decision "${decisionId}"...`); logger.debug(`Context: ${JSON.stringify(context)}`); const { decisionLogic } = decision; // resolve input expressions const resolvedInputExpressions = []; for (let i = 0; i < decisionLogic.parsedInputExpressions.length; i += 1) { const parsedInputExpression = decisionLogic.parsedInputExpressions[i]; const plainInputExpression = decisionLogic.inputExpressions[i]; try { const resolvedInputExpression = await parsedInputExpression.build(context); // eslint-disable-line no-await-in-loop // check if the input expression is to be treated as an input variable - this is the case if it is a qualified name let inputVariableName; if (parsedInputExpression.simpleExpressions && parsedInputExpression.simpleExpressions[0].type === 'QualifiedName') { inputVariableName = parsedInputExpression.simpleExpressions[0].names.map((nameNode) => nameNode.nameChars).join('.'); } resolvedInputExpressions.push({ value: resolvedInputExpression[0], inputVariableName }); } catch (err) { throw new Error(`Failed to evaluate input expression ${plainInputExpression} of decision ${decisionId}: ${err}`); } } // initialize the result to an object with undefined output values (hit policy FIRST or UNIQUE) or to an empty array (hit policy COLLECT or RULE ORDER) const decisionResult = (decisionLogic.hitPolicy === 'FIRST') || (decisionLogic.hitPolicy === 'UNIQUE') ? {} : []; decisionLogic.outputNames.forEach((outputName) => { if ((decisionLogic.hitPolicy === 'FIRST') || (decisionLogic.hitPolicy === 'UNIQUE')) { setOrAddValue(outputName, decisionResult, undefined); } }); // iterate over the rules of the decision table of the requested decision, // and either return the output of the first matching rule (hit policy FIRST) // or collect the output of all matching rules (hit policy COLLECT) let hasMatch = false; for (let i = 0; i < decisionLogic.rules.length; i += 1) { const rule = decisionLogic.rules[i]; let ruleResult; try { ruleResult = await evaluateRule(rule, resolvedInputExpressions, decisionLogic.outputNames, context); // eslint-disable-line no-await-in-loop } catch (err) { throw new Error(`Failed to evaluate rule ${rule.number} of decision ${decisionId}: ${err}`); } if (ruleResult.matched) { // only one match for hit policy UNIQUE! if (hasMatch && (decisionLogic.hitPolicy === 'UNIQUE')) { throw new Error(`Decision "${decisionId}" is not unique but hit policy is UNIQUE.`); } hasMatch = true; logger.info(`Result for decision "${decisionId}": ${JSON.stringify(ruleResult.output)} (rule ${i + 1} matched)`); // merge the result of the matched rule if ((decisionLogic.hitPolicy === 'FIRST') || (decisionLogic.hitPolicy === 'UNIQUE')) { decisionLogic.outputNames.forEach((outputName) => { const resolvedOutput = resolveExpression(outputName, ruleResult.output); if (resolvedOutput !== undefined || decisionLogic.hitPolicy === 'FIRST' || decisionLogic.hitPolicy === 'UNIQUE') { setOrAddValue(outputName, decisionResult, resolvedOutput); } }); if (decisionLogic.hitPolicy === 'FIRST') { // no more rule results in this case break; } } else { decisionResult.push(ruleResult.output); } } } if (!hasMatch && decisionLogic.rules.length > 0) { logger.warn(`No rule matched for decision "${decisionId}".`); } return decisionResult; } function dumpTree(node, indent) { if (!node) { logger.debug('undefined'); return; } if (!indent) { indent = ''; // eslint-disable-line no-param-reassign } logger.debug(indent + node.type); if (node.not) { logger.debug(`${indent} (not)`); } if (node.operator) { logger.debug(`${indent} ${node.operator}`); } const newIndent = `${indent} `; switch (node.type) { case 'ArithmeticExpression': { dumpTree(node.operand_1, newIndent); dumpTree(node.operand_2, newIndent); break; } case 'FunctionInvocation': { dumpTree(node.fnName, newIndent); dumpTree(node.params, newIndent); break; } case 'DateTimeLiteral': node.params.forEach((p) => dumpTree(p, newIndent)); break; case 'Interval': { dumpTree(node.startpoint, newIndent); dumpTree(node.endpoint, newIndent); break; } case 'Literal': logger.debug(`${newIndent}"${node.value}"`); break; case 'Name': logger.debug(`${newIndent}"${node.nameChars}"`); break; case 'Program': dumpTree(node.body, newIndent); break; case 'PositionalParameters': node.params.forEach((p) => dumpTree(p, newIndent)); break; case 'QualifiedName': node.names.forEach((n) => dumpTree(n, newIndent)); break; case 'SimpleExpressions': node.simpleExpressions.forEach((s) => dumpTree(s, newIndent)); break; case 'SimplePositiveUnaryTest': dumpTree(node.operand, newIndent); break; case 'SimpleUnaryTestsNode': node.expr.forEach((e) => dumpTree(e, newIndent)); break; case 'UnaryTestsNode': node.expr.forEach((e) => dumpTree(e, newIndent)); break; default: logger.debug('?'); } } module.exports = { readDmnXml, parseDmnXml, parseDecisions, evaluateDecision, dumpTree, };