Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/skills/mendix/write-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,23 @@ if $entity/status = empty then
end if;
```

### 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
Expand Down
2 changes: 2 additions & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ authentication basic, session
| Color | `@color Green` | Background color (before activity) |
| Annotation | `@annotation 'text'` | Visual note attached to next activity |
| IF | `if condition then ... [else ...] end if;` | |
| 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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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?
1 change: 1 addition & 0 deletions docs/11-proposals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ BSON schema Registry ◄──── multi-version Support
| [Page Styling Support](page-styling-support.md) | Partial | CSS classes, inline styles, dynamic classes, design properties. Phase 1 (Class/Style) done | — |
| [Page Composition](proposal_page_composition.md) | Proposed | Fragment definitions and ALTER PAGE for partial page editing | Page Syntax V2, Page Styling |
| [XPath Gaps](xpath-gaps-proposal.md) | Partial | XPath constraint support gap analysis. ~85% complete, association paths and nested predicates remain | — |
| [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
Expand Down
26 changes: 26 additions & 0 deletions mdl-examples/doctype-tests/inheritance_split_statement.test.mdl
Original file line number Diff line number Diff line change
@@ -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;
/
25 changes: 25 additions & 0 deletions mdl/ast/ast_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ type DeclareStmt struct {

func (s *DeclareStmt) isMicroflowStatement() {}

// 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 *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)
type MfSetStmt struct {
Expand Down
23 changes: 23 additions & 0 deletions mdl/executor/cmd_diff_mdl.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,29 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde
}
lines = append(lines, indentStr+"end if;")

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 {
Expand Down
206 changes: 206 additions & 0 deletions mdl/executor/cmd_microflows_builder_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,212 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID {
return activity.ID
}

func (fb *flowBuilder) addInheritanceSplit(s *ast.InheritanceSplitStmt) model.ID {
if len(s.Cases) == 0 && len(s.ElseBody) == 0 {
split := &microflows.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 := &microflows.InheritanceSplit{
BaseMicroflowObject: microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
Position: model.Point{X: splitX, Y: centerY},
Size: model.Size{Width: ActivityWidth, Height: ActivityHeight},
},
ErrorHandlingType: microflows.ErrorHandlingTypeRollback,
VariableName: s.Variable,
}
fb.objects = append(fb.objects, split)
splitID := split.ID
if fb.pendingAnnotations != nil {
fb.applyAnnotations(splitID, fb.pendingAnnotations)
fb.pendingAnnotations = nil
}

branchWidth := fb.measurer.measureStatements(appendInheritanceBodies(s)).Width
if branchWidth == 0 {
branchWidth = HorizontalSpacing / 2
}
branchStartX := splitX + ActivityWidth + HorizontalSpacing/2
mergeX := branchStartX + branchWidth + HorizontalSpacing/2

type branchTail struct {
id model.ID
caseValue string
fromSplit bool
}
var branchTails []branchTail

savedEndsWithReturn := fb.endsWithReturn
allBranchesReturn := len(s.Cases) > 0 && len(s.ElseBody) > 0
branchIndex := 0

addBranch := func(caseValue string, body []ast.MicroflowStatement) {
branchNumber := branchIndex
branchY := centerY + branchIndex*VerticalSpacing
branchIndex++
if len(body) == 0 {
allBranchesReturn = false
branchTails = append(branchTails, branchTail{id: splitID, caseValue: caseValue, fromSplit: true})
return
}

fb.posX = branchStartX
fb.posY = branchY
fb.endsWithReturn = false

var lastID model.ID
pendingCase := ""
for _, stmt := range body {
actID := fb.addStatement(stmt)
if actID == "" {
continue
}
if cast, ok := stmt.(*ast.CastObjectStmt); ok && cast.OutputVariable != "" && caseValue != "" && fb.varTypes != nil {
fb.varTypes[cast.OutputVariable] = caseValue
}
if fb.pendingAnnotations != nil {
fb.applyAnnotations(actID, fb.pendingAnnotations)
fb.pendingAnnotations = nil
}
if lastID == "" {
var flow *microflows.SequenceFlow
if branchNumber == 0 {
flow = newHorizontalFlowWithInheritanceCase(splitID, actID, caseValue)
} else {
flow = newDownwardFlowWithInheritanceCase(splitID, actID, caseValue)
}
if caseValue == "" {
flow = newHorizontalFlow(splitID, actID)
}
fb.flows = append(fb.flows, flow)
} else {
if pendingCase != "" {
fb.flows = append(fb.flows, newHorizontalFlowWithCase(lastID, actID, pendingCase))
pendingCase = ""
} else {
fb.flows = append(fb.flows, newHorizontalFlow(lastID, actID))
}
}
if fb.nextConnectionPoint != "" {
lastID = fb.nextConnectionPoint
fb.nextConnectionPoint = ""
pendingCase = fb.nextFlowCase
fb.nextFlowCase = ""
} else {
lastID = actID
}
}

if !lastStmtIsReturn(body) {
allBranchesReturn = false
if lastID != "" {
branchTails = append(branchTails, branchTail{id: lastID, caseValue: pendingCase})
}
}
}

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 := &microflows.ExclusiveMerge{
BaseMicroflowObject: microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
Position: model.Point{X: mergeX, Y: centerY},
Size: model.Size{Width: MergeSize, Height: MergeSize},
},
}
fb.objects = append(fb.objects, merge)
for _, tail := range branchTails {
if tail.fromSplit {
if tail.caseValue == "" {
fb.flows = append(fb.flows, newHorizontalFlow(splitID, merge.ID))
} else {
fb.flows = append(fb.flows, newDownwardFlowWithInheritanceCase(splitID, merge.ID, tail.caseValue))
}
} else {
if tail.caseValue != "" {
fb.flows = append(fb.flows, newHorizontalFlowWithCase(tail.id, merge.ID, tail.caseValue))
} else {
fb.flows = append(fb.flows, newHorizontalFlow(tail.id, merge.ID))
}
}
}
fb.nextConnectionPoint = merge.ID
}
return splitID
}

func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStatement {
var stmts []ast.MicroflowStatement
for _, c := range s.Cases {
stmts = append(stmts, c.Body...)
}
stmts = append(stmts, s.ElseBody...)
return stmts
}

func qualifiedNameString(qn ast.QualifiedName) string {
if qn.Module == "" {
return qn.Name
}
return qn.Module + "." + qn.Name
}

func (fb *flowBuilder) addCastAction(s *ast.CastObjectStmt) model.ID {
action := &microflows.CastAction{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
ObjectVariable: s.ObjectVariable,
OutputVariable: s.OutputVariable,
}

activity := &microflows.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
Expand Down
4 changes: 4 additions & 0 deletions mdl/executor/cmd_microflows_builder_annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading