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
64 changes: 64 additions & 0 deletions mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- ============================================================================
-- Bug #330: Annotations inside loop bodies disappeared after describe/exec
-- ============================================================================
--
-- Symptom (before fix):
-- `@annotation 'note'` attached to a statement nested inside `loop ... end loop;`
-- (or `while ... end while;`) survived the first execution but disappeared
-- on the next describe → exec roundtrip. The annotation existed in the
-- loop's local object collection but the parent microflow graph never
-- saw it, so the next describer pass dropped it. Annotations on nested
-- decisions (IF inside a LOOP) were the most visible casualty — the
-- notes that explain WHY a loop exists were silently lost.
--
-- Root cause:
-- The microflow builder copied nested loop sequence flows back to the
-- parent graph but did not copy nested ANNOTATION flows. The describer
-- then collected annotation captions only from the top-level object
-- collection, ignoring captions stored inside nested loop collections.
--
-- After fix:
-- - Builder: loop/while statement handlers also copy
-- `nested.AnnotationFlows` into the parent graph.
-- - Describer: `collectAnnotationCaptions` walks recursively into
-- nested loop object collections, and `emitLoopBody` merges the
-- loop-local annotation map into the per-body traversal.
--
-- Scope note:
-- The Go-side regression `TestLoopBodyIfAnnotationPromotedToParentFlows`
-- covers the AST→BSON build path. This MDL script reproduces the
-- describer side: after exec, the annotation must appear in the
-- describe output. A full describe → exec → describe FIXPOINT for
-- nested IF inside LOOP additionally depends on the nearest split-merge
-- pairing fix (issue #326 / PR #327); on a branch that includes both
-- fixes, this script round-trips cleanly.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe microflow BugTest330.MF_LoopWithAnnotations"
-- The describe output must contain `@annotation 'note on nested if'`
-- on the IF inside the loop body.
-- ============================================================================

create module BugTest330;

create entity BugTest330.Item (
Name : string(100)
);
/

-- LOOP body containing an annotated IF — the case that originally lost the
-- annotation. The note on the IF must appear in the describe output.
create microflow BugTest330.MF_LoopWithAnnotations (
$Items: list of BugTest330.Item
)
begin
loop $Item in $Items
begin
@annotation 'note on nested if'
if $Item/Name != empty then
log info node 'BugTest330' 'has name';
end if;
end loop;
end;
/
48 changes: 48 additions & 0 deletions mdl/executor/cmd_microflows_builder_annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/microflows"
)

Expand Down Expand Up @@ -220,6 +221,53 @@ func TestIfAnnotationStaysWithCorrectSplit(t *testing.T) {
}
}

func TestLoopBodyIfAnnotationPromotedToParentFlows(t *testing.T) {
nestedIf := &ast.IfStmt{
Condition: &ast.VariableExpr{Name: "IsActive"},
ThenBody: []ast.MicroflowStatement{
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "active"}},
},
Annotations: &ast.ActivityAnnotations{
AnnotationText: "Nested decision note",
},
}
loop := &ast.LoopStmt{
LoopVariable: "Item",
ListVariable: "Items",
Body: []ast.MicroflowStatement{nestedIf},
}

fb := &flowBuilder{
posX: 100,
posY: 100,
spacing: HorizontalSpacing,
varTypes: map[string]string{"Items": "List of Synthetic.Item", "IsActive": "Boolean"},
declaredVars: map[string]string{"Items": "List of Synthetic.Item", "IsActive": "Boolean"},
}
oc := fb.buildFlowGraph([]ast.MicroflowStatement{loop}, nil)

var splitID model.ID
for _, obj := range oc.Objects {
loopObj, ok := obj.(*microflows.LoopedActivity)
if !ok || loopObj.ObjectCollection == nil {
continue
}
for _, nested := range loopObj.ObjectCollection.Objects {
if split, ok := nested.(*microflows.ExclusiveSplit); ok {
splitID = split.ID
}
}
}
if splitID == "" {
t.Fatal("expected nested ExclusiveSplit inside loop body")
}

annotations := buildAnnotationsByTarget(oc)
if got := annotations[splitID]; len(got) != 1 || got[0] != "Nested decision note" {
t.Fatalf("annotations for nested split = %#v, want Nested decision note", got)
}
}

// TestLoopCaptionPreserved covers the loop caption case — previously untested
// per PR review. The fix for the outer-IF caption contamination bug also applied
// the same snapshot/restore pattern to addLoopStatement and addWhileStatement.
Expand Down
2 changes: 2 additions & 0 deletions mdl/executor/cmd_microflows_builder_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
// Add the internal flows to the parent's flows (top-level), not inside loop
// This is how Mendix stores them - all flows at the microflow level
fb.flows = append(fb.flows, loopBuilder.flows...)
fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...)

// Re-apply this loop's own annotations now that its activity exists.
if savedLoopAnnotations != nil {
Expand Down Expand Up @@ -517,6 +518,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {

fb.objects = append(fb.objects, loop)
fb.flows = append(fb.flows, loopBuilder.flows...)
fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...)

if savedWhileAnnotations != nil {
fb.applyAnnotations(loop.ID, savedWhileAnnotations)
Expand Down
43 changes: 36 additions & 7 deletions mdl/executor/cmd_microflows_show_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,8 @@ func buildAnnotationsByTarget(oc *microflows.MicroflowObjectCollection) map[mode
return result
}

// Build a map of annotation IDs to their captions
annotCaptions := make(map[model.ID]string)
for _, obj := range oc.Objects {
if annot, ok := obj.(*microflows.Annotation); ok {
annotCaptions[annot.ID] = annot.Caption
}
}
collectAnnotationCaptions(oc, annotCaptions)

// Map each annotation flow's destination (the activity) to the annotation's caption
for _, af := range oc.AnnotationFlows {
Expand All @@ -38,6 +33,38 @@ func buildAnnotationsByTarget(oc *microflows.MicroflowObjectCollection) map[mode
return result
}

func collectAnnotationCaptions(oc *microflows.MicroflowObjectCollection, captions map[model.ID]string) {
if oc == nil {
return
}
for _, obj := range oc.Objects {
if annot, ok := obj.(*microflows.Annotation); ok {
captions[annot.ID] = annot.Caption
continue
}
if loop, ok := obj.(*microflows.LoopedActivity); ok {
collectAnnotationCaptions(loop.ObjectCollection, captions)
}
}
}

func mergeAnnotationsByTarget(base, overlay map[model.ID][]string) map[model.ID][]string {
if len(base) == 0 {
return overlay
}
if len(overlay) == 0 {
return base
}
merged := make(map[model.ID][]string, len(base)+len(overlay))
for id, captions := range base {
merged[id] = captions
}
for id, captions := range overlay {
merged[id] = append(merged[id], captions...)
}
return merged
}

// collectFreeAnnotations returns captions for annotations not referenced by any AnnotationFlow.
func collectFreeAnnotations(oc *microflows.MicroflowObjectCollection) []string {
if oc == nil {
Expand Down Expand Up @@ -826,6 +853,8 @@ func emitLoopBody(
return
}

loopAnnotationsByTarget := mergeAnnotationsByTarget(annotationsByTarget, buildAnnotationsByTarget(loop.ObjectCollection))

// Build a map of objects in the loop body
loopActivityMap := make(map[model.ID]microflows.MicroflowObject)
for _, loopObj := range loop.ObjectCollection.Objects {
Expand Down Expand Up @@ -888,7 +917,7 @@ func emitLoopBody(
// Traverse the loop body
if firstID != "" {
loopVisited := make(map[model.ID]bool)
traverseLoopBody(ctx, firstID, loopActivityMap, loopFlowsByOrigin, loopFlowsByDest, loopVisited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget)
traverseLoopBody(ctx, firstID, loopActivityMap, loopFlowsByOrigin, loopFlowsByDest, loopVisited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, loopAnnotationsByTarget)
}
}

Expand Down
55 changes: 55 additions & 0 deletions mdl/executor/cmd_microflows_traverse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package executor

import (
"strings"
"testing"

"github.com/mendixlabs/mxcli/model"
Expand Down Expand Up @@ -124,6 +125,60 @@ func TestTraverseFlow_IfElse(t *testing.T) {
}
}

func TestTraverseFlow_LoopBodyUsesNestedAnnotationFlows(t *testing.T) {
e := newTestExecutor()

split := &microflows.ExclusiveSplit{
BaseMicroflowObject: microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: mkID("split")},
Position: model.Point{X: 100, Y: 100},
},
SplitCondition: &microflows.ExpressionSplitCondition{Expression: "$Item/IsActive"},
}
note := &microflows.Annotation{
BaseMicroflowObject: microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: mkID("note")},
Position: model.Point{X: 1000, Y: 100},
},
Caption: "nested split note",
}
loopObjects := &microflows.MicroflowObjectCollection{
Objects: []microflows.MicroflowObject{split, note},
AnnotationFlows: []*microflows.AnnotationFlow{
{
BaseElement: model.BaseElement{ID: mkID("note-flow")},
OriginID: mkID("note"),
DestinationID: mkID("split"),
},
},
}
annotationsByTarget := mergeAnnotationsByTarget(
buildAnnotationsByTarget(&microflows.MicroflowObjectCollection{}),
buildAnnotationsByTarget(loopObjects),
)

var lines []string
e.traverseFlow(
mkID("split"),
map[model.ID]microflows.MicroflowObject{mkID("split"): split},
nil,
nil,
make(map[model.ID]bool),
nil,
nil,
&lines,
0,
nil,
0,
annotationsByTarget,
)

out := strings.Join(lines, "\n")
if !strings.Contains(out, "@annotation 'nested split note'") {
t.Fatalf("expected nested loop annotation in output:\n%s", out)
}
}

// =============================================================================
// collectErrorHandlerStatements
// =============================================================================
Expand Down