Skip to content

Commit 620e294

Browse files
committed
feat: support inheritance split and cast statements
Symptom: type-based microflow decisions and cast actions could be read from MPRs but had no first-class MDL representation, so describe/exec round-trips could not preserve InheritanceSplit and CastAction graphs. Root cause: the microflow AST, grammar, visitor, builder, describer, validator, and MPR writer only modeled boolean exclusive splits and regular actions. InheritanceCase sequence flows and CastAction BSON were not emitted back to valid project data. Fix: add split type and cast statements, parse and build inheritance branches, describe existing InheritanceSplit graphs by resolving case entity names, serialize inheritance split/case/cast BSON, and recurse through type-split bodies in validation/reference/layout code. Tests: added parser, builder, describer, terminality, validation, and MPR writer regressions plus a doctype fixture checked with mxcli check. Also ran make build, make lint-go, and make test.
1 parent e3bf4d2 commit 620e294

33 files changed

Lines changed: 11962 additions & 10214 deletions

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,22 @@ end split;
339339
```
340340

341341
`(empty)` represents an unset enumeration value. Multiple values can share one branch by separating them with commas.
342+
### Type Split And Cast Statements
343+
344+
Use `split type` when a microflow branches on an object's runtime specialization.
345+
Use `cast` inside a type branch to create the specialized variable used by the branch body.
346+
347+
```mdl
348+
split type $Input
349+
case Sample.SpecializedInput
350+
cast $SpecificInput;
351+
return true;
352+
else
353+
return false;
354+
end split;
355+
```
356+
357+
`case` values are qualified entity names. The optional `else` branch handles objects that do not match any listed specialization.
342358

343359
### LOOP Statements
344360

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ authentication basic, session
242242
| Free annotation | `@annotation 'text'` before `@position(...)` | Free-floating visual note preserved by order |
243243
| IF | `if condition then ... [else ...] end if;` | |
244244
| Enum split | `split enum $Var case Value ... end split;` | Enumeration decision branches |
245+
| Type split | `split type $Var case Module.Entity ... end split;` | Runtime specialization branches |
246+
| Cast | `cast $SpecificVar;` | Downcast inside a type split branch |
245247
| LOOP | `loop $item in $list begin ... end loop;` | FOR EACH over list |
246248
| WHILE | `while condition begin ... end while;` | Condition-based loop |
247249
| 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 Inheritance Split And Cast Statements
2+
3+
Status: Draft
4+
5+
## Summary
6+
7+
Add round-trip MDL support for type-based microflow decisions and cast actions:
8+
9+
```mdl
10+
split type $Input
11+
case Sample.SpecializedInput
12+
cast $SpecificInput;
13+
else
14+
return false;
15+
end split;
16+
```
17+
18+
## Motivation
19+
20+
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.
21+
22+
## Semantics
23+
24+
`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.
25+
26+
`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.
27+
28+
## Tests And Examples
29+
30+
`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.
31+
32+
## Open Questions
33+
34+
- Should `exec` validate `case Module.Entity` against the project's specialization hierarchy when connected?
35+
- 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?

docs/11-proposals/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ BSON schema Registry ◄──── multi-version Support
5252
| [XPath Gaps](xpath-gaps-proposal.md) | Partial | XPath constraint support gap analysis. ~85% complete, association paths and nested predicates remain ||
5353
| [Microflow ENUM SPLIT Statement](PROPOSAL_microflow_enum_split_statement.md) | Draft | Preserve enumeration decision splits in microflow round-trips ||
5454
| [Microflow CHANGE Refresh Modifier](PROPOSAL_microflow_change_refresh_modifier.md) | Draft | Preserve `RefreshInClient` on change-object actions ||
55+
| [Microflow Inheritance Split And Cast Statements](PROPOSAL_microflow_inheritance_split_statement.md) | Draft | Preserve type-based microflow decisions and cast actions in round-trips ||
5556
| [LLM MDL Assistance](PROPOSAL_llm_mdl_assistance.md) | Proposed | Enhanced error messages with examples, reorganized skills by use case ||
5657

5758
### Testing & Evaluation
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
create module InheritanceSplitExample;
2+
3+
create persistent entity InheritanceSplitExample.BaseInput (
4+
Name: String(200)
5+
);
6+
/
7+
8+
create persistent entity InheritanceSplitExample.SpecializedInput extends InheritanceSplitExample.BaseInput (
9+
Code: String(50)
10+
);
11+
/
12+
13+
create microflow InheritanceSplitExample.RouteInput (
14+
$Input: InheritanceSplitExample.BaseInput
15+
)
16+
returns boolean
17+
begin
18+
split type $Input
19+
case InheritanceSplitExample.SpecializedInput
20+
cast $SpecializedInput;
21+
return true;
22+
else
23+
return false;
24+
end split;
25+
end;
26+
/

mdl/ast/ast_microflow.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,31 @@ type EnumSplitStmt struct {
116116
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation
117117
}
118118

119+
// InheritanceSplitCase represents one typed branch in an inheritance split.
120+
type InheritanceSplitCase struct {
121+
Entity QualifiedName
122+
Body []MicroflowStatement
123+
}
124+
125+
// InheritanceSplitStmt represents: SPLIT TYPE $Var ... END SPLIT
126+
type InheritanceSplitStmt struct {
127+
Variable string // Variable name without $ prefix
128+
Cases []InheritanceSplitCase
129+
ElseBody []MicroflowStatement
130+
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation
131+
}
132+
119133
func (s *EnumSplitStmt) isMicroflowStatement() {}
134+
func (s *InheritanceSplitStmt) isMicroflowStatement() {}
135+
136+
// CastObjectStmt represents: $Output = CAST $Object
137+
type CastObjectStmt struct {
138+
OutputVariable string // Output variable name without $ prefix
139+
ObjectVariable string // Source object variable name without $ prefix
140+
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation
141+
}
142+
143+
func (s *CastObjectStmt) isMicroflowStatement() {}
120144

121145
// MfSetStmt represents: SET $Var = expr or SET $Var/Attr = expr
122146
// (Named MfSetStmt to avoid conflict with existing SetStmt for SET key = value)

mdl/executor/cmd_diff_mdl.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,29 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde
447447
}
448448
lines = append(lines, indentStr+"end split;")
449449

450+
case *ast.InheritanceSplitStmt:
451+
lines = append(lines, fmt.Sprintf("%ssplit type $%s", indentStr, s.Variable))
452+
for _, c := range s.Cases {
453+
lines = append(lines, fmt.Sprintf("%scase %s", indentStr, c.Entity.String()))
454+
for _, caseStmt := range c.Body {
455+
lines = append(lines, microflowStatementToMDL(ctx, caseStmt, indent+1)...)
456+
}
457+
}
458+
if len(s.ElseBody) > 0 {
459+
lines = append(lines, indentStr+"else")
460+
for _, elseStmt := range s.ElseBody {
461+
lines = append(lines, microflowStatementToMDL(ctx, elseStmt, indent+1)...)
462+
}
463+
}
464+
lines = append(lines, indentStr+"end split;")
465+
466+
case *ast.CastObjectStmt:
467+
if s.ObjectVariable == "" {
468+
lines = append(lines, fmt.Sprintf("%scast $%s;", indentStr, s.OutputVariable))
469+
} else {
470+
lines = append(lines, fmt.Sprintf("%s$%s = cast $%s;", indentStr, s.OutputVariable, s.ObjectVariable))
471+
}
472+
450473
case *ast.LoopStmt:
451474
lines = append(lines, fmt.Sprintf("%sloop $%s in $%s", indentStr, s.LoopVariable, s.ListVariable))
452475
for _, bodyStmt := range s.Body {

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,159 @@ func (fb *flowBuilder) addEnumSplit(s *ast.EnumSplitStmt) model.ID {
406406
return splitID
407407
}
408408

409+
func (fb *flowBuilder) addInheritanceSplit(s *ast.InheritanceSplitStmt) model.ID {
410+
if len(s.Cases) == 0 && len(s.ElseBody) == 0 {
411+
split := &microflows.InheritanceSplit{
412+
BaseMicroflowObject: microflows.BaseMicroflowObject{
413+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
414+
Position: model.Point{X: fb.posX, Y: fb.posY},
415+
Size: model.Size{Width: ActivityWidth, Height: ActivityHeight},
416+
},
417+
ErrorHandlingType: microflows.ErrorHandlingTypeRollback,
418+
VariableName: s.Variable,
419+
}
420+
fb.objects = append(fb.objects, split)
421+
fb.posX += fb.spacing
422+
return split.ID
423+
}
424+
return fb.addStructuredInheritanceSplit(s)
425+
}
426+
427+
func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt) model.ID {
428+
if fb.measurer == nil {
429+
fb.measurer = &layoutMeasurer{varTypes: fb.varTypes}
430+
}
431+
432+
splitX := fb.posX
433+
centerY := fb.posY
434+
split := &microflows.InheritanceSplit{
435+
BaseMicroflowObject: microflows.BaseMicroflowObject{
436+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
437+
Position: model.Point{X: splitX, Y: centerY},
438+
Size: model.Size{Width: ActivityWidth, Height: ActivityHeight},
439+
},
440+
ErrorHandlingType: microflows.ErrorHandlingTypeRollback,
441+
VariableName: s.Variable,
442+
}
443+
fb.objects = append(fb.objects, split)
444+
splitID := split.ID
445+
if fb.pendingAnnotations != nil {
446+
fb.applyAnnotations(splitID, fb.pendingAnnotations)
447+
fb.pendingAnnotations = nil
448+
}
449+
450+
branchWidth := fb.measurer.measureStatements(appendInheritanceBodies(s)).Width
451+
if branchWidth == 0 {
452+
branchWidth = HorizontalSpacing / 2
453+
}
454+
branchStartX := splitX + ActivityWidth + HorizontalSpacing/2
455+
mergeX := branchStartX + branchWidth + HorizontalSpacing/2
456+
457+
type branchTail struct {
458+
id model.ID
459+
caseValue string
460+
fromSplit bool
461+
}
462+
var branchTails []branchTail
463+
464+
savedEndsWithReturn := fb.endsWithReturn
465+
allBranchesReturn := len(s.Cases) > 0 && len(s.ElseBody) > 0
466+
branchIndex := 0
467+
468+
addBranch := func(caseValue string, body []ast.MicroflowStatement) {
469+
branchNumber := branchIndex
470+
branchY := centerY + branchIndex*VerticalSpacing
471+
branchIndex++
472+
if len(body) == 0 {
473+
allBranchesReturn = false
474+
branchTails = append(branchTails, branchTail{id: splitID, caseValue: caseValue, fromSplit: true})
475+
return
476+
}
477+
478+
fb.posX = branchStartX
479+
fb.posY = branchY
480+
fb.endsWithReturn = false
481+
482+
var lastID model.ID
483+
for _, stmt := range body {
484+
actID := fb.addStatement(stmt)
485+
if actID == "" {
486+
continue
487+
}
488+
if cast, ok := stmt.(*ast.CastObjectStmt); ok && cast.OutputVariable != "" && caseValue != "" && fb.varTypes != nil {
489+
fb.varTypes[cast.OutputVariable] = caseValue
490+
}
491+
if fb.pendingAnnotations != nil {
492+
fb.applyAnnotations(actID, fb.pendingAnnotations)
493+
fb.pendingAnnotations = nil
494+
}
495+
if lastID == "" {
496+
var flow *microflows.SequenceFlow
497+
if branchNumber == 0 {
498+
flow = newHorizontalFlowWithInheritanceCase(splitID, actID, caseValue)
499+
} else {
500+
flow = newDownwardFlowWithInheritanceCase(splitID, actID, caseValue)
501+
}
502+
if caseValue == "" {
503+
flow = newHorizontalFlow(splitID, actID)
504+
}
505+
fb.flows = append(fb.flows, flow)
506+
} else {
507+
fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID))
508+
}
509+
if fb.nextConnectionPoint != "" {
510+
lastID = fb.nextConnectionPoint
511+
fb.nextConnectionPoint = ""
512+
} else {
513+
lastID = actID
514+
}
515+
}
516+
517+
if !lastStmtIsReturn(body) {
518+
allBranchesReturn = false
519+
if lastID != "" {
520+
branchTails = append(branchTails, branchTail{id: lastID})
521+
}
522+
}
523+
}
524+
525+
for _, c := range s.Cases {
526+
addBranch(qualifiedNameString(c.Entity), c.Body)
527+
}
528+
addBranch("", s.ElseBody)
529+
530+
fb.posX = mergeX
531+
fb.posY = centerY
532+
fb.endsWithReturn = savedEndsWithReturn
533+
if allBranchesReturn {
534+
fb.endsWithReturn = true
535+
} else if len(branchTails) == 1 && !branchTails[0].fromSplit {
536+
fb.nextConnectionPoint = branchTails[0].id
537+
} else if len(branchTails) > 0 {
538+
merge := &microflows.ExclusiveMerge{
539+
BaseMicroflowObject: microflows.BaseMicroflowObject{
540+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
541+
Position: model.Point{X: mergeX, Y: centerY},
542+
Size: model.Size{Width: MergeSize, Height: MergeSize},
543+
},
544+
}
545+
fb.objects = append(fb.objects, merge)
546+
for _, tail := range branchTails {
547+
if tail.fromSplit {
548+
if tail.caseValue == "" {
549+
fb.flows = append(fb.flows, newHorizontalFlow(splitID, merge.ID))
550+
} else {
551+
fb.flows = append(fb.flows, newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue))
552+
}
553+
} else {
554+
fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID))
555+
}
556+
}
557+
fb.nextConnectionPoint = merge.ID
558+
}
559+
return splitID
560+
}
561+
409562
func (fb *flowBuilder) addGroupedEnumSplitFlows(originID, destinationID model.ID, values []string, order int, mergeX, mergeY int) {
410563
if len(values) <= 1 {
411564
fb.addEnumSplitFlows(originID, destinationID, values, order)
@@ -489,6 +642,46 @@ func appendEnumBodies(s *ast.EnumSplitStmt) []ast.MicroflowStatement {
489642
return stmts
490643
}
491644

645+
func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStatement {
646+
var stmts []ast.MicroflowStatement
647+
for _, c := range s.Cases {
648+
stmts = append(stmts, c.Body...)
649+
}
650+
stmts = append(stmts, s.ElseBody...)
651+
return stmts
652+
}
653+
654+
func qualifiedNameString(qn ast.QualifiedName) string {
655+
if qn.Module == "" {
656+
return qn.Name
657+
}
658+
return qn.Module + "." + qn.Name
659+
}
660+
661+
func (fb *flowBuilder) addCastAction(s *ast.CastObjectStmt) model.ID {
662+
action := &microflows.CastAction{
663+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
664+
ObjectVariable: s.ObjectVariable,
665+
OutputVariable: s.OutputVariable,
666+
}
667+
668+
activity := &microflows.ActionActivity{
669+
BaseActivity: microflows.BaseActivity{
670+
BaseMicroflowObject: microflows.BaseMicroflowObject{
671+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
672+
Position: model.Point{X: fb.posX, Y: fb.posY},
673+
Size: model.Size{Width: ActivityWidth, Height: ActivityHeight},
674+
},
675+
AutoGenerateCaption: true,
676+
},
677+
Action: action,
678+
}
679+
680+
fb.objects = append(fb.objects, activity)
681+
fb.posX += fb.spacing
682+
return activity.ID
683+
}
684+
492685
// addRetrieveAction creates a RETRIEVE statement.
493686
func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
494687
var source microflows.RetrieveSource

mdl/executor/cmd_microflows_builder_annotations.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ func getStatementAnnotations(stmt ast.MicroflowStatement) *ast.ActivityAnnotatio
1515
switch s := stmt.(type) {
1616
case *ast.DeclareStmt:
1717
return s.Annotations
18+
case *ast.InheritanceSplitStmt:
19+
return s.Annotations
20+
case *ast.CastObjectStmt:
21+
return s.Annotations
1822
case *ast.MfSetStmt:
1923
return s.Annotations
2024
case *ast.ReturnStmt:

0 commit comments

Comments
 (0)