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..d636f784 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -422,6 +422,186 @@ 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 + order int + anchor *ast.FlowAnchors + } + 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, order: branchNumber}) + return + } + + fb.posX = branchStartX + fb.posY = branchY + 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 + } + 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) + } + applyUserAnchors(flow, nil, thisAnchor) + fb.flows = append(fb.flows, flow) + } else { + if pendingCase != "" { + flow := newHorizontalFlowWithCase(lastID, actID, pendingCase) + applyUserAnchors(flow, prevAnchor, thisAnchor) + fb.flows = append(fb.flows, flow) + pendingCase = "" + } else { + flow := newHorizontalFlow(lastID, actID) + applyUserAnchors(flow, prevAnchor, thisAnchor) + fb.flows = append(fb.flows, flow) + } + } + prevAnchor = thisAnchor + if fb.nextConnectionPoint != "" { + lastID = fb.nextConnectionPoint + fb.nextConnectionPoint = "" + pendingCase = fb.nextFlowCase + fb.nextFlowCase = "" + } else { + lastID = actID + } + } + + if !lastStmtIsReturn(body) { + allBranchesReturn = false + if lastID != "" { + branchTails = append(branchTails, branchTail{id: lastID, caseValue: pendingCase, anchor: prevAnchor}) + } + } + } + + 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 + fb.nextFlowCase = branchTails[0].caseValue + } 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 { + var flow *microflows.SequenceFlow + if tail.order == 0 { + flow = newHorizontalFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue) + } else { + flow = newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue) + } + applyInheritanceSplitCaseOrder(flow, tail.order) + fb.flows = append(fb.flows, flow) + } else { + if tail.caseValue != "" { + flow := newHorizontalFlowWithCase(tail.id, merge.ID, tail.caseValue) + applyUserAnchors(flow, tail.anchor, nil) + fb.flows = append(fb.flows, flow) + } else { + flow := newHorizontalFlow(tail.id, merge.ID) + applyUserAnchors(flow, tail.anchor, nil) + fb.flows = append(fb.flows, flow) + } + } + } + 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 +698,79 @@ 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 +} + +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 + } + 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..ef93009a --- /dev/null +++ b/mdl/executor/cmd_microflows_inheritance_test.go @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "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 caseValue, ok := flow.CaseValue.(*microflows.InheritanceCase); ok && caseValue.EntityQualifiedName == "Sample.SpecializedInput" { + 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 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 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{ + 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 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 + } + // 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 { + return + } + } + 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 { + 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..2225d058 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, 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 + 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,56 @@ func emitEnumSplitStatement( *lines = append(*lines, indentStr+"end case;") } +func emitInheritanceSplitStatement( + ctx *ExecContext, + currentID model.ID, + stopID 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) + + branchStopID := splitMergeMap[currentID] + if branchStopID == "" { + branchStopID = stopID + } + + var elseFlow *microflows.SequenceFlow + for _, flow := range orderedInheritanceSplitFlows(findNormalFlows(flowsByOrigin[currentID])) { + caseName, ok := inheritanceCaseName(flow, entityNames) + if !ok { + elseFlow = flow + continue + } + *lines = append(*lines, indentStr+"case "+caseName) + 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, branchStopID, 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 +1438,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 { @@ -1366,6 +1469,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 @@ -1378,6 +1489,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)" 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))},