From ee8d0a8ac9913e3f8f6437f3cf6b78485e55c9d2 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sat, 2 May 2026 16:19:17 +0200 Subject: [PATCH] fix: emit expression case values for IF flows Symptom: consistency validation can report duplicate output variables for values declared in mutually exclusive IF branches after exec builds the graph. A targeted Mendix 9 validation pass also showed that emitting ExpressionCase directly breaks legacy MPRs because that metamodel stores boolean branches as enumeration-style case values. Root cause: IF sequence flows used EnumerationCase values as the only in-memory representation. That conflates IF branch cases with real enum split cases, but the writer also needs to honor the target Mendix version when serializing the case object. Fix: represent true/false IF branch flows as ExpressionCase internally, keep EnumerationCase for real enum split values, serialize ExpressionCase as ExpressionCase for modern MPRs, and downgrade it to EnumerationCase when writing Mendix 9 projects. Tests: make build, make test, make lint-go. --- .../cmd_microflows_builder_enum_split_test.go | 2 +- mdl/executor/cmd_microflows_builder_flows.go | 28 +++++++++---- .../cmd_microflows_builder_terminal_test.go | 12 +----- .../cmd_microflows_guard_pattern_test.go | 39 ++++++++++++++----- sdk/mpr/parser_microflow_test.go | 21 ++++++++++ sdk/mpr/writer_microflow.go | 23 ++++++++++- sdk/mpr/writer_microflow_version_test.go | 32 ++++++++++++++- 7 files changed, 126 insertions(+), 31 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_enum_split_test.go b/mdl/executor/cmd_microflows_builder_enum_split_test.go index 45877637..91968cd7 100644 --- a/mdl/executor/cmd_microflows_builder_enum_split_test.go +++ b/mdl/executor/cmd_microflows_builder_enum_split_test.go @@ -111,7 +111,7 @@ func TestEnumSplitNestedEmptyThenBranchKeepsContinuationCase(t *testing.T) { if flow.OriginID != nestedSplitID { continue } - if value, ok := enumCaseValue(flow); ok && value == "true" { + if flowCaseString(flow.CaseValue) == "true" { if _, ok := objects[flow.DestinationID].(*microflows.ExclusiveMerge); ok { return } diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 5a556c11..fd263dc7 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -653,10 +653,7 @@ func newHorizontalFlow(originID, destinationID model.ID) *microflows.SequenceFlo // newHorizontalFlowWithCase creates a horizontal SequenceFlow with a boolean case value (for splits) func newHorizontalFlowWithCase(originID, destinationID model.ID, caseValue string) *microflows.SequenceFlow { flow := newHorizontalFlow(originID, destinationID) - flow.CaseValue = microflows.EnumerationCase{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - Value: caseValue, // "true" or "false" as string - } + flow.CaseValue = caseValueForFlow(caseValue) return flow } @@ -678,10 +675,27 @@ func newDownwardFlowWithCase(originID, destinationID model.ID, caseValue string) DestinationID: destinationID, OriginConnectionIndex: AnchorBottom, // Connect from bottom of origin (going down) DestinationConnectionIndex: AnchorLeft, // Connect to left side of destination - CaseValue: microflows.EnumerationCase{ + CaseValue: caseValueForFlow(caseValue), + } +} + +func caseValueForFlow(caseValue string) microflows.CaseValue { + switch caseValue { + case "true": + return µflows.ExpressionCase{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - Value: caseValue, // "true" or "false" as string - }, + Expression: "true", + } + case "false": + return µflows.ExpressionCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Expression: "false", + } + default: + return microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: caseValue, + } } } diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index bf5982c2..23a285e8 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -892,17 +892,9 @@ func flowCaseString(caseValue microflows.CaseValue) string { if c != nil { return c.Value } - case microflows.BooleanCase: - if c.Value { - return "true" - } - return "false" - case *microflows.BooleanCase: - if c != nil && c.Value { - return "true" - } + case *microflows.ExpressionCase: if c != nil { - return "false" + return c.Expression } } return "" diff --git a/mdl/executor/cmd_microflows_guard_pattern_test.go b/mdl/executor/cmd_microflows_guard_pattern_test.go index e733d1e7..aab47cb2 100644 --- a/mdl/executor/cmd_microflows_guard_pattern_test.go +++ b/mdl/executor/cmd_microflows_guard_pattern_test.go @@ -47,17 +47,10 @@ func TestBuilder_GuardPatternPreservesFalseBranchAnchor(t *testing.T) { oc := fb.buildFlowGraph(body, nil) // Find the flow from the split to the tail log. It's the only one with - // an EnumerationCase Value=="false" that doesn't target an EndEvent. + // a false branch case that doesn't target an EndEvent. var found *microflows.SequenceFlow for _, f := range oc.Flows { - cv, ok := f.CaseValue.(microflows.EnumerationCase) - if !ok { - if p, okp := f.CaseValue.(*microflows.EnumerationCase); okp { - cv = *p - ok = true - } - } - if !ok || cv.Value != "false" { + if flowCaseString(f.CaseValue) != "false" { continue } // Exclude flows pointing at an EndEvent. @@ -85,3 +78,31 @@ func TestBuilder_GuardPatternPreservesFalseBranchAnchor(t *testing.T) { t.Errorf("destination: got %d, want %d (Top)", found.DestinationConnectionIndex, AnchorTop) } } + +func TestCaseValueForFlowUsesExpressionCaseForBooleanBranches(t *testing.T) { + for _, tc := range []struct { + value string + want string + }{ + {value: "true", want: "true"}, + {value: "false", want: "false"}, + } { + got, ok := caseValueForFlow(tc.value).(*microflows.ExpressionCase) + if !ok { + t.Fatalf("caseValueForFlow(%q) = %T, want *ExpressionCase", tc.value, caseValueForFlow(tc.value)) + } + if got.Expression != tc.want { + t.Fatalf("caseValueForFlow(%q).Expression = %q, want %q", tc.value, got.Expression, tc.want) + } + } +} + +func TestCaseValueForFlowKeepsEnumValuesAsEnumerationCase(t *testing.T) { + got, ok := caseValueForFlow("Submitted").(microflows.EnumerationCase) + if !ok { + t.Fatalf("caseValueForFlow(enum) = %T, want EnumerationCase", caseValueForFlow("Submitted")) + } + if got.Value != "Submitted" { + t.Fatalf("enum case value = %q, want Submitted", got.Value) + } +} diff --git a/sdk/mpr/parser_microflow_test.go b/sdk/mpr/parser_microflow_test.go index 33562cd3..2c868871 100644 --- a/sdk/mpr/parser_microflow_test.go +++ b/sdk/mpr/parser_microflow_test.go @@ -35,6 +35,27 @@ func TestParseSequenceFlow_NewCaseValueEnumerationCase(t *testing.T) { } } +func TestParseSequenceFlow_NewCaseValueExpressionCase(t *testing.T) { + flow := parseSequenceFlow(map[string]any{ + "$ID": "flow-1", + "OriginPointer": "start-1", + "DestinationPointer": "dest-1", + "NewCaseValue": primitive.D{ + {Key: "$ID", Value: "case-1"}, + {Key: "$Type", Value: "Microflows$ExpressionCase"}, + {Key: "Expression", Value: "false"}, + }, + }) + + got, ok := flow.CaseValue.(*microflows.ExpressionCase) + if !ok { + t.Fatalf("expected *ExpressionCase, got %T", flow.CaseValue) + } + if got.Expression != "false" { + t.Fatalf("expected false branch, got %q", got.Expression) + } +} + func TestParseSequenceFlow_NewCaseValueNoCase(t *testing.T) { flow := parseSequenceFlow(map[string]any{ "$ID": "flow-1", diff --git a/sdk/mpr/writer_microflow.go b/sdk/mpr/writer_microflow.go index 772adf42..60a6cb27 100644 --- a/sdk/mpr/writer_microflow.go +++ b/sdk/mpr/writer_microflow.go @@ -170,7 +170,7 @@ func (w *Writer) serializeMicroflow(mf *microflows.Microflow) ([]byte, error) { func serializeSequenceFlow(flow *microflows.SequenceFlow, majorVersion int) bson.D { // Build the case document. Every sequence flow needs a case — NoCase is the // default when no branch condition has been set. - caseDoc := buildSequenceFlowCase(flow.CaseValue) + caseDoc := buildSequenceFlowCase(flow.CaseValue, majorVersion) originCV := flow.OriginControlVector if originCV == "" { @@ -221,13 +221,15 @@ func serializeSequenceFlow(flow *microflows.SequenceFlow, majorVersion int) bson // buildSequenceFlowCase renders the case document for a sequence flow. // When no case has been set on the flow, a NoCase document is synthesised — // Studio Pro requires every SequenceFlow to carry an explicit case object. -func buildSequenceFlowCase(cv microflows.CaseValue) bson.D { +func buildSequenceFlowCase(cv microflows.CaseValue, majorVersion int) bson.D { // Normalise value receivers to pointers so each case is handled once. switch c := cv.(type) { case microflows.EnumerationCase: cv = &c case microflows.NoCase: cv = &c + case microflows.ExpressionCase: + cv = &c } switch c := cv.(type) { @@ -250,6 +252,23 @@ func buildSequenceFlowCase(cv microflows.CaseValue) bson.D { {Key: "$ID", Value: idToBsonBinary(id)}, {Key: "$Type", Value: "Microflows$NoCase"}, } + case *microflows.ExpressionCase: + id := string(c.ID) + if id == "" { + id = generateUUID() + } + if majorVersion <= 9 { + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(id)}, + {Key: "$Type", Value: "Microflows$EnumerationCase"}, + {Key: "Value", Value: c.Expression}, + } + } + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(id)}, + {Key: "$Type", Value: "Microflows$ExpressionCase"}, + {Key: "Expression", Value: c.Expression}, + } } // Default: synthesise a NoCase document with a fresh ID. return bson.D{ diff --git a/sdk/mpr/writer_microflow_version_test.go b/sdk/mpr/writer_microflow_version_test.go index 356927fc..55d1e693 100644 --- a/sdk/mpr/writer_microflow_version_test.go +++ b/sdk/mpr/writer_microflow_version_test.go @@ -161,10 +161,38 @@ func TestSerializeMicroflowParameter_Mx9_OmitsMx10OnlyKeys(t *testing.T) { func TestBuildSequenceFlowCase_NormalisesValueReceiver(t *testing.T) { // A value-receiver NoCase must produce the same shape as a pointer. - fromValue := buildSequenceFlowCase(microflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}) - fromPointer := buildSequenceFlowCase(µflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}) + fromValue := buildSequenceFlowCase(microflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}, 9) + fromPointer := buildSequenceFlowCase(µflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}, 9) if bsonGetKey(fromValue, "$Type") != bsonGetKey(fromPointer, "$Type") { t.Error("value and pointer NoCase must produce identical $Type") } } + +func TestBuildSequenceFlowCase_ExpressionCase_Mx10(t *testing.T) { + doc := buildSequenceFlowCase(microflows.ExpressionCase{ + BaseElement: model.BaseElement{ID: "case-false"}, + Expression: "false", + }, 10) + + if got := bsonGetKey(doc, "$Type"); got != "Microflows$ExpressionCase" { + t.Fatalf("$Type = %v, want Microflows$ExpressionCase", got) + } + if got := bsonGetKey(doc, "Expression"); got != "false" { + t.Fatalf("Expression = %v, want false", got) + } +} + +func TestBuildSequenceFlowCase_ExpressionCase_Mx9UsesEnumerationCase(t *testing.T) { + doc := buildSequenceFlowCase(microflows.ExpressionCase{ + BaseElement: model.BaseElement{ID: "case-false"}, + Expression: "false", + }, 9) + + if got := bsonGetKey(doc, "$Type"); got != "Microflows$EnumerationCase" { + t.Fatalf("$Type = %v, want Microflows$EnumerationCase", got) + } + if got := bsonGetKey(doc, "Value"); got != "false" { + t.Fatalf("Value = %v, want false", got) + } +}