Skip to content

Commit fd36ff9

Browse files
committed
feat: support enum split microflow statements
Symptom: enumeration decision splits could not be represented as first-class MDL, so describe/exec round-trips either collapsed them into boolean-looking splits or lost the enumeration case structure. Root cause: the microflow AST, parser, visitor, builder, describer, validator, and reference collectors only modeled boolean IF-style exclusive splits. Sequence flows with EnumerationCase values had no statement form. Fix: add a split enum statement with case/else bodies, emit and parse enumeration case values, build ExpressionSplitCondition graphs with EnumerationCase flows, describe existing enum split graphs back to MDL, and teach validation/layout/terminality/reference walks to recurse through enum branches. Tests: added parser, builder, describer, terminality, and validation regression tests plus a doctype fixture checked with mxcli check. Also ran make build, make lint-go, and make test.
1 parent d871691 commit fd36ff9

28 files changed

Lines changed: 11679 additions & 9920 deletions

.claude/skills/mendix/write-microflows.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,23 @@ if $entity/status = empty then
323323
end if;
324324
```
325325

326+
### ENUM SPLIT Statements
327+
328+
Use `split enum` when a microflow branches on an enumeration value.
329+
330+
```mdl
331+
split enum $Status
332+
case Open, Pending
333+
return true;
334+
case (empty)
335+
return false;
336+
else
337+
return false;
338+
end split;
339+
```
340+
341+
`(empty)` represents an unset enumeration value. Multiple values can share one branch by separating them with commas.
342+
326343
### LOOP Statements
327344

328345
```mdl

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ authentication basic, session
232232
| Color | `@color Green` | Background color (before activity) |
233233
| Annotation | `@annotation 'text'` | Visual note attached to next activity |
234234
| IF | `if condition then ... [else ...] end if;` | |
235+
| Enum split | `split enum $Var case Value ... end split;` | Enumeration decision branches |
235236
| LOOP | `loop $item in $list begin ... end loop;` | FOR EACH over list |
236237
| WHILE | `while condition begin ... end while;` | Condition-based loop |
237238
| Return | `return $value;` | Required at end of every flow path |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Proposal: Microflow ENUM SPLIT Statement
2+
3+
Status: Draft
4+
5+
## Summary
6+
7+
Add round-trip MDL support for enumeration decisions:
8+
9+
```mdl
10+
split enum $Status
11+
case Open, Pending
12+
return true;
13+
case (empty)
14+
return false;
15+
else
16+
return false;
17+
end split;
18+
```
19+
20+
## Motivation
21+
22+
Studio Pro represents enumeration decisions as exclusive splits whose outgoing sequence flows carry enumeration case values. Without a first-class MDL statement, describe/exec round-trips collapse those structures into boolean-looking decisions or unsupported comments.
23+
24+
## Semantics
25+
26+
`split enum` evaluates an enumeration variable or attribute path. Each `case` lists one or more enumeration values that enter the same branch. `(empty)` represents the Mendix empty enumeration case. `else` is optional and maps to the outgoing flow without an explicit case value.
27+
28+
## Tests And Examples
29+
30+
`mdl-examples/doctype-tests/enum_split_statement.test.mdl` demonstrates parser syntax. Go regression tests cover AST parsing, builder generation of enumeration case flows, and describer output for existing split graphs.
31+
32+
## Open Questions
33+
34+
- Should the builder validate case values against the referenced enumeration when backend metadata is available?
35+
- Should enum value names be emitted fully qualified in ambiguous cross-module cases?

docs/11-proposals/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ BSON schema Registry ◄──── multi-version Support
4747
| [Page Styling Support](page-styling-support.md) | Partial | CSS classes, inline styles, dynamic classes, design properties. Phase 1 (Class/Style) done ||
4848
| [Page Composition](proposal_page_composition.md) | Proposed | Fragment definitions and ALTER PAGE for partial page editing | Page Syntax V2, Page Styling |
4949
| [XPath Gaps](xpath-gaps-proposal.md) | Partial | XPath constraint support gap analysis. ~85% complete, association paths and nested predicates remain ||
50+
| [Microflow ENUM SPLIT Statement](PROPOSAL_microflow_enum_split_statement.md) | Draft | Preserve enumeration decision splits in microflow round-trips ||
5051
| [LLM MDL Assistance](PROPOSAL_llm_mdl_assistance.md) | Proposed | Enhanced error messages with examples, reorganized skills by use case ||
5152

5253
### Testing & Evaluation
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
create module EnumSplitExample;
2+
3+
create enumeration EnumSplitExample.Status (
4+
Open,
5+
Pending,
6+
Closed
7+
);
8+
/
9+
10+
create microflow EnumSplitExample.RouteStatus (
11+
$Status: enum EnumSplitExample.Status
12+
)
13+
returns boolean
14+
begin
15+
split enum $Status
16+
case Open, Pending
17+
return true;
18+
case Closed
19+
return false;
20+
else
21+
return false;
22+
end split;
23+
end;
24+
/

mdl/ast/ast_microflow.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,23 @@ type DeclareStmt struct {
7979

8080
func (s *DeclareStmt) isMicroflowStatement() {}
8181

82+
// EnumSplitCase represents one enumeration branch in an EnumSplit.
83+
type EnumSplitCase struct {
84+
Value string // First enumeration value, or "(empty)" for Mendix's empty enum case.
85+
Values []string
86+
Body []MicroflowStatement
87+
}
88+
89+
// EnumSplitStmt represents: SPLIT ENUM $Var ... END SPLIT
90+
type EnumSplitStmt struct {
91+
Variable string // Variable or attribute path without $ prefix (e.g. EventType or Event/EventType)
92+
Cases []EnumSplitCase
93+
ElseBody []MicroflowStatement
94+
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation
95+
}
96+
97+
func (s *EnumSplitStmt) isMicroflowStatement() {}
98+
8299
// MfSetStmt represents: SET $Var = expr or SET $Var/Attr = expr
83100
// (Named MfSetStmt to avoid conflict with existing SetStmt for SET key = value)
84101
type MfSetStmt struct {

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 _, c := range s.Cases {
343+
lines = append(lines, fmt.Sprintf("%scase %s", indentStr, formatEnumSplitCaseValues(enumSplitCaseValues(c))))
344+
for _, caseStmt := range c.Body {
345+
lines = append(lines, microflowStatementToMDL(ctx, caseStmt, 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: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,141 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID {
280280
return activity.ID
281281
}
282282

283+
func (fb *flowBuilder) addEnumSplit(s *ast.EnumSplitStmt) model.ID {
284+
if fb.measurer == nil {
285+
fb.measurer = &layoutMeasurer{varTypes: fb.varTypes}
286+
}
287+
288+
splitX := fb.posX
289+
centerY := fb.posY
290+
split := &microflows.ExclusiveSplit{
291+
BaseMicroflowObject: microflows.BaseMicroflowObject{
292+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
293+
Position: model.Point{X: splitX, Y: centerY},
294+
Size: model.Size{Width: SplitWidth, Height: SplitHeight},
295+
},
296+
Caption: "$" + s.Variable,
297+
SplitCondition: &microflows.ExpressionSplitCondition{
298+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
299+
Expression: "$" + s.Variable,
300+
},
301+
ErrorHandlingType: microflows.ErrorHandlingTypeRollback,
302+
}
303+
fb.objects = append(fb.objects, split)
304+
splitID := split.ID
305+
if fb.pendingAnnotations != nil {
306+
fb.applyAnnotations(splitID, fb.pendingAnnotations)
307+
fb.pendingAnnotations = nil
308+
}
309+
310+
type branch struct {
311+
values []string
312+
body []ast.MicroflowStatement
313+
}
314+
branches := make([]branch, 0, len(s.Cases)+1)
315+
for _, c := range s.Cases {
316+
branches = append(branches, branch{values: enumSplitCaseValues(c), body: c.Body})
317+
}
318+
if len(s.ElseBody) > 0 {
319+
branches = append(branches, branch{body: s.ElseBody})
320+
}
321+
322+
branchWidth := fb.measurer.measureStatements(appendEnumBodies(s)).Width
323+
if branchWidth == 0 {
324+
branchWidth = HorizontalSpacing / 2
325+
}
326+
mergeX := splitX + SplitWidth + HorizontalSpacing/2 + branchWidth + HorizontalSpacing/2
327+
merge := &microflows.ExclusiveMerge{
328+
BaseMicroflowObject: microflows.BaseMicroflowObject{
329+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
330+
Position: model.Point{X: mergeX, Y: centerY},
331+
Size: model.Size{Width: MergeSize, Height: MergeSize},
332+
},
333+
}
334+
fb.objects = append(fb.objects, merge)
335+
336+
savedEndsWithReturn := fb.endsWithReturn
337+
allBranchesReturn := len(branches) > 0
338+
for i, br := range branches {
339+
branchY := centerY + i*VerticalSpacing
340+
fb.posX = splitX + SplitWidth + HorizontalSpacing/2
341+
fb.posY = branchY
342+
fb.endsWithReturn = false
343+
344+
lastID := model.ID("")
345+
for _, stmt := range br.body {
346+
actID := fb.addStatement(stmt)
347+
if actID == "" {
348+
continue
349+
}
350+
if fb.pendingAnnotations != nil {
351+
fb.applyAnnotations(actID, fb.pendingAnnotations)
352+
fb.pendingAnnotations = nil
353+
}
354+
if lastID == "" {
355+
fb.addEnumSplitFlows(splitID, actID, br.values)
356+
} else {
357+
fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID))
358+
}
359+
if fb.nextConnectionPoint != "" {
360+
lastID = fb.nextConnectionPoint
361+
fb.nextConnectionPoint = ""
362+
} else {
363+
lastID = actID
364+
}
365+
}
366+
367+
if lastStmtIsReturn(br.body) {
368+
continue
369+
}
370+
allBranchesReturn = false
371+
if lastID == "" {
372+
fb.addEnumSplitFlows(splitID, merge.ID, br.values)
373+
} else {
374+
fb.flows = append(fb.flows, newHorizontalFlow(lastID, merge.ID))
375+
}
376+
}
377+
378+
fb.posX = mergeX + HorizontalSpacing/2
379+
fb.posY = centerY
380+
fb.endsWithReturn = savedEndsWithReturn
381+
if allBranchesReturn {
382+
fb.endsWithReturn = true
383+
} else {
384+
fb.nextConnectionPoint = merge.ID
385+
}
386+
return splitID
387+
}
388+
389+
func (fb *flowBuilder) addEnumSplitFlows(originID, destinationID model.ID, values []string) {
390+
if len(values) == 0 {
391+
fb.flows = append(fb.flows, newHorizontalFlow(originID, destinationID))
392+
return
393+
}
394+
for _, value := range values {
395+
fb.flows = append(fb.flows, newHorizontalFlowWithEnumCase(originID, destinationID, value))
396+
}
397+
}
398+
399+
func enumSplitCaseValues(c ast.EnumSplitCase) []string {
400+
if len(c.Values) > 0 {
401+
return append([]string(nil), c.Values...)
402+
}
403+
if c.Value != "" {
404+
return []string{c.Value}
405+
}
406+
return nil
407+
}
408+
409+
func appendEnumBodies(s *ast.EnumSplitStmt) []ast.MicroflowStatement {
410+
var stmts []ast.MicroflowStatement
411+
for _, c := range s.Cases {
412+
stmts = append(stmts, c.Body...)
413+
}
414+
stmts = append(stmts, s.ElseBody...)
415+
return stmts
416+
}
417+
283418
// addRetrieveAction creates a RETRIEVE statement.
284419
func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
285420
var source microflows.RetrieveSource

mdl/executor/cmd_microflows_builder_annotations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ func getStatementAnnotations(stmt ast.MicroflowStatement) *ast.ActivityAnnotatio
3535
return s.Annotations
3636
case *ast.IfStmt:
3737
return s.Annotations
38+
case *ast.EnumSplitStmt:
39+
return s.Annotations
3840
case *ast.LoopStmt:
3941
return s.Annotations
4042
case *ast.WhileStmt:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/ast"
9+
"github.com/mendixlabs/mxcli/sdk/microflows"
10+
)
11+
12+
func TestEnumSplitBuilderCreatesEnumerationCaseFlows(t *testing.T) {
13+
fb := &flowBuilder{
14+
spacing: HorizontalSpacing,
15+
measurer: &layoutMeasurer{},
16+
}
17+
18+
fb.addEnumSplit(&ast.EnumSplitStmt{
19+
Variable: "Status",
20+
Cases: []ast.EnumSplitCase{
21+
{
22+
Values: []string{"Open", "Pending"},
23+
Body: []ast.MicroflowStatement{
24+
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "open"}},
25+
},
26+
},
27+
},
28+
ElseBody: []ast.MicroflowStatement{
29+
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "other"}},
30+
},
31+
})
32+
33+
var split *microflows.ExclusiveSplit
34+
for _, obj := range fb.objects {
35+
if candidate, ok := obj.(*microflows.ExclusiveSplit); ok {
36+
split = candidate
37+
break
38+
}
39+
}
40+
if split == nil {
41+
t.Fatal("Expected ExclusiveSplit")
42+
}
43+
cond, ok := split.SplitCondition.(*microflows.ExpressionSplitCondition)
44+
if !ok {
45+
t.Fatalf("SplitCondition = %T, want ExpressionSplitCondition", split.SplitCondition)
46+
}
47+
if cond.Expression != "$Status" {
48+
t.Fatalf("Expression = %q, want $Status", cond.Expression)
49+
}
50+
51+
var cases []string
52+
for _, flow := range fb.flows {
53+
if flow.OriginID != split.ID {
54+
continue
55+
}
56+
if value, ok := enumCaseValue(flow); ok {
57+
cases = append(cases, value)
58+
}
59+
}
60+
if len(cases) != 2 || cases[0] != "Open" || cases[1] != "Pending" {
61+
t.Fatalf("enum case flows = %v, want [Open Pending]", cases)
62+
}
63+
}

0 commit comments

Comments
 (0)