From cc1903387bf02a9d0ee847e31ce568f759b06a0c Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 25 Nov 2022 11:07:31 -0500 Subject: [PATCH] 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 --- SpiffWorkflow/bpmn/serializer/dict.py | 4 +- SpiffWorkflow/dmn/engine/DMNEngine.py | 26 ++++++- SpiffWorkflow/dmn/parser/DMNParser.py | 5 +- .../dmn/serializer/task_spec_converters.py | 5 +- SpiffWorkflow/dmn/specs/BusinessRuleTask.py | 7 +- SpiffWorkflow/dmn/specs/model.py | 24 +++++- tests/SpiffWorkflow/dmn/DecisionRunner.py | 12 ++- tests/SpiffWorkflow/dmn/HitPolicyTest.py | 37 +++++++++ tests/SpiffWorkflow/dmn/data/collect_hit.dmn | 78 +++++++++++++++++++ tests/SpiffWorkflow/dmn/data/unique_hit.dmn | 54 +++++++++++++ 10 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 tests/SpiffWorkflow/dmn/HitPolicyTest.py create mode 100644 tests/SpiffWorkflow/dmn/data/collect_hit.dmn create mode 100644 tests/SpiffWorkflow/dmn/data/unique_hit.dmn diff --git a/SpiffWorkflow/bpmn/serializer/dict.py b/SpiffWorkflow/bpmn/serializer/dict.py index ca727c97..237b21a1 100644 --- a/SpiffWorkflow/bpmn/serializer/dict.py +++ b/SpiffWorkflow/bpmn/serializer/dict.py @@ -177,7 +177,7 @@ class BPMNDictionarySerializer(DictionarySerializer): return 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']) dmn_engine = DMNEngine(dt) spec = BusinessRuleTask(wf_spec, s_state['name'], dmn_engine) @@ -224,7 +224,7 @@ class BPMNDictionarySerializer(DictionarySerializer): if s_state.get('expanded',None): cls.expanded = self.deserialize_arg(s_state['expanded']) if isinstance(cls,BusinessRuleTask): - dt = DecisionTable(None,None) + dt = DecisionTable(None,None,None) dt.deserialize(s_state['dmn']) dmn_engine = DMNEngine(dt) cls.dmnEngine=dmn_engine diff --git a/SpiffWorkflow/dmn/engine/DMNEngine.py b/SpiffWorkflow/dmn/engine/DMNEngine.py index d2b10532..68ef38bc 100644 --- a/SpiffWorkflow/dmn/engine/DMNEngine.py +++ b/SpiffWorkflow/dmn/engine/DMNEngine.py @@ -1,6 +1,7 @@ import logging import re +from ..specs.model import HitPolicy from ...util import levenshtein from ...workflow import WorkflowException @@ -16,9 +17,32 @@ class DMNEngine: self.decision_table = decision_table def decide(self, task): + rules = [] for rule in self.decision_table.rules: 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): for input_entry in rule.inputEntries: diff --git a/SpiffWorkflow/dmn/parser/DMNParser.py b/SpiffWorkflow/dmn/parser/DMNParser.py index 303fe64d..02d7ae56 100644 --- a/SpiffWorkflow/dmn/parser/DMNParser.py +++ b/SpiffWorkflow/dmn/parser/DMNParser.py @@ -93,9 +93,10 @@ class DMNParser(NodeParser): def _parse_decision_tables(self, decision, decisionElement): 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_element.attrib.get( - 'name', '')) + name, hitPolicy) decision.decisionTables.append(decision_table) # parse inputs diff --git a/SpiffWorkflow/dmn/serializer/task_spec_converters.py b/SpiffWorkflow/dmn/serializer/task_spec_converters.py index 2f523e36..4e3cb183 100644 --- a/SpiffWorkflow/dmn/serializer/task_spec_converters.py +++ b/SpiffWorkflow/dmn/serializer/task_spec_converters.py @@ -1,7 +1,7 @@ from ...bpmn.serializer.bpmn_converters import BpmnTaskSpecConverter 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 ..engine.DMNEngine import DMNEngine @@ -57,7 +57,8 @@ class BusinessRuleTaskConverter(BpmnTaskSpecConverter): return self.task_spec_from_dict(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.outputs = [ Output(**val) for val in dct['outputs'] ] table.rules = [ self.rule_from_dict(rule, table.inputs, table.outputs) diff --git a/SpiffWorkflow/dmn/specs/BusinessRuleTask.py b/SpiffWorkflow/dmn/specs/BusinessRuleTask.py index 5825c023..8486def2 100644 --- a/SpiffWorkflow/dmn/specs/BusinessRuleTask.py +++ b/SpiffWorkflow/dmn/specs/BusinessRuleTask.py @@ -18,7 +18,6 @@ class BusinessRuleTask(Simple, BpmnSpecMixin): super().__init__(wf_spec, name, **kwargs) self.dmnEngine = dmnEngine - self.res = None self.resDict = None @property @@ -27,10 +26,8 @@ class BusinessRuleTask(Simple, BpmnSpecMixin): def _on_complete_hook(self, my_task): try: - self.res = self.dmnEngine.decide(my_task) - if self.res is not None: # it is conceivable that no rules fire. - self.resDict = self.res.output_as_dict(my_task) - my_task.data = DeepMerge.merge(my_task.data,self.resDict) + my_task.data = DeepMerge.merge(my_task.data, + self.dmnEngine.result(my_task)) super(BusinessRuleTask, self)._on_complete_hook(my_task) except Exception as e: raise WorkflowTaskExecException(my_task, str(e)) diff --git a/SpiffWorkflow/dmn/specs/model.py b/SpiffWorkflow/dmn/specs/model.py index 11ea1a27..a4d847b5 100644 --- a/SpiffWorkflow/dmn/specs/model.py +++ b/SpiffWorkflow/dmn/specs/model.py @@ -1,8 +1,24 @@ from collections import OrderedDict +from enum import Enum 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: def __init__(self, id, name): self.id = id @@ -11,9 +27,10 @@ class Decision: self.decisionTables = [] class DecisionTable: - def __init__(self, id, name): + def __init__(self, id, name, hit_policy): self.id = id self.name = name + self.hit_policy = hit_policy self.inputs = [] self.outputs = [] @@ -23,6 +40,7 @@ class DecisionTable: out = {} out['id'] = self.id out['name'] = self.name + out['hit_policy'] = self.hit_policy out['inputs'] = [x.serialize() for x in self.inputs] out['outputs'] = [x.serialize() for x in self.outputs] out['rules'] = [x.serialize() for x in self.rules] @@ -31,6 +49,10 @@ class DecisionTable: def deserialize(self,indict): self.id = indict['id'] 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']] list(map(lambda x, y: x.deserialize(y), self.inputs, indict['inputs'])) self.outputs = [Output(**x) for x in indict['outputs']] diff --git a/tests/SpiffWorkflow/dmn/DecisionRunner.py b/tests/SpiffWorkflow/dmn/DecisionRunner.py index e69c5db8..133f1292 100644 --- a/tests/SpiffWorkflow/dmn/DecisionRunner.py +++ b/tests/SpiffWorkflow/dmn/DecisionRunner.py @@ -41,11 +41,17 @@ class DecisionRunner: 'Exactly one decision table should exist! (%s)' \ % (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): - + """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): context = {'input': 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) diff --git a/tests/SpiffWorkflow/dmn/HitPolicyTest.py b/tests/SpiffWorkflow/dmn/HitPolicyTest.py new file mode 100644 index 00000000..a7503732 --- /dev/null +++ b/tests/SpiffWorkflow/dmn/HitPolicyTest.py @@ -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()) diff --git a/tests/SpiffWorkflow/dmn/data/collect_hit.dmn b/tests/SpiffWorkflow/dmn/data/collect_hit.dmn new file mode 100644 index 00000000..b918c3e5 --- /dev/null +++ b/tests/SpiffWorkflow/dmn/data/collect_hit.dmn @@ -0,0 +1,78 @@ + + + + + + + type + + + + + + + + "stooge" + + + 1 + + + "Larry" + + + + + + "stooge" + + + 2 + + + "Mo" + + + + + "stooge" + + + 3 + + + "Curly" + + + + + "stooge" + + + 4 + + + "Shemp" + + + + + "farmer" + + + 5 + + + "Elmer Fudd" + + + + + + + + + + + + diff --git a/tests/SpiffWorkflow/dmn/data/unique_hit.dmn b/tests/SpiffWorkflow/dmn/data/unique_hit.dmn new file mode 100644 index 00000000..f4d86264 --- /dev/null +++ b/tests/SpiffWorkflow/dmn/data/unique_hit.dmn @@ -0,0 +1,54 @@ + + + + + + + name + + + + + + + "Larry" + + + 1 + + + + + + "Mo" + + + 2 + + + + + "Curly" + + + 3 + + + + + "Shemp" + + + 4 + + + + + + + + + + + +