Squashed 'SpiffWorkflow/' changes from 46f410a28..46d3de27f

46d3de27f Merge pull request #267 from sartography/feature/dmn_collect_policy
2d5ca32d5 Support for the "COLLECT" hit policy. * DecisionTable constructor now expects a third argument (the HitPolicy) * DMNParser now checks for a hitPolicy attribute, but defaults ot "UNIQUE" as Camunda doesn't put another in there if Unique is selected. * DecisionTable deserializer will default to a hitPolicy of "UNIQUE" if not value is in the Json.

git-subtree-dir: SpiffWorkflow
git-subtree-split: 46d3de27ffbcaf60025f09d1cf04fcc7ee98658a
This commit is contained in:
Dan 2022-11-25 11:07:31 -05:00
parent d2af3e7252
commit cc1903387b
10 changed files with 236 additions and 16 deletions

View File

@ -177,7 +177,7 @@ class BPMNDictionarySerializer(DictionarySerializer):
return s_state return s_state
def deserialize_business_rule_task(self, wf_spec, s_state): def deserialize_business_rule_task(self, wf_spec, s_state):
dt = DecisionTable(None,None) dt = DecisionTable(None, None, None)
dt.deserialize(s_state['dmn']) dt.deserialize(s_state['dmn'])
dmn_engine = DMNEngine(dt) dmn_engine = DMNEngine(dt)
spec = BusinessRuleTask(wf_spec, s_state['name'], dmn_engine) spec = BusinessRuleTask(wf_spec, s_state['name'], dmn_engine)
@ -224,7 +224,7 @@ class BPMNDictionarySerializer(DictionarySerializer):
if s_state.get('expanded',None): if s_state.get('expanded',None):
cls.expanded = self.deserialize_arg(s_state['expanded']) cls.expanded = self.deserialize_arg(s_state['expanded'])
if isinstance(cls,BusinessRuleTask): if isinstance(cls,BusinessRuleTask):
dt = DecisionTable(None,None) dt = DecisionTable(None,None,None)
dt.deserialize(s_state['dmn']) dt.deserialize(s_state['dmn'])
dmn_engine = DMNEngine(dt) dmn_engine = DMNEngine(dt)
cls.dmnEngine=dmn_engine cls.dmnEngine=dmn_engine

View File

@ -1,6 +1,7 @@
import logging import logging
import re import re
from ..specs.model import HitPolicy
from ...util import levenshtein from ...util import levenshtein
from ...workflow import WorkflowException from ...workflow import WorkflowException
@ -16,9 +17,32 @@ class DMNEngine:
self.decision_table = decision_table self.decision_table = decision_table
def decide(self, task): def decide(self, task):
rules = []
for rule in self.decision_table.rules: for rule in self.decision_table.rules:
if self.__check_rule(rule, task): if self.__check_rule(rule, task):
return rule rules.append(rule)
if self.decision_table.hit_policy == HitPolicy.UNIQUE.value:
return rules
return rules
def result(self, task):
"""Returns the results of running this decision table against
a given task."""
result = {}
matched_rules = self.decide(task)
if len(matched_rules) == 1:
result = matched_rules[0].output_as_dict(task)
elif len(matched_rules) > 1: # This must be a multi-output
# each output will be an array of values, all outputs will
# be placed in a dict, which we will then merge.
for rule in matched_rules:
rule_output = rule.output_as_dict(task)
for key in rule_output.keys():
if not key in result:
result[key] = []
result[key].append(rule_output[key])
return result
def __check_rule(self, rule, task): def __check_rule(self, rule, task):
for input_entry in rule.inputEntries: for input_entry in rule.inputEntries:

View File

@ -93,9 +93,10 @@ class DMNParser(NodeParser):
def _parse_decision_tables(self, decision, decisionElement): def _parse_decision_tables(self, decision, decisionElement):
for decision_table_element in decisionElement.findall('{*}decisionTable'): for decision_table_element in decisionElement.findall('{*}decisionTable'):
name = decision_table_element.attrib.get('name', '')
hitPolicy = decision_table_element.attrib.get('hitPolicy', 'UNIQUE').upper()
decision_table = DecisionTable(decision_table_element.attrib['id'], decision_table = DecisionTable(decision_table_element.attrib['id'],
decision_table_element.attrib.get( name, hitPolicy)
'name', ''))
decision.decisionTables.append(decision_table) decision.decisionTables.append(decision_table)
# parse inputs # parse inputs

View File

@ -1,7 +1,7 @@
from ...bpmn.serializer.bpmn_converters import BpmnTaskSpecConverter from ...bpmn.serializer.bpmn_converters import BpmnTaskSpecConverter
from ..specs.BusinessRuleTask import BusinessRuleTask from ..specs.BusinessRuleTask import BusinessRuleTask
from ..specs.model import DecisionTable, Rule from ..specs.model import DecisionTable, Rule, HitPolicy
from ..specs.model import Input, InputEntry, Output, OutputEntry from ..specs.model import Input, InputEntry, Output, OutputEntry
from ..engine.DMNEngine import DMNEngine from ..engine.DMNEngine import DMNEngine
@ -57,7 +57,8 @@ class BusinessRuleTaskConverter(BpmnTaskSpecConverter):
return self.task_spec_from_dict(dct) return self.task_spec_from_dict(dct)
def decision_table_from_dict(self, dct): def decision_table_from_dict(self, dct):
table = DecisionTable(dct['id'], dct['name']) hit_policy = dct.get('hit_policy', HitPolicy.UNIQUE.value)
table = DecisionTable(dct['id'], dct['name'], hit_policy)
table.inputs = [ Input(**val) for val in dct['inputs'] ] table.inputs = [ Input(**val) for val in dct['inputs'] ]
table.outputs = [ Output(**val) for val in dct['outputs'] ] table.outputs = [ Output(**val) for val in dct['outputs'] ]
table.rules = [ self.rule_from_dict(rule, table.inputs, table.outputs) table.rules = [ self.rule_from_dict(rule, table.inputs, table.outputs)

View File

@ -18,7 +18,6 @@ class BusinessRuleTask(Simple, BpmnSpecMixin):
super().__init__(wf_spec, name, **kwargs) super().__init__(wf_spec, name, **kwargs)
self.dmnEngine = dmnEngine self.dmnEngine = dmnEngine
self.res = None
self.resDict = None self.resDict = None
@property @property
@ -27,10 +26,8 @@ class BusinessRuleTask(Simple, BpmnSpecMixin):
def _on_complete_hook(self, my_task): def _on_complete_hook(self, my_task):
try: try:
self.res = self.dmnEngine.decide(my_task) my_task.data = DeepMerge.merge(my_task.data,
if self.res is not None: # it is conceivable that no rules fire. self.dmnEngine.result(my_task))
self.resDict = self.res.output_as_dict(my_task)
my_task.data = DeepMerge.merge(my_task.data,self.resDict)
super(BusinessRuleTask, self)._on_complete_hook(my_task) super(BusinessRuleTask, self)._on_complete_hook(my_task)
except Exception as e: except Exception as e:
raise WorkflowTaskExecException(my_task, str(e)) raise WorkflowTaskExecException(my_task, str(e))

View File

@ -1,8 +1,24 @@
from collections import OrderedDict from collections import OrderedDict
from enum import Enum
from ...util.deep_merge import DeepMerge from ...util.deep_merge import DeepMerge
class HitPolicy(Enum):
UNIQUE = "UNIQUE"
COLLECT = "COLLECT"
# ANY = "ANY"
# PRIORITY = "PRIORITY"
# FIRST = "FIRST"
# OUTPUT_ORDER = "OUTPUT ORDER"
# RULE_ORDER = "RULE ORDER"
# class Aggregation(Enum):
# SUM = "SUM"
# COUNT = "COUNT"
# MIN = "MIN"
# MAX = "MAX"
class Decision: class Decision:
def __init__(self, id, name): def __init__(self, id, name):
self.id = id self.id = id
@ -11,9 +27,10 @@ class Decision:
self.decisionTables = [] self.decisionTables = []
class DecisionTable: class DecisionTable:
def __init__(self, id, name): def __init__(self, id, name, hit_policy):
self.id = id self.id = id
self.name = name self.name = name
self.hit_policy = hit_policy
self.inputs = [] self.inputs = []
self.outputs = [] self.outputs = []
@ -23,6 +40,7 @@ class DecisionTable:
out = {} out = {}
out['id'] = self.id out['id'] = self.id
out['name'] = self.name out['name'] = self.name
out['hit_policy'] = self.hit_policy
out['inputs'] = [x.serialize() for x in self.inputs] out['inputs'] = [x.serialize() for x in self.inputs]
out['outputs'] = [x.serialize() for x in self.outputs] out['outputs'] = [x.serialize() for x in self.outputs]
out['rules'] = [x.serialize() for x in self.rules] out['rules'] = [x.serialize() for x in self.rules]
@ -31,6 +49,10 @@ class DecisionTable:
def deserialize(self,indict): def deserialize(self,indict):
self.id = indict['id'] self.id = indict['id']
self.name = indict['name'] self.name = indict['name']
if 'hit_policy' in indict:
self.hit_policy = indict['hit_policy']
else:
self.hit_policy = HitPolicy.UNIQUE.value
self.inputs = [Input(**x) for x in indict['inputs']] self.inputs = [Input(**x) for x in indict['inputs']]
list(map(lambda x, y: x.deserialize(y), self.inputs, indict['inputs'])) list(map(lambda x, y: x.deserialize(y), self.inputs, indict['inputs']))
self.outputs = [Output(**x) for x in indict['outputs']] self.outputs = [Output(**x) for x in indict['outputs']]

View File

@ -41,11 +41,17 @@ class DecisionRunner:
'Exactly one decision table should exist! (%s)' \ 'Exactly one decision table should exist! (%s)' \
% (len(decision.decisionTables)) % (len(decision.decisionTables))
self.dmnEngine = DMNEngine(decision.decisionTables[0]) self.decision_table = decision.decisionTables[0]
self.dmnEngine = DMNEngine(self.decision_table)
def decide(self, context): def decide(self, context):
"""Makes the rather ugly assumption that there is only one
rule match for a decision - which was previously the case"""
if not isinstance(context, dict): if not isinstance(context, dict):
context = {'input': context} context = {'input': context}
task = Task(self.script_engine, context) task = Task(self.script_engine, context)
return self.dmnEngine.decide(task) return self.dmnEngine.decide(task)[0]
def result(self, context):
task = Task(self.script_engine, context)
return self.dmnEngine.result(task)

View File

@ -0,0 +1,37 @@
import os
import unittest
from SpiffWorkflow.dmn.engine.DMNEngine import DMNEngine
from SpiffWorkflow.dmn.parser.BpmnDmnParser import BpmnDmnParser
from tests.SpiffWorkflow.bpmn.BpmnWorkflowTestCase import BpmnWorkflowTestCase
from tests.SpiffWorkflow.dmn.DecisionRunner import DecisionRunner
from tests.SpiffWorkflow.dmn.python_engine.PythonDecisionRunner import \
PythonDecisionRunner
class HitPolicyTest(BpmnWorkflowTestCase):
PARSER_CLASS = BpmnDmnParser
def testHitPolicyUnique(self):
file_name = os.path.join(os.path.dirname(__file__), 'data', 'unique_hit.dmn')
runner = PythonDecisionRunner(file_name)
decision_table = runner.decision_table
self.assertEqual('UNIQUE', decision_table.hit_policy)
res = runner.result({'name': 'Larry'})
self.assertEqual(1, res['result'])
def testHitPolicyCollect(self):
file_name = os.path.join(os.path.dirname(__file__), 'data', 'collect_hit.dmn')
runner = PythonDecisionRunner(file_name)
decision_table = runner.decision_table
self.assertEqual('COLLECT', decision_table.hit_policy)
res = runner.result({'type': 'stooge'})
self.assertEqual(4, len(res['name']))
def suite():
return unittest.TestLoader().loadTestsFromTestCase(HitPolicyTest)
if __name__ == '__main__':
unittest.TextTestRunner(verbosity=2).run(suite())

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="https://www.omg.org/spec/DMN/20191111/DMNDI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/" id="Definitions_06veek1" name="DRD" namespace="http://camunda.org/schema/1.0/dmn" exporter="Camunda Modeler" exporterVersion="5.0.0">
<decision id="Decision_ExclusiveAMCheck" name="UniqueHit">
<decisionTable id="decisionTable_1" hitPolicy="COLLECT">
<input id="InputClause_1ley07z" label="Input &#34;type&#34;">
<inputExpression id="LiteralExpression_0yhfcz3" typeRef="string" expressionLanguage="python">
<text>type</text>
</inputExpression>
</input>
<output id="output_1" label="Output &#34;result&#34;" name="result" typeRef="integer" />
<output id="OutputClause_1yrcvgl" label="Output &#34;name&#34;" name="name" typeRef="string" />
<rule id="DecisionRule_07162mr">
<description></description>
<inputEntry id="UnaryTests_079zee3">
<text>"stooge"</text>
</inputEntry>
<outputEntry id="LiteralExpression_16l50ps">
<text>1</text>
</outputEntry>
<outputEntry id="LiteralExpression_1lgpxjl">
<text>"Larry"</text>
</outputEntry>
</rule>
<rule id="DecisionRule_0ifa4wu">
<description></description>
<inputEntry id="UnaryTests_1fj8ed0">
<text>"stooge"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0td8sa6">
<text>2</text>
</outputEntry>
<outputEntry id="LiteralExpression_13y7kcx">
<text>"Mo"</text>
</outputEntry>
</rule>
<rule id="DecisionRule_0x9z2jm">
<inputEntry id="UnaryTests_1nsph49">
<text>"stooge"</text>
</inputEntry>
<outputEntry id="LiteralExpression_04v8i8e">
<text>3</text>
</outputEntry>
<outputEntry id="LiteralExpression_1f70eo7">
<text>"Curly"</text>
</outputEntry>
</rule>
<rule id="DecisionRule_16ur1y7">
<inputEntry id="UnaryTests_0etcimx">
<text>"stooge"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0yc7vs6">
<text>4</text>
</outputEntry>
<outputEntry id="LiteralExpression_1k2kclw">
<text>"Shemp"</text>
</outputEntry>
</rule>
<rule id="DecisionRule_0fzx40q">
<inputEntry id="UnaryTests_1gzu54o">
<text>"farmer"</text>
</inputEntry>
<outputEntry id="LiteralExpression_11h0epz">
<text>5</text>
</outputEntry>
<outputEntry id="LiteralExpression_031adn2">
<text>"Elmer Fudd"</text>
</outputEntry>
</rule>
</decisionTable>
</decision>
<dmndi:DMNDI>
<dmndi:DMNDiagram id="DMNDiagram_1vu8ul5">
<dmndi:DMNShape id="DMNShape_0sl8kie" dmnElementRef="Decision_ExclusiveAMCheck">
<dc:Bounds height="80" width="180" x="150" y="150" />
</dmndi:DMNShape>
</dmndi:DMNDiagram>
</dmndi:DMNDI>
</definitions>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:biodi="http://bpmn.io/schema/dmn/biodi/2.0" xmlns:dmndi="https://www.omg.org/spec/DMN/20191111/DMNDI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/" id="Definitions_06veek1" name="DRD" namespace="http://camunda.org/schema/1.0/dmn" exporter="Camunda Modeler" exporterVersion="5.0.0">
<decision id="Decision_ExclusiveAMCheck" name="UniqueHit">
<decisionTable id="decisionTable_1">
<input id="input_1" label="Input &#34;name&#34;" biodi:width="192">
<inputExpression id="inputExpression_1" typeRef="string" expressionLanguage="python">
<text>name</text>
</inputExpression>
</input>
<output id="output_1" label="Output &#34;result&#34;" name="result" typeRef="integer" />
<rule id="DecisionRule_07162mr">
<description></description>
<inputEntry id="UnaryTests_1jqxc3u">
<text>"Larry"</text>
</inputEntry>
<outputEntry id="LiteralExpression_16l50ps">
<text>1</text>
</outputEntry>
</rule>
<rule id="DecisionRule_0ifa4wu">
<description></description>
<inputEntry id="UnaryTests_0szbwxc">
<text>"Mo"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0td8sa6">
<text>2</text>
</outputEntry>
</rule>
<rule id="DecisionRule_0x9z2jm">
<inputEntry id="UnaryTests_1ul57lx">
<text>"Curly"</text>
</inputEntry>
<outputEntry id="LiteralExpression_04v8i8e">
<text>3</text>
</outputEntry>
</rule>
<rule id="DecisionRule_16ur1y7">
<inputEntry id="UnaryTests_09q92u9">
<text>"Shemp"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0yc7vs6">
<text>4</text>
</outputEntry>
</rule>
</decisionTable>
</decision>
<dmndi:DMNDI>
<dmndi:DMNDiagram id="DMNDiagram_1vu8ul5">
<dmndi:DMNShape id="DMNShape_0sl8kie" dmnElementRef="Decision_ExclusiveAMCheck">
<dc:Bounds height="80" width="180" x="150" y="150" />
</dmndi:DMNShape>
</dmndi:DMNDiagram>
</dmndi:DMNDI>
</definitions>