From 18007ee8bcc14f2c91405f38c79f4628c67bba0c Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 15:51:34 +0200 Subject: [PATCH 1/5] feat: support inheritance split and cast statements Symptom: type-based microflow decisions and cast actions could be read from MPRs but had no first-class MDL representation, so describe/exec round-trips could not preserve InheritanceSplit and CastAction graphs. Root cause: the microflow AST, grammar, visitor, builder, describer, validator, and MPR writer only modeled boolean exclusive splits and regular actions. InheritanceCase sequence flows and CastAction BSON were not emitted back to valid project data. Fix: add split type and cast statements, parse and build inheritance branches, describe existing InheritanceSplit graphs by resolving case entity names, serialize inheritance split/case/cast BSON, and recurse through type-split bodies in validation/reference/layout code. Tests: added parser, builder, describer, terminality, validation, and MPR writer regressions plus a doctype fixture checked with mxcli check. Also ran make build, make lint-go, and make test. --- .claude/skills/mendix/write-microflows.md | 17 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 2 + ...L_microflow_inheritance_split_statement.md | 35 ++++ docs/11-proposals/README.md | 1 + .../inheritance_split_statement.test.mdl | 26 +++ mdl/ast/ast_microflow.go | 24 +++ mdl/executor/cmd_diff_mdl.go | 23 +++ .../cmd_microflows_builder_actions.go | 193 ++++++++++++++++++ .../cmd_microflows_builder_annotations.go | 4 + mdl/executor/cmd_microflows_builder_flows.go | 33 +++ mdl/executor/cmd_microflows_builder_graph.go | 4 + .../cmd_microflows_builder_validate.go | 8 + mdl/executor/cmd_microflows_format_action.go | 24 +++ .../cmd_microflows_inheritance_test.go | 141 +++++++++++++ mdl/executor/cmd_microflows_show.go | 5 +- mdl/executor/cmd_microflows_show_helpers.go | 98 +++++++++ mdl/executor/layout.go | 26 +++ mdl/executor/validate.go | 1 + mdl/executor/validate_microflow.go | 37 ++++ .../validate_microflow_inheritance_test.go | 41 ++++ mdl/grammar/domains/MDLMicroflow.g4 | 16 ++ .../visitor_microflow_inheritance_test.go | 77 +++++++ mdl/visitor/visitor_microflow_statements.go | 49 +++++ sdk/microflows/microflows.go | 3 +- sdk/mpr/inheritance_roundtrip_test.go | 65 ++++++ sdk/mpr/parser_microflow.go | 10 + sdk/mpr/parser_microflow_actions.go | 3 + sdk/mpr/writer_microflow.go | 24 +++ sdk/mpr/writer_microflow_actions.go | 8 + 29 files changed, 995 insertions(+), 3 deletions(-) create mode 100644 docs/11-proposals/PROPOSAL_microflow_inheritance_split_statement.md create mode 100644 mdl-examples/doctype-tests/inheritance_split_statement.test.mdl create mode 100644 mdl/executor/cmd_microflows_inheritance_test.go create mode 100644 mdl/executor/validate_microflow_inheritance_test.go create mode 100644 mdl/visitor/visitor_microflow_inheritance_test.go create mode 100644 sdk/mpr/inheritance_roundtrip_test.go diff --git a/.claude/skills/mendix/write-microflows.md b/.claude/skills/mendix/write-microflows.md index 348fc25f..ae907ecf 100644 --- a/.claude/skills/mendix/write-microflows.md +++ b/.claude/skills/mendix/write-microflows.md @@ -340,6 +340,23 @@ end case; `(empty)` represents an unset enumeration value. Multiple values can share one `when` branch by separating them with commas. Case values are bare identifiers — do **not** quote them. +### Type Split And Cast Statements + +Use `split type` when a microflow branches on an object's runtime specialization. +Use `cast` inside a type branch to create the specialized variable used by the branch body. + +```mdl +split type $Input +case Sample.SpecializedInput + cast $SpecificInput; + return true; +else + return false; +end split; +``` + +`case` values are qualified entity names. The optional `else` branch handles objects that do not match any listed specialization. + ### LOOP Statements ```mdl diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 018754ce..0441ec31 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -243,6 +243,8 @@ authentication basic, session | Free annotation | `@annotation 'text'` before `@position(...)` | Free-floating visual note preserved by order | | IF | `if condition then ... [else ...] end if;` | | | Enum split | `case $Var when Value then ... end case;` | Enumeration decision branches | +| Type split | `split type $Var case Module.Entity ... end split;` | Runtime specialization branches | +| Cast | `cast $SpecificVar;` | Downcast inside a type split branch | | LOOP | `loop $item in $list begin ... end loop;` | FOR EACH over list | | WHILE | `while condition begin ... end while;` | Condition-based loop | | Return | `return $value;` | Required at end of every flow path | diff --git a/docs/11-proposals/PROPOSAL_microflow_inheritance_split_statement.md b/docs/11-proposals/PROPOSAL_microflow_inheritance_split_statement.md new file mode 100644 index 00000000..718687b3 --- /dev/null +++ b/docs/11-proposals/PROPOSAL_microflow_inheritance_split_statement.md @@ -0,0 +1,35 @@ +# Proposal: Microflow Inheritance Split And Cast Statements + +Status: Draft + +## Summary + +Add round-trip MDL support for type-based microflow decisions and cast actions: + +```mdl +split type $Input +case Sample.SpecializedInput + cast $SpecificInput; +else + return false; +end split; +``` + +## Motivation + +Studio Pro represents specialization/type decisions as `InheritanceSplit` objects and stores downcasts as `CastAction` activities. Without first-class MDL statements, `describe` can only emit unsupported comments or incomplete split output, and `exec` cannot rebuild the same graph. + +## Semantics + +`split type $Var` evaluates the runtime specialization of an object variable. Each `case Module.Entity` branch corresponds to an outgoing sequence flow with an `InheritanceCase`. The optional `else` branch maps to the outgoing flow without an inheritance case. + +`cast $Output` emits a `CastAction` that produces the downcast variable. `$Output = cast $Input` is accepted for source-preserving authoring, but current Mendix BSON stores the generated cast variable as the primary persisted field. + +## Tests And Examples + +`mdl-examples/doctype-tests/inheritance_split_statement.test.mdl` demonstrates the syntax. Go regression tests cover parser construction, builder output, describer output, validation recursion, and BSON writer support for inheritance case values and cast actions. + +## Open Questions + +- Should `exec` validate `case Module.Entity` against the project's specialization hierarchy when connected? +- Should the source-preserving `$Output = cast $Input` form round-trip both variable names once the underlying BSON fields are confirmed for all supported Mendix versions? diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index c290b6de..1409f93e 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -54,6 +54,7 @@ BSON schema Registry ◄──── multi-version Support | [Microflow ENUM SPLIT Statement](PROPOSAL_microflow_enum_split_statement.md) | Implemented | Enumeration decision splits via `case $Var when Value then … end case;` | — | | [Microflow CHANGE Refresh Modifier](PROPOSAL_microflow_change_refresh_modifier.md) | Draft | Preserve `RefreshInClient` on change-object actions | — | | [Microflow ADD Expression To List](PROPOSAL_microflow_add_expression_to_list.md) | Draft | Preserve expression-valued list-add actions in microflow round-trips | — | +| [Microflow Inheritance Split And Cast Statements](PROPOSAL_microflow_inheritance_split_statement.md) | Draft | Preserve type-based microflow decisions and cast actions in round-trips | — | | [LLM MDL Assistance](PROPOSAL_llm_mdl_assistance.md) | Proposed | Enhanced error messages with examples, reorganized skills by use case | — | ### Testing & Evaluation diff --git a/mdl-examples/doctype-tests/inheritance_split_statement.test.mdl b/mdl-examples/doctype-tests/inheritance_split_statement.test.mdl new file mode 100644 index 00000000..b938d49c --- /dev/null +++ b/mdl-examples/doctype-tests/inheritance_split_statement.test.mdl @@ -0,0 +1,26 @@ +create module InheritanceSplitExample; + +create persistent entity InheritanceSplitExample.BaseInput ( + Name: String(200) +); +/ + +create persistent entity InheritanceSplitExample.SpecializedInput extends InheritanceSplitExample.BaseInput ( + Code: String(50) +); +/ + +create microflow InheritanceSplitExample.RouteInput ( + $Input: InheritanceSplitExample.BaseInput +) +returns boolean +begin + split type $Input + case InheritanceSplitExample.SpecializedInput + cast $SpecializedInput; + return true; + else + return false; + end split; +end; +/ diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 30e7ce23..147d13a2 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -116,7 +116,31 @@ type EnumSplitStmt struct { Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } +// InheritanceSplitCase represents one typed branch in an inheritance split. +type InheritanceSplitCase struct { + Entity QualifiedName + Body []MicroflowStatement +} + +// InheritanceSplitStmt represents: SPLIT TYPE $Var ... END SPLIT +type InheritanceSplitStmt struct { + Variable string // Variable name without $ prefix + Cases []InheritanceSplitCase + ElseBody []MicroflowStatement + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation +} + func (s *EnumSplitStmt) isMicroflowStatement() {} +func (s *InheritanceSplitStmt) isMicroflowStatement() {} + +// CastObjectStmt represents: $Output = CAST $Object +type CastObjectStmt struct { + OutputVariable string // Output variable name without $ prefix + ObjectVariable string // Source object variable name without $ prefix + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation +} + +func (s *CastObjectStmt) isMicroflowStatement() {} // MfSetStmt represents: SET $Var = expr or SET $Var/Attr = expr // (Named MfSetStmt to avoid conflict with existing SetStmt for SET key = value) diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index 6cfa9f96..be8f6ffc 100644 --- a/mdl/executor/cmd_diff_mdl.go +++ b/mdl/executor/cmd_diff_mdl.go @@ -447,6 +447,29 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde } lines = append(lines, indentStr+"end case;") + case *ast.InheritanceSplitStmt: + lines = append(lines, fmt.Sprintf("%ssplit type $%s", indentStr, s.Variable)) + for _, c := range s.Cases { + lines = append(lines, fmt.Sprintf("%scase %s", indentStr, c.Entity.String())) + for _, caseStmt := range c.Body { + lines = append(lines, microflowStatementToMDL(ctx, caseStmt, indent+1)...) + } + } + if len(s.ElseBody) > 0 { + lines = append(lines, indentStr+"else") + for _, elseStmt := range s.ElseBody { + lines = append(lines, microflowStatementToMDL(ctx, elseStmt, indent+1)...) + } + } + lines = append(lines, indentStr+"end split;") + + case *ast.CastObjectStmt: + if s.ObjectVariable == "" { + lines = append(lines, fmt.Sprintf("%scast $%s;", indentStr, s.OutputVariable)) + } else { + lines = append(lines, fmt.Sprintf("%s$%s = cast $%s;", indentStr, s.OutputVariable, s.ObjectVariable)) + } + case *ast.LoopStmt: lines = append(lines, fmt.Sprintf("%sloop $%s in $%s", indentStr, s.LoopVariable, s.ListVariable)) for _, bodyStmt := range s.Body { diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index f1db1c8b..fcaa38fe 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -422,6 +422,159 @@ func (fb *flowBuilder) addEnumSplit(s *ast.EnumSplitStmt) model.ID { return splitID } +func (fb *flowBuilder) addInheritanceSplit(s *ast.InheritanceSplitStmt) model.ID { + if len(s.Cases) == 0 && len(s.ElseBody) == 0 { + split := µflows.InheritanceSplit{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX, Y: fb.posY}, + Size: model.Size{Width: ActivityWidth, Height: ActivityHeight}, + }, + ErrorHandlingType: microflows.ErrorHandlingTypeRollback, + VariableName: s.Variable, + } + fb.objects = append(fb.objects, split) + fb.posX += fb.spacing + return split.ID + } + return fb.addStructuredInheritanceSplit(s) +} + +func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt) model.ID { + if fb.measurer == nil { + fb.measurer = &layoutMeasurer{varTypes: fb.varTypes} + } + + splitX := fb.posX + centerY := fb.posY + split := µflows.InheritanceSplit{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: splitX, Y: centerY}, + Size: model.Size{Width: ActivityWidth, Height: ActivityHeight}, + }, + ErrorHandlingType: microflows.ErrorHandlingTypeRollback, + VariableName: s.Variable, + } + fb.objects = append(fb.objects, split) + splitID := split.ID + if fb.pendingAnnotations != nil { + fb.applyAnnotations(splitID, fb.pendingAnnotations) + fb.pendingAnnotations = nil + } + + branchWidth := fb.measurer.measureStatements(appendInheritanceBodies(s)).Width + if branchWidth == 0 { + branchWidth = HorizontalSpacing / 2 + } + branchStartX := splitX + ActivityWidth + HorizontalSpacing/2 + mergeX := branchStartX + branchWidth + HorizontalSpacing/2 + + type branchTail struct { + id model.ID + caseValue string + fromSplit bool + } + var branchTails []branchTail + + savedEndsWithReturn := fb.endsWithReturn + allBranchesReturn := len(s.Cases) > 0 && len(s.ElseBody) > 0 + branchIndex := 0 + + addBranch := func(caseValue string, body []ast.MicroflowStatement) { + branchNumber := branchIndex + branchY := centerY + branchIndex*VerticalSpacing + branchIndex++ + if len(body) == 0 { + allBranchesReturn = false + branchTails = append(branchTails, branchTail{id: splitID, caseValue: caseValue, fromSplit: true}) + return + } + + fb.posX = branchStartX + fb.posY = branchY + fb.endsWithReturn = false + + var lastID model.ID + for _, stmt := range body { + actID := fb.addStatement(stmt) + if actID == "" { + continue + } + if cast, ok := stmt.(*ast.CastObjectStmt); ok && cast.OutputVariable != "" && caseValue != "" && fb.varTypes != nil { + fb.varTypes[cast.OutputVariable] = caseValue + } + if fb.pendingAnnotations != nil { + fb.applyAnnotations(actID, fb.pendingAnnotations) + fb.pendingAnnotations = nil + } + if lastID == "" { + var flow *microflows.SequenceFlow + if branchNumber == 0 { + flow = newHorizontalFlowWithInheritanceCase(splitID, actID, caseValue) + } else { + flow = newDownwardFlowWithInheritanceCase(splitID, actID, caseValue) + } + if caseValue == "" { + flow = newHorizontalFlow(splitID, actID) + } + fb.flows = append(fb.flows, flow) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID)) + } + if fb.nextConnectionPoint != "" { + lastID = fb.nextConnectionPoint + fb.nextConnectionPoint = "" + } else { + lastID = actID + } + } + + if !lastStmtIsReturn(body) { + allBranchesReturn = false + if lastID != "" { + branchTails = append(branchTails, branchTail{id: lastID}) + } + } + } + + for _, c := range s.Cases { + addBranch(qualifiedNameString(c.Entity), c.Body) + } + addBranch("", s.ElseBody) + + fb.posX = mergeX + fb.posY = centerY + fb.endsWithReturn = savedEndsWithReturn + if allBranchesReturn { + fb.endsWithReturn = true + } else if len(branchTails) == 1 && !branchTails[0].fromSplit { + fb.nextConnectionPoint = branchTails[0].id + } else if len(branchTails) > 0 { + merge := µflows.ExclusiveMerge{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: mergeX, Y: centerY}, + Size: model.Size{Width: MergeSize, Height: MergeSize}, + }, + } + fb.objects = append(fb.objects, merge) + for _, tail := range branchTails { + if tail.fromSplit { + if tail.caseValue == "" { + fb.flows = append(fb.flows, newHorizontalFlow(splitID, merge.ID)) + } else { + fb.flows = append(fb.flows, newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue)) + } + } else { + fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID)) + } + } + fb.nextConnectionPoint = merge.ID + } + return splitID +} + func (fb *flowBuilder) addGroupedEnumSplitFlows(originID, destinationID model.ID, values []string, order int, mergeX, mergeY int) { if len(values) <= 1 { fb.addEnumSplitFlows(originID, destinationID, values, order) @@ -518,6 +671,46 @@ func appendEnumBodies(s *ast.EnumSplitStmt) []ast.MicroflowStatement { return stmts } +func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStatement { + var stmts []ast.MicroflowStatement + for _, c := range s.Cases { + stmts = append(stmts, c.Body...) + } + stmts = append(stmts, s.ElseBody...) + return stmts +} + +func qualifiedNameString(qn ast.QualifiedName) string { + if qn.Module == "" { + return qn.Name + } + return qn.Module + "." + qn.Name +} + +func (fb *flowBuilder) addCastAction(s *ast.CastObjectStmt) model.ID { + action := µflows.CastAction{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + ObjectVariable: s.ObjectVariable, + OutputVariable: s.OutputVariable, + } + + activity := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX, Y: fb.posY}, + Size: model.Size{Width: ActivityWidth, Height: ActivityHeight}, + }, + AutoGenerateCaption: true, + }, + Action: action, + } + + fb.objects = append(fb.objects, activity) + fb.posX += fb.spacing + return activity.ID +} + // addRetrieveAction creates a RETRIEVE statement. func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { var source microflows.RetrieveSource diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 0f322b12..5b55322f 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -15,6 +15,10 @@ func getStatementAnnotations(stmt ast.MicroflowStatement) *ast.ActivityAnnotatio switch s := stmt.(type) { case *ast.DeclareStmt: return s.Annotations + case *ast.InheritanceSplitStmt: + return s.Annotations + case *ast.CastObjectStmt: + return s.Annotations case *ast.MfSetStmt: return s.Annotations case *ast.ReturnStmt: diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 32b56b4a..3c83268b 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -773,6 +773,15 @@ func newHorizontalFlowWithEnumCase(originID, destinationID model.ID, caseValue s return flow } +func newHorizontalFlowWithInheritanceCase(originID, destinationID model.ID, entity string) *microflows.SequenceFlow { + flow := newHorizontalFlow(originID, destinationID) + flow.CaseValue = µflows.InheritanceCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + EntityQualifiedName: entity, + } + return flow +} + // newDownwardFlowWithCase creates a SequenceFlow going down from origin (Bottom) to destination (Left) // Used when TRUE path goes below the main line func newDownwardFlowWithCase(originID, destinationID model.ID, caseValue string) *microflows.SequenceFlow { @@ -806,6 +815,20 @@ func caseValueForFlow(caseValue string) microflows.CaseValue { } } +func newDownwardFlowWithInheritanceCase(originID, destinationID model.ID, entity string) *microflows.SequenceFlow { + return µflows.SequenceFlow{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: originID, + DestinationID: destinationID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.InheritanceCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + EntityQualifiedName: entity, + }, + } +} + // newUpwardFlow creates a SequenceFlow going up from origin (Right) to destination (Top) // Used when returning from a lower branch to merge func newUpwardFlow(originID, destinationID model.ID) *microflows.SequenceFlow { @@ -914,6 +937,16 @@ func isTerminalStmt(stmt ast.MicroflowStatement) bool { // in both no-else and with-else forms the split terminates once we // reach this point. return true + case *ast.InheritanceSplitStmt: + if len(s.Cases) == 0 || len(s.ElseBody) == 0 || !lastStmtIsReturn(s.ElseBody) { + return false + } + for _, c := range s.Cases { + if !lastStmtIsReturn(c.Body) { + return false + } + } + return true default: return false } diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 32d8c68a..77f4f8dd 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -525,6 +525,10 @@ func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID { return fb.addCreateVariableAction(s) case *ast.EnumSplitStmt: return fb.addEnumSplit(s) + case *ast.InheritanceSplitStmt: + return fb.addInheritanceSplit(s) + case *ast.CastObjectStmt: + return fb.addCastAction(s) case *ast.MfSetStmt: return fb.addChangeVariableAction(s) case *ast.ReturnStmt: diff --git a/mdl/executor/cmd_microflows_builder_validate.go b/mdl/executor/cmd_microflows_builder_validate.go index 8069b5ec..a9e96e79 100644 --- a/mdl/executor/cmd_microflows_builder_validate.go +++ b/mdl/executor/cmd_microflows_builder_validate.go @@ -117,6 +117,14 @@ func (fb *flowBuilder) validateStatement(stmt ast.MicroflowStatement) { fb.validateScopedStatements(s.ElseBody) } + case *ast.InheritanceSplitStmt: + for _, c := range s.Cases { + fb.validateScopedStatements(c.Body) + } + if len(s.ElseBody) > 0 { + fb.validateScopedStatements(s.ElseBody) + } + case *ast.LoopStmt: // Register loop variable (derived from list type) if s.ListVariable != "" { diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index ffd1dedc..d5dcfcf7 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -93,6 +93,13 @@ func formatActivity( condition := formatSplitCondition(activity.SplitCondition) return fmt.Sprintf("if %s then", condition) + case *microflows.InheritanceSplit: + varName := activity.VariableName + if !strings.HasPrefix(varName, "$") { + varName = "$" + varName + } + return fmt.Sprintf("split type %s;", varName) + case *microflows.ExclusiveMerge: return "end if;" @@ -143,6 +150,23 @@ func formatAction( } switch a := action.(type) { + case *microflows.CastAction: + outputVar := a.OutputVariable + if outputVar != "" && !strings.HasPrefix(outputVar, "$") { + outputVar = "$" + outputVar + } + objectVar := a.ObjectVariable + if objectVar != "" && !strings.HasPrefix(objectVar, "$") { + objectVar = "$" + objectVar + } + if objectVar == "" { + return fmt.Sprintf("cast %s;", outputVar) + } + if outputVar == "" { + return fmt.Sprintf("cast %s;", objectVar) + } + return fmt.Sprintf("%s = cast %s;", outputVar, objectVar) + case *microflows.CreateVariableAction: varType := "Object" if a.DataType != nil { diff --git a/mdl/executor/cmd_microflows_inheritance_test.go b/mdl/executor/cmd_microflows_inheritance_test.go new file mode 100644 index 00000000..c5ac7cfe --- /dev/null +++ b/mdl/executor/cmd_microflows_inheritance_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestFormatActivity_InheritanceSplit(t *testing.T) { + stmt := formatActivity(nil, µflows.InheritanceSplit{VariableName: "Input"}, nil, nil) + if stmt != "split type $Input;" { + t.Fatalf("formatActivity = %q, want split type $Input;", stmt) + } +} + +func TestFormatAction_CastAction(t *testing.T) { + stmt := formatAction(nil, µflows.CastAction{OutputVariable: "SpecificInput"}, nil, nil) + if stmt != "cast $SpecificInput;" { + t.Fatalf("formatAction = %q, want cast $SpecificInput;", stmt) + } +} + +func TestBuilder_InheritanceSplitAndCastAction(t *testing.T) { + fb := &flowBuilder{spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph([]ast.MicroflowStatement{ + &ast.InheritanceSplitStmt{ + Variable: "Input", + Cases: []ast.InheritanceSplitCase{ + { + Entity: ast.QualifiedName{Module: "Sample", Name: "SpecializedInput"}, + Body: []ast.MicroflowStatement{ + &ast.CastObjectStmt{OutputVariable: "SpecificInput"}, + }, + }, + }, + ElseBody: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + }, nil) + + var split *microflows.InheritanceSplit + var cast *microflows.CastAction + var caseFlow *microflows.SequenceFlow + for _, obj := range oc.Objects { + if candidate, ok := obj.(*microflows.InheritanceSplit); ok { + split = candidate + } + if activity, ok := obj.(*microflows.ActionActivity); ok { + if candidate, ok := activity.Action.(*microflows.CastAction); ok { + cast = candidate + } + } + } + for _, flow := range oc.Flows { + if split != nil && flow.OriginID == split.ID { + if _, ok := flow.CaseValue.(*microflows.InheritanceCase); ok { + caseFlow = flow + } + } + } + if split == nil { + t.Fatal("expected InheritanceSplit object") + } + if split.VariableName != "Input" { + t.Fatalf("split variable = %q, want Input", split.VariableName) + } + if cast == nil || cast.OutputVariable != "SpecificInput" { + t.Fatalf("cast action = %#v, want output SpecificInput", cast) + } + if caseFlow == nil { + t.Fatal("expected inheritance case flow") + } + caseValue := caseFlow.CaseValue.(*microflows.InheritanceCase) + if caseValue.EntityQualifiedName != "Sample.SpecializedInput" { + t.Fatalf("case entity = %q, want Sample.SpecializedInput", caseValue.EntityQualifiedName) + } +} + +func TestTraverseFlow_InheritanceSplit(t *testing.T) { + e := newTestExecutor() + entityID := mkID("entity-specialized") + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("split"): µflows.InheritanceSplit{ + BaseMicroflowObject: mkObj("split"), + VariableName: "Input", + }, + mkID("cast"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("cast")}, + Action: µflows.CastAction{OutputVariable: "SpecificInput"}, + }, + mkID("fallback"): µflows.EndEvent{BaseMicroflowObject: mkObj("fallback")}, + mkID("merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("merge")}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("split"): { + mkBranchFlow("split", "cast", µflows.InheritanceCase{EntityID: entityID}), + mkFlow("split", "fallback"), + }, + mkID("cast"): {mkFlow("cast", "merge")}, + mkID("fallback"): {mkFlow("fallback", "merge")}, + } + splitMergeMap := map[model.ID]model.ID{mkID("split"): mkID("merge")} + entityNames := map[model.ID]string{entityID: "Sample.SpecializedInput"} + + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("split"), activityMap, flowsByOrigin, splitMergeMap, visited, entityNames, nil, &lines, 1, nil, 0, nil) + + assertLineContains(t, lines, "split type $Input") + assertLineContains(t, lines, "case Sample.SpecializedInput") + assertLineContains(t, lines, "cast $SpecificInput;") + assertLineContains(t, lines, "else") + assertLineContains(t, lines, "end split;") +} + +func TestLastStmtIsReturn_InheritanceSplitAllBranchesReturn(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.InheritanceSplitStmt{ + Cases: []ast.InheritanceSplitCase{ + {Entity: ast.QualifiedName{Module: "Sample", Name: "SpecializedInput"}, Body: []ast.MicroflowStatement{&ast.ReturnStmt{}}}, + }, + ElseBody: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + } + if !lastStmtIsReturn(body) { + t.Fatal("inheritance split where all cases and ELSE return must be terminal") + } +} + +func assertLineContains(t *testing.T, lines []string, want string) { + t.Helper() + for _, line := range lines { + if contains(line, want) { + return + } + } + t.Fatalf("expected output to contain %q, got %v", want, lines) +} diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index 71a11c61..df8353fe 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -1021,9 +1021,10 @@ func findSplitMergePointsForGraph( ) map[model.ID]model.ID { result := make(map[model.ID]model.ID) for _, obj := range activityMap { - if _, ok := obj.(*microflows.ExclusiveSplit); ok { + switch obj.(type) { + case *microflows.ExclusiveSplit, *microflows.InheritanceSplit: splitID := obj.GetID() - // Find merge by following both branches until they converge + // Find merge by following both branches until they converge. mergeID := findMergeForSplit(ctx, splitID, flowsByOrigin, activityMap) if mergeID != "" { result[splitID] = mergeID diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index cc060069..2d36befd 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -659,6 +659,21 @@ func traverseFlow( stmt := formatActivity(ctx, obj, entityNames, microflowNames) indentStr := strings.Repeat(" ", indent) + if _, isSplit := obj.(*microflows.InheritanceSplit); isSplit && len(findNormalFlows(flowsByOrigin[currentID])) > 1 { + startLine := len(*lines) + headerLineCount + mergeID := splitMergeMap[currentID] + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) + emitInheritanceSplitStatement(ctx, currentID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) + if mergeID != "" { + visited[mergeID] = true + for _, flow := range flowsByOrigin[mergeID] { + traverseFlow(ctx, flow.DestinationID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + } + } + return + } + // Handle ExclusiveSplit specially - need to process both branches if split, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { startLine := len(*lines) + headerLineCount @@ -814,6 +829,21 @@ func traverseFlowUntilMerge( stmt := formatActivity(ctx, obj, entityNames, microflowNames) indentStr := strings.Repeat(" ", indent) + if _, isSplit := obj.(*microflows.InheritanceSplit); isSplit && len(findNormalFlows(flowsByOrigin[currentID])) > 1 { + startLine := len(*lines) + headerLineCount + nestedMergeID := splitMergeMap[currentID] + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) + emitInheritanceSplitStatement(ctx, currentID, nestedMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) + if nestedMergeID != "" && nestedMergeID != mergeID { + visited[nestedMergeID] = true + for _, flow := range flowsByOrigin[nestedMergeID] { + traverseFlowUntilMerge(ctx, flow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + } + } + return + } + // Handle nested ExclusiveSplit if split, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { startLine := len(*lines) + headerLineCount @@ -1320,6 +1350,51 @@ func emitEnumSplitStatement( *lines = append(*lines, indentStr+"end case;") } +func emitInheritanceSplitStatement( + ctx *ExecContext, + currentID model.ID, + mergeID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + flowsByDest map[model.ID][]*microflows.SequenceFlow, + splitMergeMap map[model.ID]model.ID, + visited map[model.ID]bool, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, + lines *[]string, + indent int, + sourceMap map[string]elkSourceRange, + headerLineCount int, + annotationsByTarget map[model.ID][]string, +) { + split, _ := activityMap[currentID].(*microflows.InheritanceSplit) + if split == nil { + return + } + varName := split.VariableName + if !strings.HasPrefix(varName, "$") { + varName = "$" + varName + } + indentStr := strings.Repeat(" ", indent) + *lines = append(*lines, indentStr+"split type "+varName) + + var elseFlow *microflows.SequenceFlow + for _, flow := range findNormalFlows(flowsByOrigin[currentID]) { + caseName, ok := inheritanceCaseName(flow, entityNames) + if !ok { + elseFlow = flow + continue + } + *lines = append(*lines, indentStr+"case "+caseName) + traverseFlowUntilMerge(ctx, flow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, cloneVisited(visited), entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) + } + if elseFlow != nil { + *lines = append(*lines, indentStr+"else") + traverseFlowUntilMerge(ctx, elseFlow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, cloneVisited(visited), entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) + } + *lines = append(*lines, indentStr+"end split;") +} + func enumSplitVariable(split *microflows.ExclusiveSplit) (string, bool) { if split == nil { return "", false @@ -1358,6 +1433,29 @@ func enumCaseValue(flow *microflows.SequenceFlow) (string, bool) { } } +func inheritanceCaseName(flow *microflows.SequenceFlow, entityNames map[model.ID]string) (string, bool) { + if flow == nil || flow.CaseValue == nil { + return "", false + } + switch cv := flow.CaseValue.(type) { + case *microflows.InheritanceCase: + if cv.EntityQualifiedName != "" { + return cv.EntityQualifiedName, true + } + if name := entityNames[cv.EntityID]; name != "" { + return name, true + } + case microflows.InheritanceCase: + if cv.EntityQualifiedName != "" { + return cv.EntityQualifiedName, true + } + if name := entityNames[cv.EntityID]; name != "" { + return name, true + } + } + return "", false +} + func orderedEnumSplitFlows(flows []*microflows.SequenceFlow) []*microflows.SequenceFlow { ordered := append([]*microflows.SequenceFlow(nil), flows...) sort.SliceStable(ordered, func(i, j int) bool { diff --git a/mdl/executor/layout.go b/mdl/executor/layout.go index 60dee3ff..9e959ddb 100644 --- a/mdl/executor/layout.go +++ b/mdl/executor/layout.go @@ -75,6 +75,8 @@ func (m *layoutMeasurer) measureStatement(stmt ast.MicroflowStatement) Bounds { return m.measureIfStatement(s) case *ast.EnumSplitStmt: return m.measureEnumSplitStatement(s) + case *ast.InheritanceSplitStmt: + return m.measureInheritanceSplitStatement(s) case *ast.LoopStmt: return m.measureLoopStatement(s) case *ast.WhileStmt: @@ -112,6 +114,30 @@ func (m *layoutMeasurer) measureEnumSplitStatement(s *ast.EnumSplitStmt) Bounds return Bounds{Width: width, Height: height} } +func (m *layoutMeasurer) measureInheritanceSplitStatement(s *ast.InheritanceSplitStmt) Bounds { + maxBranchWidth := 0 + branchCount := len(s.Cases) + for _, c := range s.Cases { + bounds := m.measureStatements(c.Body) + maxBranchWidth = max(maxBranchWidth, bounds.Width) + } + if len(s.ElseBody) > 0 { + bounds := m.measureStatements(s.ElseBody) + maxBranchWidth = max(maxBranchWidth, bounds.Width) + branchCount++ + } + if maxBranchWidth == 0 { + maxBranchWidth = HorizontalSpacing / 2 + } + if branchCount == 0 { + branchCount = 1 + } + + width := ActivityWidth + HorizontalSpacing/2 + maxBranchWidth + HorizontalSpacing/2 + MergeSize + height := ActivityHeight + (branchCount-1)*VerticalSpacing + return Bounds{Width: width, Height: height} +} + // measureIfStatement calculates bounds for IF/ELSE // Layout strategy matches addIfStatement: // - IF with ELSE: TRUE path horizontal, FALSE path below diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 700d52a6..d2cb4fea 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -570,6 +570,7 @@ func (c *flowRefCollector) collectFromStatements(stmts []ast.MicroflowStatement) c.collectFromStatements(s.ThenBody) c.collectFromStatements(s.ElseBody) case *ast.EnumSplitStmt: + case *ast.InheritanceSplitStmt: for _, cse := range s.Cases { c.collectFromStatements(cse.Body) } diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index c2c8ef3a..8f679e23 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -112,6 +112,11 @@ func (v *microflowValidator) walkBody(body []ast.MicroflowStatement) { v.walkBody(c.Body) } v.walkBody(stmt.ElseBody) + case *ast.InheritanceSplitStmt: + for _, c := range stmt.Cases { + v.walkBody(c.Body) + } + v.walkBody(stmt.ElseBody) case *ast.DeclareStmt: // Track list variables declared as empty (candidates for the empty-list-in-loop anti-pattern) if stmt.Type.Kind == ast.TypeListOf { @@ -324,6 +329,16 @@ func bodyReturns(stmts []ast.MicroflowStatement) bool { } } return true + case *ast.InheritanceSplitStmt: + if len(s.Cases) == 0 || len(s.ElseBody) == 0 || !bodyReturns(s.ElseBody) { + return false + } + for _, c := range s.Cases { + if !bodyReturns(c.Body) { + return false + } + } + return true } return false } @@ -371,6 +386,17 @@ func (v *microflowValidator) checkBranchScoping(body []ast.MicroflowStatement) { branchVars[varName] = "enum split else branch" } v.checkBranchScoping(stmt.ElseBody) + case *ast.InheritanceSplitStmt: + for _, c := range stmt.Cases { + for varName := range collectDeclaredVars(c.Body) { + branchVars[varName] = "split type branch" + } + v.checkBranchScoping(c.Body) + } + for varName := range collectDeclaredVars(stmt.ElseBody) { + branchVars[varName] = "split type else branch" + } + v.checkBranchScoping(stmt.ElseBody) case *ast.LoopStmt: v.checkBranchScoping(stmt.Body) } @@ -449,6 +475,11 @@ func collectDeclaredVars(body []ast.MicroflowStatement) map[string]bool { vars[stmt.Variable] = true } case *ast.EnumSplitStmt: + case *ast.CastObjectStmt: + if stmt.OutputVariable != "" { + vars[stmt.OutputVariable] = true + } + case *ast.InheritanceSplitStmt: for _, c := range stmt.Cases { for varName := range collectDeclaredVars(c.Body) { vars[varName] = true @@ -494,6 +525,12 @@ func referencedVars(stmt ast.MicroflowStatement) []string { refs = append(refs, exprVarRefs(s.Message)...) case *ast.EnumSplitStmt: refs = append(refs, extractVarName(s.Variable)) + case *ast.CastObjectStmt: + if s.ObjectVariable != "" { + refs = append(refs, s.ObjectVariable) + } + case *ast.InheritanceSplitStmt: + refs = append(refs, s.Variable) for _, c := range s.Cases { for _, nested := range c.Body { refs = append(refs, referencedVars(nested)...) diff --git a/mdl/executor/validate_microflow_inheritance_test.go b/mdl/executor/validate_microflow_inheritance_test.go new file mode 100644 index 00000000..a1d4f375 --- /dev/null +++ b/mdl/executor/validate_microflow_inheritance_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestValidateMicroflow_InheritanceSplitAllBranchesReturn(t *testing.T) { + stmt := &ast.CreateMicroflowStmt{ + Name: ast.QualifiedName{Module: "Sample", Name: "Route"}, + ReturnType: &ast.MicroflowReturnType{ + Type: ast.DataType{Kind: ast.TypeBoolean}, + }, + Body: []ast.MicroflowStatement{ + &ast.InheritanceSplitStmt{ + Variable: "Input", + Cases: []ast.InheritanceSplitCase{ + { + Entity: ast.QualifiedName{Module: "Sample", Name: "SpecializedInput"}, + Body: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}}, + }, + }, + }, + ElseBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: false}}, + }, + }, + }, + } + + violations := ValidateMicroflow(stmt) + for _, violation := range violations { + if violation.RuleID == "MDL003" { + t.Fatalf("inheritance split with exhaustive returning branches must satisfy return validation: %#v", violation) + } + } +} diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index 5594671b..6d6ad295 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -100,6 +100,8 @@ microflowBody microflowStatement : annotation* declareStatement SEMICOLON? | annotation* caseStatement SEMICOLON? + | annotation* inheritanceSplitStatement SEMICOLON? + | annotation* castObjectStatement SEMICOLON? | annotation* setStatement SEMICOLON? | annotation* createListStatement SEMICOLON? // Must be before createObjectStatement to match "CREATE LIST OF" | annotation* createObjectStatement SEMICOLON? @@ -173,6 +175,20 @@ enumSplitCaseValue | LPAREN EMPTY RPAREN ; +inheritanceSplitStatement + : SPLIT TYPE VARIABLE + (inheritanceSplitCase+ (ELSE microflowBody)? END SPLIT)? + ; + +inheritanceSplitCase + : CASE qualifiedName microflowBody + ; + +castObjectStatement + : CAST VARIABLE + | VARIABLE EQUALS CAST VARIABLE + ; + setStatement : SET (VARIABLE | attributePath) EQUALS expression ; diff --git a/mdl/visitor/visitor_microflow_inheritance_test.go b/mdl/visitor/visitor_microflow_inheritance_test.go new file mode 100644 index 00000000..d0178645 --- /dev/null +++ b/mdl/visitor/visitor_microflow_inheritance_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestMicroflowParsing_InheritanceSplitAndCastAction(t *testing.T) { + input := `CREATE MICROFLOW Sample.Route ($Input: Sample.BaseInput) +RETURNS Boolean +BEGIN + SPLIT TYPE $Input + CASE Sample.SpecializedInput + CAST $SpecificInput; + RETURN true; + ELSE + RETURN false; + END SPLIT; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + split, ok := mf.Body[0].(*ast.InheritanceSplitStmt) + if !ok { + t.Fatalf("first body statement: got %T, want *ast.InheritanceSplitStmt", mf.Body[0]) + } + if split.Variable != "Input" { + t.Fatalf("split variable = %q, want Input", split.Variable) + } + if len(split.Cases) != 1 || split.Cases[0].Entity.String() != "Sample.SpecializedInput" { + t.Fatalf("split cases = %#v, want Sample.SpecializedInput", split.Cases) + } + cast, ok := split.Cases[0].Body[0].(*ast.CastObjectStmt) + if !ok { + t.Fatalf("case body[0]: got %T, want *ast.CastObjectStmt", split.Cases[0].Body[0]) + } + if cast.OutputVariable != "SpecificInput" || cast.ObjectVariable != "" { + t.Fatalf("cast vars: got output=%q object=%q", cast.OutputVariable, cast.ObjectVariable) + } + if len(split.ElseBody) != 1 { + t.Fatalf("else body length = %d, want 1", len(split.ElseBody)) + } +} + +func TestMicroflowParsing_CastWithSourceVariable(t *testing.T) { + input := `CREATE MICROFLOW Sample.Cast ($Input: Sample.BaseInput) +BEGIN + $SpecificInput = CAST $Input; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + cast, ok := mf.Body[0].(*ast.CastObjectStmt) + if !ok { + t.Fatalf("body[0]: got %T, want *ast.CastObjectStmt", mf.Body[0]) + } + if cast.OutputVariable != "SpecificInput" || cast.ObjectVariable != "Input" { + t.Fatalf("cast vars: got output=%q object=%q", cast.OutputVariable, cast.ObjectVariable) + } +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 3a5405cb..446f3516 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -45,6 +45,10 @@ func buildMicroflowStatement(ctx parser.IMicroflowStatementContext) ast.Microflo stmt = buildDeclareStatement(decl) } else if caseStmt := mfCtx.CaseStatement(); caseStmt != nil { stmt = buildCaseStatement(caseStmt) + } else if split := mfCtx.InheritanceSplitStatement(); split != nil { + stmt = buildInheritanceSplitStatement(split) + } else if cast := mfCtx.CastObjectStatement(); cast != nil { + stmt = buildCastObjectStatement(cast) } else if set := mfCtx.SetStatement(); set != nil { stmt = buildSetStatement(set) } else if createList := mfCtx.CreateListStatement(); createList != nil { @@ -484,6 +488,9 @@ func setStatementAnnotations(stmt ast.MicroflowStatement, ann *ast.ActivityAnnot case *ast.DeclareStmt: s.Annotations = ann case *ast.EnumSplitStmt: + case *ast.InheritanceSplitStmt: + s.Annotations = ann + case *ast.CastObjectStmt: s.Annotations = ann case *ast.MfSetStmt: s.Annotations = ann @@ -609,6 +616,48 @@ func buildDeclareStatement(ctx parser.IDeclareStatementContext) *ast.DeclareStmt return stmt } +func buildInheritanceSplitStatement(ctx parser.IInheritanceSplitStatementContext) *ast.InheritanceSplitStmt { + if ctx == nil { + return nil + } + splitCtx := ctx.(*parser.InheritanceSplitStatementContext) + stmt := &ast.InheritanceSplitStmt{} + if v := splitCtx.VARIABLE(); v != nil { + stmt.Variable = strings.TrimPrefix(v.GetText(), "$") + } + for _, caseCtx := range splitCtx.AllInheritanceSplitCase() { + c := caseCtx.(*parser.InheritanceSplitCaseContext) + stmt.Cases = append(stmt.Cases, ast.InheritanceSplitCase{ + Entity: buildQualifiedName(c.QualifiedName()), + Body: buildMicroflowBody(c.MicroflowBody()), + }) + } + if splitCtx.ELSE() != nil { + stmt.ElseBody = buildMicroflowBody(splitCtx.MicroflowBody()) + } + return stmt +} + +func buildCastObjectStatement(ctx parser.ICastObjectStatementContext) *ast.CastObjectStmt { + if ctx == nil { + return nil + } + castCtx := ctx.(*parser.CastObjectStatementContext) + stmt := &ast.CastObjectStmt{} + vars := castCtx.AllVARIABLE() + if len(vars) == 1 { + stmt.OutputVariable = strings.TrimPrefix(vars[0].GetText(), "$") + return stmt + } + if len(vars) > 0 { + stmt.OutputVariable = strings.TrimPrefix(vars[0].GetText(), "$") + } + if len(vars) > 1 { + stmt.ObjectVariable = strings.TrimPrefix(vars[1].GetText(), "$") + } + return stmt +} + // buildSetStatement converts SET statement context to MfSetStmt or specialized statement types. // When the expression is a list operation (HEAD, TAIL, etc.) or aggregate (COUNT, SUM, etc.), // this returns the specialized statement type instead of MfSetStmt. diff --git a/sdk/microflows/microflows.go b/sdk/microflows/microflows.go index ec382cfb..2902c2d6 100644 --- a/sdk/microflows/microflows.go +++ b/sdk/microflows/microflows.go @@ -190,7 +190,8 @@ func (EnumerationCase) isCaseValue() {} // InheritanceCase represents an inheritance/type case value. type InheritanceCase struct { model.BaseElement - EntityID model.ID `json:"entityId"` + EntityID model.ID `json:"entityId"` + EntityQualifiedName string `json:"entityQualifiedName,omitempty"` } func (InheritanceCase) isCaseValue() {} diff --git a/sdk/mpr/inheritance_roundtrip_test.go b/sdk/mpr/inheritance_roundtrip_test.go new file mode 100644 index 00000000..8542c91b --- /dev/null +++ b/sdk/mpr/inheritance_roundtrip_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "go.mongodb.org/mongo-driver/bson" +) + +func TestBuildSequenceFlowCase_InheritanceCase(t *testing.T) { + doc := buildSequenceFlowCase(µflows.InheritanceCase{ + BaseElement: model.BaseElement{ID: "case-1"}, + EntityQualifiedName: "Sample.SpecializedInput", + }) + + if got := bsonGetKey(doc, "$Type"); got != "Microflows$InheritanceCase" { + t.Fatalf("$Type = %v, want Microflows$InheritanceCase", got) + } + if got := bsonGetKey(doc, "Value"); got != "Sample.SpecializedInput" { + t.Fatalf("Value = %v, want Sample.SpecializedInput", got) + } +} + +func TestSerializeMicroflowObject_InheritanceSplit(t *testing.T) { + doc := serializeMicroflowObject(µflows.InheritanceSplit{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "split-1"}, + Position: model.Point{X: 100, Y: 200}, + Size: model.Size{Width: 120, Height: 60}, + }, + VariableName: "Input", + ErrorHandlingType: microflows.ErrorHandlingTypeRollback, + }) + + if got := bsonGetKey(doc, "$Type"); got != "Microflows$InheritanceSplit" { + t.Fatalf("$Type = %v, want Microflows$InheritanceSplit", got) + } + if got := bsonGetKey(doc, "SplitVariableName"); got != "Input" { + t.Fatalf("SplitVariableName = %v, want Input", got) + } +} + +func TestCastAction_RoundtripVariableName(t *testing.T) { + action := µflows.CastAction{ + BaseElement: model.BaseElement{ID: "cast-1"}, + OutputVariable: "SpecificInput", + } + doc := serializeMicroflowAction(action) + data, err := bson.Marshal(doc) + if err != nil { + t.Fatalf("marshal cast action: %v", err) + } + var raw map[string]any + if err := bson.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal cast action: %v", err) + } + + parsed := parseCastAction(raw) + if parsed.OutputVariable != "SpecificInput" { + t.Fatalf("OutputVariable = %q, want SpecificInput", parsed.OutputVariable) + } +} diff --git a/sdk/mpr/parser_microflow.go b/sdk/mpr/parser_microflow.go index 88423e67..a417af5e 100644 --- a/sdk/mpr/parser_microflow.go +++ b/sdk/mpr/parser_microflow.go @@ -223,6 +223,16 @@ func parseCaseValue(raw any) microflows.CaseValue { Value: val, } } + case "Microflows$InheritanceCase": + entityName := extractString(caseMap["Value"]) + if entityName == "" { + entityName = extractString(caseMap["Entity"]) + } + return µflows.InheritanceCase{ + BaseElement: model.BaseElement{ID: id}, + EntityID: model.ID(extractBsonID(caseMap["Entity"])), + EntityQualifiedName: entityName, + } } return nil } diff --git a/sdk/mpr/parser_microflow_actions.go b/sdk/mpr/parser_microflow_actions.go index 01641fc0..74cd7085 100644 --- a/sdk/mpr/parser_microflow_actions.go +++ b/sdk/mpr/parser_microflow_actions.go @@ -397,6 +397,9 @@ func parseCastAction(raw map[string]any) *microflows.CastAction { action.ID = model.ID(extractBsonID(raw["$ID"])) action.ObjectVariable = extractString(raw["ObjectVariableName"]) action.OutputVariable = extractString(raw["OutputVariableName"]) + if action.OutputVariable == "" { + action.OutputVariable = extractString(raw["VariableName"]) + } return action } diff --git a/sdk/mpr/writer_microflow.go b/sdk/mpr/writer_microflow.go index f974e996..490bd795 100644 --- a/sdk/mpr/writer_microflow.go +++ b/sdk/mpr/writer_microflow.go @@ -230,6 +230,8 @@ func buildSequenceFlowCase(cv microflows.CaseValue) bson.D { cv = &c case microflows.ExpressionCase: cv = &c + case microflows.InheritanceCase: + cv = &c } switch c := cv.(type) { @@ -266,6 +268,16 @@ func buildSequenceFlowCase(cv microflows.CaseValue) bson.D { {Key: "$Type", Value: "Microflows$EnumerationCase"}, {Key: "Value", Value: c.Expression}, } + case *microflows.InheritanceCase: + id := string(c.ID) + if id == "" { + id = generateUUID() + } + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(id)}, + {Key: "$Type", Value: "Microflows$InheritanceCase"}, + {Key: "Value", Value: c.EntityQualifiedName}, + } } // Default: synthesise a NoCase document with a fresh ID. return bson.D{ @@ -587,6 +599,18 @@ func serializeMicroflowObject(obj microflows.MicroflowObject) bson.D { {Key: "Size", Value: sizeToString(o.Size)}, } + case *microflows.InheritanceSplit: + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, + {Key: "$Type", Value: "Microflows$InheritanceSplit"}, + {Key: "Caption", Value: o.Caption}, + {Key: "Documentation", Value: o.Documentation}, + {Key: "ErrorHandlingType", Value: stringOrDefault(string(o.ErrorHandlingType), "Rollback")}, + {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, + {Key: "Size", Value: sizeToString(o.Size)}, + {Key: "SplitVariableName", Value: o.VariableName}, + } + case *microflows.LoopedActivity: doc := bson.D{ {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go index b4aab00b..986b49bf 100644 --- a/sdk/mpr/writer_microflow_actions.go +++ b/sdk/mpr/writer_microflow_actions.go @@ -33,6 +33,14 @@ import ( // When adding new action types, check existing MPR files or reflection data for the storage name. func serializeMicroflowAction(action microflows.MicroflowAction) bson.D { switch a := action.(type) { + case *microflows.CastAction: + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, + {Key: "$Type", Value: "Microflows$CastAction"}, + {Key: "ErrorHandlingType", Value: "Rollback"}, + {Key: "VariableName", Value: a.OutputVariable}, + } + case *microflows.CreateVariableAction: doc := bson.D{ {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, From ce88475202c649c83834fe54eefce7055c414af5 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 19:27:17 +0200 Subject: [PATCH 2/5] fix: preserve inheritance split nested continuation cases Symptom: an inheritance split branch containing a nested empty-then decision could lose the boolean case value on the continuation flow when the branch joined a shared split merge. Root cause: addStructuredInheritanceSplit consumed flowBuilder.nextConnectionPoint from the nested decision but did not carry flowBuilder.nextFlowCase through branch tail wiring. Fix: preserve the pending case value on inheritance branch tails and reapply it when connecting to the next statement, parent continuation, or shared merge. Tests: add a builder regression for nested empty-then inheritance branches that must keep CaseValue=true on continuation flows. --- .../cmd_microflows_builder_actions.go | 19 +++++- .../cmd_microflows_inheritance_test.go | 66 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index fcaa38fe..4dbf1932 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -496,6 +496,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.endsWithReturn = false var lastID model.ID + pendingCase := "" for _, stmt := range body { actID := fb.addStatement(stmt) if actID == "" { @@ -520,11 +521,18 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt } fb.flows = append(fb.flows, flow) } else { - fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID)) + if pendingCase != "" { + fb.flows = append(fb.flows, newHorizontalFlowWithCase(lastID, actID, pendingCase)) + pendingCase = "" + } else { + fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID)) + } } if fb.nextConnectionPoint != "" { lastID = fb.nextConnectionPoint fb.nextConnectionPoint = "" + pendingCase = fb.nextFlowCase + fb.nextFlowCase = "" } else { lastID = actID } @@ -533,7 +541,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt if !lastStmtIsReturn(body) { allBranchesReturn = false if lastID != "" { - branchTails = append(branchTails, branchTail{id: lastID}) + branchTails = append(branchTails, branchTail{id: lastID, caseValue: pendingCase}) } } } @@ -550,6 +558,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.endsWithReturn = true } else if len(branchTails) == 1 && !branchTails[0].fromSplit { fb.nextConnectionPoint = branchTails[0].id + fb.nextFlowCase = branchTails[0].caseValue } else if len(branchTails) > 0 { merge := µflows.ExclusiveMerge{ BaseMicroflowObject: microflows.BaseMicroflowObject{ @@ -567,7 +576,11 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.flows = append(fb.flows, newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue)) } } else { - fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID)) + if tail.caseValue != "" { + fb.flows = append(fb.flows, newHorizontalFlowWithCase(tail.id, merge.ID, tail.caseValue)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID)) + } } } fb.nextConnectionPoint = merge.ID diff --git a/mdl/executor/cmd_microflows_inheritance_test.go b/mdl/executor/cmd_microflows_inheritance_test.go index c5ac7cfe..eb584ecf 100644 --- a/mdl/executor/cmd_microflows_inheritance_test.go +++ b/mdl/executor/cmd_microflows_inheritance_test.go @@ -130,6 +130,72 @@ func TestLastStmtIsReturn_InheritanceSplitAllBranchesReturn(t *testing.T) { } } +func TestBuilder_InheritanceSplitNestedEmptyThenBranchKeepsContinuationCase(t *testing.T) { + fb := &flowBuilder{ + spacing: HorizontalSpacing, + declaredVars: map[string]string{"HasMember": "Boolean", "HasApp": "Boolean"}, + varTypes: map[string]string{"Selection": "Sample.Selection"}, + measurer: &layoutMeasurer{}, + } + + oc := fb.buildFlowGraph([]ast.MicroflowStatement{ + &ast.InheritanceSplitStmt{ + Variable: "Selection", + Cases: []ast.InheritanceSplitCase{ + { + Entity: ast.QualifiedName{Module: "Sample", Name: "MemberSelection"}, + Body: []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "HasMember"}, + ElseBody: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + }, + }, + { + Entity: ast.QualifiedName{Module: "Sample", Name: "AppSelection"}, + Body: []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "HasApp"}, + ElseBody: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + }, + }, + }, + ElseBody: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "shared tail"}}, + }, nil) + + objects := map[model.ID]microflows.MicroflowObject{} + var nestedSplitID model.ID + for _, obj := range oc.Objects { + objects[obj.GetID()] = obj + split, ok := obj.(*microflows.ExclusiveSplit) + if !ok { + continue + } + if condition, ok := split.SplitCondition.(*microflows.ExpressionSplitCondition); ok && condition.Expression == "$HasMember" { + nestedSplitID = split.ID + } + } + if nestedSplitID == "" { + t.Fatal("expected nested decision split") + } + for _, flow := range oc.Flows { + if flow.OriginID != nestedSplitID { + continue + } + caseValue, ok := flow.CaseValue.(microflows.EnumerationCase) + if !ok || caseValue.Value != "true" { + continue + } + if _, ok := objects[flow.DestinationID].(*microflows.ExclusiveMerge); ok { + return + } + } + t.Fatal("nested empty-then inheritance branch must carry CaseValue=true to the inheritance merge") +} + func assertLineContains(t *testing.T, lines []string, want string) { t.Helper() for _, line := range lines { From 20c35ff7805e82bb17377e6f611f00392e826a9f Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 10:38:49 +0200 Subject: [PATCH 3/5] fix: preserve inheritance split case order Symptom: type split cases with equivalent empty/fall-through bodies could be reordered after describe/exec/describe. Root cause: inheritance split case flows did not carry a stable ordering signal when multiple cases shared the same split-to-merge shape, so the describer could only rely on connection metadata that was identical across those branches. Fix: encode the parsed case order into valid split flow connection pairs and sort inheritance split flows by that encoded order during describe. Tests: added traversal coverage for shuffled stored inheritance case flows that must still describe in the original authoring order; existing inheritance split builder and cast coverage continues to pass. --- .../cmd_microflows_builder_actions.go | 44 +++++++++++++++++-- .../cmd_microflows_inheritance_test.go | 34 ++++++++++++++ mdl/executor/cmd_microflows_show_helpers.go | 22 +++++++++- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 4dbf1932..75acfbd3 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -474,6 +474,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt id model.ID caseValue string fromSplit bool + order int } var branchTails []branchTail @@ -487,7 +488,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt branchIndex++ if len(body) == 0 { allBranchesReturn = false - branchTails = append(branchTails, branchTail{id: splitID, caseValue: caseValue, fromSplit: true}) + branchTails = append(branchTails, branchTail{id: splitID, caseValue: caseValue, fromSplit: true, order: branchNumber}) return } @@ -519,6 +520,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt if caseValue == "" { flow = newHorizontalFlow(splitID, actID) } + applyInheritanceSplitCaseOrder(flow, branchNumber) fb.flows = append(fb.flows, flow) } else { if pendingCase != "" { @@ -570,11 +572,14 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.objects = append(fb.objects, merge) for _, tail := range branchTails { if tail.fromSplit { + var flow *microflows.SequenceFlow if tail.caseValue == "" { - fb.flows = append(fb.flows, newHorizontalFlow(splitID, merge.ID)) + flow = newHorizontalFlow(splitID, merge.ID) } else { - fb.flows = append(fb.flows, newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue)) + flow = newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue) } + applyInheritanceSplitCaseOrder(flow, tail.order) + fb.flows = append(fb.flows, flow) } else { if tail.caseValue != "" { fb.flows = append(fb.flows, newHorizontalFlowWithCase(tail.id, merge.ID, tail.caseValue)) @@ -693,6 +698,39 @@ func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStateme return stmts } +type inheritanceSplitCaseOrderAnchor struct { + origin int + destination int +} + +var inheritanceSplitCaseOrderAnchors = []inheritanceSplitCaseOrderAnchor{ + {AnchorTop, AnchorLeft}, + {AnchorRight, AnchorLeft}, + {AnchorBottom, AnchorLeft}, + {AnchorLeft, AnchorLeft}, + {AnchorTop, AnchorTop}, + {AnchorRight, AnchorTop}, + {AnchorBottom, AnchorTop}, + {AnchorLeft, AnchorTop}, + {AnchorTop, AnchorRight}, + {AnchorRight, AnchorRight}, + {AnchorBottom, AnchorRight}, + {AnchorLeft, AnchorRight}, + {AnchorTop, AnchorBottom}, + {AnchorRight, AnchorBottom}, + {AnchorBottom, AnchorBottom}, + {AnchorLeft, AnchorBottom}, +} + +func applyInheritanceSplitCaseOrder(flow *microflows.SequenceFlow, order int) { + if flow == nil || order < 0 || order >= len(inheritanceSplitCaseOrderAnchors) { + return + } + pair := inheritanceSplitCaseOrderAnchors[order] + flow.OriginConnectionIndex = pair.origin + flow.DestinationConnectionIndex = pair.destination +} + func qualifiedNameString(qn ast.QualifiedName) string { if qn.Module == "" { return qn.Name diff --git a/mdl/executor/cmd_microflows_inheritance_test.go b/mdl/executor/cmd_microflows_inheritance_test.go index eb584ecf..5d974746 100644 --- a/mdl/executor/cmd_microflows_inheritance_test.go +++ b/mdl/executor/cmd_microflows_inheritance_test.go @@ -3,6 +3,7 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" @@ -116,6 +117,39 @@ func TestTraverseFlow_InheritanceSplit(t *testing.T) { assertLineContains(t, lines, "end split;") } +func TestTraverseFlow_InheritanceSplitPreservesExplicitCaseOrder(t *testing.T) { + e := newTestExecutor() + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("split"): µflows.InheritanceSplit{ + BaseMicroflowObject: mkObj("split"), + VariableName: "Input", + }, + mkID("merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("merge")}, + } + accountFlow := mkBranchFlow("split", "merge", µflows.InheritanceCase{EntityQualifiedName: "Sample.Account"}) + userFlow := mkBranchFlow("split", "merge", µflows.InheritanceCase{EntityQualifiedName: "Sample.User"}) + applyInheritanceSplitCaseOrder(accountFlow, 0) + applyInheritanceSplitCaseOrder(userFlow, 1) + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("split"): {userFlow, accountFlow}, + } + splitMergeMap := map[model.ID]model.ID{mkID("split"): mkID("merge")} + + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("split"), activityMap, flowsByOrigin, splitMergeMap, visited, nil, nil, &lines, 1, nil, 0, nil) + + out := strings.Join(lines, "\n") + accountIdx := strings.Index(out, "case Sample.Account") + userIdx := strings.Index(out, "case Sample.User") + if accountIdx == -1 || userIdx == -1 { + t.Fatalf("missing expected cases:\n%s", out) + } + if accountIdx > userIdx { + t.Fatalf("case order was not preserved:\n%s", out) + } +} + func TestLastStmtIsReturn_InheritanceSplitAllBranchesReturn(t *testing.T) { body := []ast.MicroflowStatement{ &ast.InheritanceSplitStmt{ diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 2d36befd..b8937226 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1379,7 +1379,7 @@ func emitInheritanceSplitStatement( *lines = append(*lines, indentStr+"split type "+varName) var elseFlow *microflows.SequenceFlow - for _, flow := range findNormalFlows(flowsByOrigin[currentID]) { + for _, flow := range orderedInheritanceSplitFlows(findNormalFlows(flowsByOrigin[currentID])) { caseName, ok := inheritanceCaseName(flow, entityNames) if !ok { elseFlow = flow @@ -1464,6 +1464,14 @@ func orderedEnumSplitFlows(flows []*microflows.SequenceFlow) []*microflows.Seque return ordered } +func orderedInheritanceSplitFlows(flows []*microflows.SequenceFlow) []*microflows.SequenceFlow { + ordered := append([]*microflows.SequenceFlow(nil), flows...) + sort.SliceStable(ordered, func(i, j int) bool { + return inheritanceSplitCaseOrder(ordered[i]) < inheritanceSplitCaseOrder(ordered[j]) + }) + return ordered +} + func splitCaseOrder(flow *microflows.SequenceFlow) int { if flow == nil { return 1 << 20 @@ -1476,6 +1484,18 @@ func splitCaseOrder(flow *microflows.SequenceFlow) int { return (1 << 10) + flow.OriginConnectionIndex*4 + flow.DestinationConnectionIndex } +func inheritanceSplitCaseOrder(flow *microflows.SequenceFlow) int { + if flow == nil { + return 1 << 20 + } + for i, pair := range inheritanceSplitCaseOrderAnchors { + if flow.OriginConnectionIndex == pair.origin && flow.DestinationConnectionIndex == pair.destination { + return i + } + } + return (1 << 10) + flow.OriginConnectionIndex*4 + flow.DestinationConnectionIndex +} + func formatEnumSplitCaseValue(value string) string { if value == "" || value == "(empty)" { return "(empty)" From d57a27d387f2bcd9781761ce37a66805efb27368 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 13:57:20 +0200 Subject: [PATCH 4/5] fix: preserve inheritance split branch anchors Symptom: inheritance split roundtrips could lose branch flow anchors or collapse an explicit empty ELSE branch into an untyped flow. Root cause: inheritance branch building did not thread statement anchor metadata through split-to-body and body-to-merge flows, and empty ELSE tails used a plain sequence flow instead of an explicit inheritance case. Fix: propagate authored branch anchors across inheritance branch body flows and keep empty ELSE branches represented by an explicit empty InheritanceCase. Tests: added builder coverage for anchored inheritance branch bodies and tightened the existing case-flow assertion to select the intended typed branch. --- .../cmd_microflows_builder_actions.go | 31 ++++--- .../cmd_microflows_inheritance_test.go | 85 ++++++++++++++++++- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 75acfbd3..d636f784 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -475,6 +475,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt caseValue string fromSplit bool order int + anchor *ast.FlowAnchors } var branchTails []branchTail @@ -497,8 +498,10 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.endsWithReturn = false var lastID model.ID + var prevAnchor *ast.FlowAnchors pendingCase := "" for _, stmt := range body { + thisAnchor := stmtOwnAnchor(stmt) actID := fb.addStatement(stmt) if actID == "" { continue @@ -517,19 +520,21 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt } else { flow = newDownwardFlowWithInheritanceCase(splitID, actID, caseValue) } - if caseValue == "" { - flow = newHorizontalFlow(splitID, actID) - } - applyInheritanceSplitCaseOrder(flow, branchNumber) + applyUserAnchors(flow, nil, thisAnchor) fb.flows = append(fb.flows, flow) } else { if pendingCase != "" { - fb.flows = append(fb.flows, newHorizontalFlowWithCase(lastID, actID, pendingCase)) + flow := newHorizontalFlowWithCase(lastID, actID, pendingCase) + applyUserAnchors(flow, prevAnchor, thisAnchor) + fb.flows = append(fb.flows, flow) pendingCase = "" } else { - fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID)) + flow := newHorizontalFlow(lastID, actID) + applyUserAnchors(flow, prevAnchor, thisAnchor) + fb.flows = append(fb.flows, flow) } } + prevAnchor = thisAnchor if fb.nextConnectionPoint != "" { lastID = fb.nextConnectionPoint fb.nextConnectionPoint = "" @@ -543,7 +548,7 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt if !lastStmtIsReturn(body) { allBranchesReturn = false if lastID != "" { - branchTails = append(branchTails, branchTail{id: lastID, caseValue: pendingCase}) + branchTails = append(branchTails, branchTail{id: lastID, caseValue: pendingCase, anchor: prevAnchor}) } } } @@ -573,8 +578,8 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt for _, tail := range branchTails { if tail.fromSplit { var flow *microflows.SequenceFlow - if tail.caseValue == "" { - flow = newHorizontalFlow(splitID, merge.ID) + if tail.order == 0 { + flow = newHorizontalFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue) } else { flow = newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue) } @@ -582,9 +587,13 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.flows = append(fb.flows, flow) } else { if tail.caseValue != "" { - fb.flows = append(fb.flows, newHorizontalFlowWithCase(tail.id, merge.ID, tail.caseValue)) + flow := newHorizontalFlowWithCase(tail.id, merge.ID, tail.caseValue) + applyUserAnchors(flow, tail.anchor, nil) + fb.flows = append(fb.flows, flow) } else { - fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID)) + flow := newHorizontalFlow(tail.id, merge.ID) + applyUserAnchors(flow, tail.anchor, nil) + fb.flows = append(fb.flows, flow) } } } diff --git a/mdl/executor/cmd_microflows_inheritance_test.go b/mdl/executor/cmd_microflows_inheritance_test.go index 5d974746..86be75dc 100644 --- a/mdl/executor/cmd_microflows_inheritance_test.go +++ b/mdl/executor/cmd_microflows_inheritance_test.go @@ -57,7 +57,7 @@ func TestBuilder_InheritanceSplitAndCastAction(t *testing.T) { } for _, flow := range oc.Flows { if split != nil && flow.OriginID == split.ID { - if _, ok := flow.CaseValue.(*microflows.InheritanceCase); ok { + if caseValue, ok := flow.CaseValue.(*microflows.InheritanceCase); ok && caseValue.EntityQualifiedName == "Sample.SpecializedInput" { caseFlow = flow } } @@ -230,6 +230,89 @@ func TestBuilder_InheritanceSplitNestedEmptyThenBranchKeepsContinuationCase(t *t t.Fatal("nested empty-then inheritance branch must carry CaseValue=true to the inheritance merge") } +func TestBuilder_InheritanceSplitBranchAnchorsApplyToBodyFlows(t *testing.T) { + fb := &flowBuilder{spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + message := &ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "No matching account"}, + Type: "Information", + Annotations: &ast.ActivityAnnotations{ + Anchor: &ast.FlowAnchors{From: ast.AnchorSideBottom, To: ast.AnchorSideTop}, + }, + } + bodyReturn := &ast.ReturnStmt{ + Annotations: &ast.ActivityAnnotations{ + Anchor: &ast.FlowAnchors{From: ast.AnchorSideUnset, To: ast.AnchorSideTop}, + }, + } + + oc := fb.buildFlowGraph([]ast.MicroflowStatement{ + &ast.InheritanceSplitStmt{ + Variable: "Input", + Cases: []ast.InheritanceSplitCase{ + { + Entity: ast.QualifiedName{Module: "Sample", Name: "Primary"}, + Body: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + { + Entity: ast.QualifiedName{Module: "Sample", Name: "Secondary"}, + Body: []ast.MicroflowStatement{message, bodyReturn}, + }, + }, + ElseBody: []ast.MicroflowStatement{&ast.ReturnStmt{}}, + }, + }, nil) + + var splitID, messageID model.ID + for _, obj := range oc.Objects { + switch obj := obj.(type) { + case *microflows.InheritanceSplit: + splitID = obj.ID + case *microflows.ActionActivity: + if _, ok := obj.Action.(*microflows.ShowMessageAction); ok { + messageID = obj.ID + } + } + } + if splitID == "" || messageID == "" { + t.Fatalf("expected split and show-message activity, got split=%q message=%q", splitID, messageID) + } + + var splitToMessage, messageToReturn *microflows.SequenceFlow + var elseCase *microflows.InheritanceCase + for _, flow := range oc.Flows { + if flow.OriginID == splitID && flow.DestinationID == messageID { + splitToMessage = flow + } + if flow.OriginID == messageID { + messageToReturn = flow + } + if flow.OriginID == splitID { + if c, ok := flow.CaseValue.(*microflows.InheritanceCase); ok && c.EntityQualifiedName == "" { + elseCase = c + } + } + } + if splitToMessage == nil { + t.Fatal("expected inheritance split flow to annotated branch body") + } + if splitToMessage.OriginConnectionIndex != AnchorBottom || splitToMessage.DestinationConnectionIndex != AnchorTop { + t.Fatalf("split branch anchors = (%d,%d), want (%d,%d)", + splitToMessage.OriginConnectionIndex, splitToMessage.DestinationConnectionIndex, + AnchorBottom, AnchorTop) + } + if messageToReturn == nil { + t.Fatal("expected message to return flow") + } + if messageToReturn.OriginConnectionIndex != AnchorBottom || messageToReturn.DestinationConnectionIndex != AnchorTop { + t.Fatalf("body flow anchors = (%d,%d), want (%d,%d)", + messageToReturn.OriginConnectionIndex, messageToReturn.DestinationConnectionIndex, + AnchorBottom, AnchorTop) + } + if elseCase == nil { + t.Fatal("expected ELSE branch to keep an explicit empty inheritance case") + } +} + func assertLineContains(t *testing.T, lines []string, want string) { t.Helper() for _, line := range lines { From 6e9bbe325929758e8e5fe90adbd3b60d4831199a Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 17:10:31 +0200 Subject: [PATCH 5/5] fix: keep nested inheritance split tails outside cases Symptom: describing a microflow with an inheritance split inside an if could emit the parent continuation inside the matching split case. Re-executing that MDL made variables declared in the continuation branch-scoped, so Mendix mx check reported invalid or missing return/variable state. Root cause: nested inheritance split emission stopped branches only at the split's own merge. When the inheritance split had no merge because one branch returned and the other fell through to the parent if merge, branch traversal used an empty stop ID and consumed the parent tail. Fix: when emitting an inheritance split, prefer the split's own merge but fall back to the caller's stop ID. This keeps parent continuation statements outside the split cases while preserving standalone inheritance split behavior. Tests: added a synthetic nested if/split-type traversal regression that verifies the parent tail is emitted after both end split and end if. --- .../cmd_microflows_inheritance_test.go | 104 +++++++++++++++++- mdl/executor/cmd_microflows_show_helpers.go | 13 ++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/mdl/executor/cmd_microflows_inheritance_test.go b/mdl/executor/cmd_microflows_inheritance_test.go index 86be75dc..ef93009a 100644 --- a/mdl/executor/cmd_microflows_inheritance_test.go +++ b/mdl/executor/cmd_microflows_inheritance_test.go @@ -150,6 +150,92 @@ func TestTraverseFlow_InheritanceSplitPreservesExplicitCaseOrder(t *testing.T) { } } +func TestTraverseFlow_NestedInheritanceSplitKeepsParentTailOutsideCase(t *testing.T) { + e := newTestExecutor() + entityID := mkID("entity-specialized") + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("init"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("init")}, + Action: µflows.CreateVariableAction{ + VariableName: "TokenValue", + InitialValue: "''", + }, + }, + mkID("outer_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("outer_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$UseToken"}, + }, + mkID("before_type_split"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("before_type_split")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "before type split"}}}, + }, + mkID("type_split"): µflows.InheritanceSplit{ + BaseMicroflowObject: mkObj("type_split"), + VariableName: "Input", + }, + mkID("set_token"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("set_token")}, + Action: µflows.ChangeVariableAction{VariableName: "TokenValue", Value: "$Input/Value"}, + }, + mkID("failed_log"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("failed_log")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "no token"}}}, + }, + mkID("failed_return"): µflows.EndEvent{ + BaseMicroflowObject: mkObj("failed_return"), + ReturnValue: "empty", + }, + mkID("outer_merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("outer_merge")}, + mkID("tail"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("tail")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "tail after split"}}}, + }, + mkID("end"): µflows.EndEvent{ + BaseMicroflowObject: mkObj("end"), + ReturnValue: "'ok'", + }, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "init")}, + mkID("init"): {mkFlow("init", "outer_split")}, + mkID("outer_split"): { + mkBranchFlow("outer_split", "before_type_split", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("outer_split", "outer_merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("before_type_split"): {mkFlow("before_type_split", "type_split")}, + mkID("type_split"): { + mkBranchFlow("type_split", "set_token", µflows.InheritanceCase{EntityID: entityID}), + mkBranchFlow("type_split", "failed_log", µflows.InheritanceCase{}), + }, + mkID("set_token"): {mkFlow("set_token", "outer_merge")}, + mkID("failed_log"): {mkFlow("failed_log", "failed_return")}, + mkID("outer_merge"): {mkFlow("outer_merge", "tail")}, + mkID("tail"): {mkFlow("tail", "end")}, + } + splitMergeMap := map[model.ID]model.ID{mkID("outer_split"): mkID("outer_merge")} + entityNames := map[model.ID]string{entityID: "Sample.SpecializedInput"} + + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, splitMergeMap, visited, entityNames, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + tail := strings.Index(out, "tail after split") + endSplit := strings.Index(out, "end split;") + endIf := strings.Index(out, "end if;") + if tail == -1 { + t.Fatalf("expected parent tail after nested inheritance split:\n%s", out) + } + if endSplit == -1 || tail < endSplit { + t.Fatalf("parent tail must not be emitted inside the inheritance case:\n%s", out) + } + if endIf == -1 || tail < endIf { + t.Fatalf("parent tail must remain after the outer IF closes:\n%s", out) + } +} + func TestLastStmtIsReturn_InheritanceSplitAllBranchesReturn(t *testing.T) { body := []ast.MicroflowStatement{ &ast.InheritanceSplitStmt{ @@ -219,8 +305,22 @@ func TestBuilder_InheritanceSplitNestedEmptyThenBranchKeepsContinuationCase(t *t if flow.OriginID != nestedSplitID { continue } - caseValue, ok := flow.CaseValue.(microflows.EnumerationCase) - if !ok || caseValue.Value != "true" { + // After PR #337 the expression split uses ExpressionCase (pointer or + // value receiver) with Expression="true"/"false" rather than + // EnumerationCase. Accept either representation so the test + // documents the intent without pinning the case shape. + value := "" + switch c := flow.CaseValue.(type) { + case microflows.EnumerationCase: + value = c.Value + case *microflows.EnumerationCase: + value = c.Value + case microflows.ExpressionCase: + value = c.Expression + case *microflows.ExpressionCase: + value = c.Expression + } + if value != "true" { continue } if _, ok := objects[flow.DestinationID].(*microflows.ExclusiveMerge); ok { diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index b8937226..2225d058 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -833,7 +833,7 @@ func traverseFlowUntilMerge( startLine := len(*lines) + headerLineCount nestedMergeID := splitMergeMap[currentID] emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) - emitInheritanceSplitStatement(ctx, currentID, nestedMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + emitInheritanceSplitStatement(ctx, currentID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) if nestedMergeID != "" && nestedMergeID != mergeID { visited[nestedMergeID] = true @@ -1353,7 +1353,7 @@ func emitEnumSplitStatement( func emitInheritanceSplitStatement( ctx *ExecContext, currentID model.ID, - mergeID model.ID, + stopID model.ID, activityMap map[model.ID]microflows.MicroflowObject, flowsByOrigin map[model.ID][]*microflows.SequenceFlow, flowsByDest map[model.ID][]*microflows.SequenceFlow, @@ -1378,6 +1378,11 @@ func emitInheritanceSplitStatement( indentStr := strings.Repeat(" ", indent) *lines = append(*lines, indentStr+"split type "+varName) + branchStopID := splitMergeMap[currentID] + if branchStopID == "" { + branchStopID = stopID + } + var elseFlow *microflows.SequenceFlow for _, flow := range orderedInheritanceSplitFlows(findNormalFlows(flowsByOrigin[currentID])) { caseName, ok := inheritanceCaseName(flow, entityNames) @@ -1386,11 +1391,11 @@ func emitInheritanceSplitStatement( continue } *lines = append(*lines, indentStr+"case "+caseName) - traverseFlowUntilMerge(ctx, flow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, cloneVisited(visited), entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) + traverseFlowUntilMerge(ctx, flow.DestinationID, branchStopID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, cloneVisited(visited), entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) } if elseFlow != nil { *lines = append(*lines, indentStr+"else") - traverseFlowUntilMerge(ctx, elseFlow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, cloneVisited(visited), entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) + traverseFlowUntilMerge(ctx, elseFlow.DestinationID, branchStopID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, cloneVisited(visited), entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) } *lines = append(*lines, indentStr+"end split;") }