Skip to content

Commit bddf5aa

Browse files
committed
fix: preserve enum split roundtrip validity
Symptom: enum-based microflow dispatchers described as boolean IFs, duplicated shared continuation tails inside each case, or rebuilt grouped enum flows with invalid Studio Pro topology. Root cause: the MDL AST, parser, describer, and builder had no explicit enum split representation. Split pairing also required every branch to reach the same join, so a terminal enum case prevented shared non-terminal tails from being emitted once after the split. Fix: add `split enum` parsing, formatting, validation, and graph building; preserve grouped enum cases through valid intermediate merges; treat exhaustive terminal enum splits as terminating; keep repeated call-output declarations branch-local; and let split pairing select a partial shared join when all non-reaching branches terminate before it. Tests: make build; make test; targeted Control Center audit `targeted-structural-enum-partial-join-20260426T203027Z` returned 8 match, 0 discrepancies, and clean Mendix mx check results.
1 parent a909665 commit bddf5aa

24 files changed

Lines changed: 12780 additions & 10300 deletions

mdl/ast/ast_microflow.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,23 @@ type InheritanceSplitStmt struct {
9595

9696
func (s *InheritanceSplitStmt) isMicroflowStatement() {}
9797

98+
// EnumSplitCase represents one enumeration branch in an EnumSplit.
99+
type EnumSplitCase struct {
100+
Value string // First enumeration value, or "(empty)" for Mendix's empty enum case.
101+
Values []string // All enumeration values that share this branch.
102+
Body []MicroflowStatement
103+
}
104+
105+
// EnumSplitStmt represents: SPLIT ENUM $Var ... END SPLIT
106+
type EnumSplitStmt struct {
107+
Variable string // Variable or attribute path without $ prefix (e.g. EventType or Event/EventType)
108+
Cases []EnumSplitCase
109+
ElseBody []MicroflowStatement
110+
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation
111+
}
112+
113+
func (s *EnumSplitStmt) isMicroflowStatement() {}
114+
98115
// CastObjectStmt represents: $Output = CAST $Object
99116
type CastObjectStmt struct {
100117
OutputVariable string // Output variable name (without $ prefix)

mdl/executor/cmd_diff_mdl.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,22 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde
337337
}
338338
lines = append(lines, indentStr+"end if;")
339339

340+
case *ast.EnumSplitStmt:
341+
lines = append(lines, fmt.Sprintf("%ssplit enum $%s", indentStr, s.Variable))
342+
for _, branch := range s.Cases {
343+
lines = append(lines, fmt.Sprintf("%scase %s", indentStr, formatEnumSplitCaseValues(enumSplitCaseValues(branch))))
344+
for _, branchStmt := range branch.Body {
345+
lines = append(lines, microflowStatementToMDL(ctx, branchStmt, indent+1)...)
346+
}
347+
}
348+
if len(s.ElseBody) > 0 {
349+
lines = append(lines, indentStr+"else")
350+
for _, elseStmt := range s.ElseBody {
351+
lines = append(lines, microflowStatementToMDL(ctx, elseStmt, indent+1)...)
352+
}
353+
}
354+
lines = append(lines, indentStr+"end split;")
355+
340356
case *ast.LoopStmt:
341357
lines = append(lines, fmt.Sprintf("%sloop $%s in $%s", indentStr, s.LoopVariable, s.ListVariable))
342358
for _, bodyStmt := range s.Body {

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,298 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt
259259
return splitID
260260
}
261261

262+
func (fb *flowBuilder) addEnumSplit(s *ast.EnumSplitStmt) model.ID {
263+
splitX := fb.posX
264+
centerY := fb.posY
265+
split := &microflows.ExclusiveSplit{
266+
BaseMicroflowObject: microflows.BaseMicroflowObject{
267+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
268+
Position: model.Point{X: splitX, Y: centerY},
269+
Size: model.Size{Width: SplitWidth, Height: SplitHeight},
270+
},
271+
Caption: "$" + s.Variable,
272+
SplitCondition: &microflows.ExpressionSplitCondition{
273+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
274+
Expression: "$" + s.Variable,
275+
},
276+
ErrorHandlingType: microflows.ErrorHandlingTypeRollback,
277+
}
278+
fb.objects = append(fb.objects, split)
279+
280+
splitID := split.ID
281+
if fb.pendingAnnotations != nil {
282+
fb.applyAnnotations(splitID, fb.pendingAnnotations)
283+
fb.pendingAnnotations = nil
284+
}
285+
286+
savedEndsWithReturn := fb.endsWithReturn
287+
fb.endsWithReturn = false
288+
allBranchesReturn := len(s.Cases) > 0 || len(s.ElseBody) > 0
289+
branchStartX := splitX + SplitWidth + HorizontalSpacing/2
290+
branchIndex := 0
291+
type branchTail struct {
292+
id model.ID
293+
values []string
294+
fromSplit bool
295+
}
296+
var branchTails []branchTail
297+
routePendingErrorToElse := len(s.ElseBody) > 0 && fb.errorHandlerSkipVar != "" && s.Variable == fb.errorHandlerSkipVar
298+
pendingErrorForElse := pendingErrorHandlerState{}
299+
300+
addBranch := func(values []string, body []ast.MicroflowStatement) {
301+
branchNumber := branchIndex
302+
branchY := centerY + branchNumber*VerticalSpacing
303+
branchIndex++
304+
if len(body) == 0 {
305+
allBranchesReturn = false
306+
branchTails = append(branchTails, branchTail{id: splitID, values: values, fromSplit: true})
307+
return
308+
}
309+
310+
fb.posX = branchStartX
311+
fb.posY = branchY
312+
fb.endsWithReturn = false
313+
branchEntryID := splitID
314+
branchEntryIsSplit := true
315+
if len(values) > 1 {
316+
branchMerge := &microflows.ExclusiveMerge{
317+
BaseMicroflowObject: microflows.BaseMicroflowObject{
318+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
319+
Position: model.Point{X: branchStartX, Y: branchY},
320+
Size: model.Size{Width: MergeSize, Height: MergeSize},
321+
},
322+
}
323+
fb.objects = append(fb.objects, branchMerge)
324+
for i, value := range values {
325+
var flow *microflows.SequenceFlow
326+
if enumSplitUsesHorizontalOrigin(values) {
327+
flow = newHorizontalFlowWithEnumCase(splitID, branchMerge.ID, value)
328+
} else {
329+
flow = newDownwardFlowWithEnumCase(splitID, branchMerge.ID, value)
330+
}
331+
if i > 0 {
332+
flow.DestinationConnectionIndex = enumSplitGroupedDestinationAnchor(AnchorLeft, i)
333+
}
334+
applyEnumGroupedControlVectors(flow, i, len(values))
335+
fb.flows = append(fb.flows, flow)
336+
}
337+
branchEntryID = branchMerge.ID
338+
branchEntryIsSplit = false
339+
fb.posX = branchStartX + HorizontalSpacing/2
340+
}
341+
342+
var lastID model.ID
343+
var prevAnchor *ast.FlowAnchors
344+
var pendingCase string
345+
var pendingAnchor *ast.FlowAnchors
346+
for i, stmt := range body {
347+
thisAnchor := stmtOwnAnchor(stmt)
348+
actID := fb.addStatement(stmt)
349+
if actID == "" {
350+
continue
351+
}
352+
fb.applyPendingAnnotations(actID)
353+
if lastID == "" {
354+
var flow *microflows.SequenceFlow
355+
if branchEntryIsSplit {
356+
if enumSplitUsesHorizontalOrigin(values) {
357+
flow = newHorizontalFlowWithEnumCase(splitID, actID, enumSplitFlowCaseValue(values, 0))
358+
} else {
359+
flow = newDownwardFlowWithEnumCase(splitID, actID, enumSplitFlowCaseValue(values, 0))
360+
}
361+
} else {
362+
flow = newHorizontalFlow(branchEntryID, actID)
363+
}
364+
if thisAnchor != nil && thisAnchor.To != ast.AnchorSideUnset {
365+
flow.DestinationConnectionIndex = int(thisAnchor.To)
366+
}
367+
baseDestinationAnchor := flow.DestinationConnectionIndex
368+
if branchEntryIsSplit {
369+
applyEnumGroupedControlVectors(flow, 0, len(values))
370+
}
371+
fb.flows = append(fb.flows, flow)
372+
if branchEntryIsSplit {
373+
for i := 1; i < len(values); i++ {
374+
var extraFlow *microflows.SequenceFlow
375+
if enumSplitUsesHorizontalOrigin(values) {
376+
extraFlow = newHorizontalFlowWithEnumCase(splitID, actID, values[i])
377+
} else {
378+
extraFlow = newDownwardFlowWithEnumCase(splitID, actID, values[i])
379+
}
380+
if thisAnchor != nil && thisAnchor.To != ast.AnchorSideUnset {
381+
extraFlow.DestinationConnectionIndex = int(thisAnchor.To)
382+
}
383+
extraFlow.DestinationConnectionIndex = enumSplitGroupedDestinationAnchor(baseDestinationAnchor, i)
384+
applyEnumGroupedControlVectors(extraFlow, i, len(values))
385+
fb.flows = append(fb.flows, extraFlow)
386+
}
387+
}
388+
if routePendingErrorToElse && len(values) == 0 {
389+
fb.routePendingErrorHandlerToAlternative(splitID, actID)
390+
}
391+
fb.addPendingErrorHandlerFlowForStatement(flow.OriginID, flow.DestinationID, stmt, statementsReferenceVar(body[i+1:], fb.errorHandlerSkipVar))
392+
} else {
393+
var flow *microflows.SequenceFlow
394+
originAnchor := prevAnchor
395+
destAnchor := thisAnchor
396+
if pendingCase != "" {
397+
flow = newHorizontalFlowWithCase(lastID, actID, pendingCase)
398+
originAnchor, destAnchor = pendingFlowAnchors(prevAnchor, pendingAnchor, thisAnchor)
399+
pendingCase = ""
400+
pendingAnchor = nil
401+
} else {
402+
flow = newHorizontalFlow(lastID, actID)
403+
}
404+
applyUserAnchors(flow, originAnchor, destAnchor)
405+
fb.flows = append(fb.flows, flow)
406+
fb.addPendingErrorHandlerFlowForStatement(flow.OriginID, flow.DestinationID, stmt, statementsReferenceVar(body[i+1:], fb.errorHandlerSkipVar))
407+
}
408+
prevAnchor = thisAnchor
409+
if fb.nextConnectionPoint != "" {
410+
lastID = fb.nextConnectionPoint
411+
fb.nextConnectionPoint = ""
412+
pendingCase = fb.nextFlowCase
413+
fb.nextFlowCase = ""
414+
pendingAnchor = fb.nextFlowAnchor
415+
fb.nextFlowAnchor = nil
416+
} else {
417+
lastID = actID
418+
}
419+
}
420+
if !lastStmtIsReturn(body) {
421+
allBranchesReturn = false
422+
if lastID != "" {
423+
branchTails = append(branchTails, branchTail{id: lastID})
424+
}
425+
}
426+
}
427+
428+
if routePendingErrorToElse {
429+
pendingErrorForElse = fb.capturePendingErrorHandler()
430+
fb.clearPendingErrorHandler()
431+
}
432+
for _, c := range s.Cases {
433+
addBranch(enumSplitCaseValues(c), c.Body)
434+
}
435+
if routePendingErrorToElse && fb.capturePendingErrorHandler().isEmpty() {
436+
fb.restorePendingErrorHandler(pendingErrorForElse)
437+
}
438+
if len(s.ElseBody) > 0 {
439+
addBranch(nil, s.ElseBody)
440+
}
441+
442+
fb.posX = branchStartX + fb.measurer.measureStatements(appendEnumBodies(s)).Width + HorizontalSpacing/2
443+
fb.posY = centerY
444+
fb.endsWithReturn = savedEndsWithReturn
445+
if allBranchesReturn {
446+
fb.endsWithReturn = true
447+
} else if len(branchTails) == 1 && !branchTails[0].fromSplit {
448+
fb.nextConnectionPoint = branchTails[0].id
449+
} else if len(branchTails) > 0 {
450+
merge := &microflows.ExclusiveMerge{
451+
BaseMicroflowObject: microflows.BaseMicroflowObject{
452+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
453+
Position: model.Point{X: fb.posX, Y: centerY},
454+
Size: model.Size{Width: MergeSize, Height: MergeSize},
455+
},
456+
}
457+
fb.objects = append(fb.objects, merge)
458+
for _, tail := range branchTails {
459+
if tail.fromSplit {
460+
var flow *microflows.SequenceFlow
461+
if enumSplitUsesHorizontalOrigin(tail.values) {
462+
flow = newHorizontalFlowWithEnumCase(splitID, merge.ID, enumSplitFlowCaseValue(tail.values, 0))
463+
} else {
464+
flow = newDownwardFlowWithEnumCase(splitID, merge.ID, enumSplitFlowCaseValue(tail.values, 0))
465+
}
466+
baseDestinationAnchor := flow.DestinationConnectionIndex
467+
applyEnumGroupedControlVectors(flow, 0, len(tail.values))
468+
fb.flows = append(fb.flows, flow)
469+
for i := 1; i < len(tail.values); i++ {
470+
var extraFlow *microflows.SequenceFlow
471+
if enumSplitUsesHorizontalOrigin(tail.values) {
472+
extraFlow = newHorizontalFlowWithEnumCase(splitID, merge.ID, tail.values[i])
473+
} else {
474+
extraFlow = newDownwardFlowWithEnumCase(splitID, merge.ID, tail.values[i])
475+
}
476+
extraFlow.DestinationConnectionIndex = enumSplitGroupedDestinationAnchor(baseDestinationAnchor, i)
477+
applyEnumGroupedControlVectors(extraFlow, i, len(tail.values))
478+
fb.flows = append(fb.flows, extraFlow)
479+
}
480+
} else {
481+
fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID))
482+
}
483+
}
484+
fb.nextConnectionPoint = merge.ID
485+
}
486+
return splitID
487+
}
488+
489+
func enumSplitCaseValues(c ast.EnumSplitCase) []string {
490+
if len(c.Values) > 0 {
491+
return append([]string(nil), c.Values...)
492+
}
493+
if c.Value != "" {
494+
return []string{c.Value}
495+
}
496+
return nil
497+
}
498+
499+
func enumSplitFlowCaseValue(values []string, index int) string {
500+
if index >= 0 && index < len(values) {
501+
return values[index]
502+
}
503+
return ""
504+
}
505+
506+
func enumSplitUsesHorizontalOrigin(values []string) bool {
507+
return len(values) > 0 && values[0] != "" && values[0] != "(empty)"
508+
}
509+
510+
func enumSplitGroupedDestinationAnchor(baseAnchor, index int) int {
511+
if index == 0 {
512+
return baseAnchor
513+
}
514+
offset := 0
515+
for _, candidate := range []int{AnchorTop, AnchorBottom, AnchorRight, AnchorLeft} {
516+
if candidate == baseAnchor {
517+
continue
518+
}
519+
if offset == index-1 {
520+
return candidate
521+
}
522+
offset++
523+
}
524+
return baseAnchor
525+
}
526+
527+
func applyEnumGroupedControlVectors(flow *microflows.SequenceFlow, index, total int) {
528+
if flow == nil || total == 0 {
529+
return
530+
}
531+
switch {
532+
case flow.OriginConnectionIndex == AnchorRight && flow.DestinationConnectionIndex == AnchorLeft:
533+
flow.OriginControlVector = "15;0"
534+
if total > 1 {
535+
flow.DestinationControlVector = "-15;0"
536+
} else {
537+
flow.DestinationControlVector = "-30;0"
538+
}
539+
case flow.OriginConnectionIndex == AnchorRight && flow.DestinationConnectionIndex == AnchorTop:
540+
flow.OriginControlVector = "0;-95"
541+
flow.DestinationControlVector = "15;-45"
542+
case flow.OriginConnectionIndex == AnchorRight && flow.DestinationConnectionIndex == AnchorBottom:
543+
flow.OriginControlVector = "0;95"
544+
flow.DestinationControlVector = "15;45"
545+
case flow.OriginConnectionIndex == AnchorBottom && flow.DestinationConnectionIndex == AnchorTop:
546+
flow.OriginControlVector = "0;15"
547+
flow.DestinationControlVector = "0;-30"
548+
case index > 0:
549+
flow.OriginControlVector = "15;0"
550+
flow.DestinationControlVector = "-15;0"
551+
}
552+
}
553+
262554
func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStatement {
263555
var stmts []ast.MicroflowStatement
264556
for _, c := range s.Cases {
@@ -268,6 +560,15 @@ func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStateme
268560
return stmts
269561
}
270562

563+
func appendEnumBodies(s *ast.EnumSplitStmt) []ast.MicroflowStatement {
564+
var stmts []ast.MicroflowStatement
565+
for _, c := range s.Cases {
566+
stmts = append(stmts, c.Body...)
567+
}
568+
stmts = append(stmts, s.ElseBody...)
569+
return stmts
570+
}
571+
271572
func qualifiedNameString(qn ast.QualifiedName) string {
272573
if qn.Module == "" {
273574
return qn.Name

mdl/executor/cmd_microflows_builder_annotations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ func getStatementAnnotations(stmt ast.MicroflowStatement) *ast.ActivityAnnotatio
1717
return s.Annotations
1818
case *ast.InheritanceSplitStmt:
1919
return s.Annotations
20+
case *ast.EnumSplitStmt:
21+
return s.Annotations
2022
case *ast.CastObjectStmt:
2123
return s.Annotations
2224
case *ast.MfSetStmt:

0 commit comments

Comments
 (0)