Skip to content

Commit 3e8d0a6

Browse files
committed
fix: address merged microflow review followups
1. Manual while-true detection over-triggered when the only continue lived inside a nested loop. The continue scan crossed loop boundaries while the break scan did not, so the outer while could be rebuilt as a manual back-edge. Stop the continue scan at nested loops and add a regression test. 2. Owner-both reverse retrieves feeding add/remove-to-list were still treated as object-only consumers. The list pre-scan ignored AddToListStmt and RemoveFromListStmt target lists, so AssociationRetrieveSource could be suppressed. Track those list consumers and add coverage for the helper. 3. Direct nanoflow describe did not set the return-value render context used by EndEvent formatting. Thread the wrapped nanoflow return type through the formatter so value-returning nanoflows do not emit bare return; for empty EndEvents. Also folds in low-risk review followups: commit-action writer coverage, change-object refresh negative coverage, download-file formatter coverage, reverse-retrieve name validation tightening, and documentation for MXCLI_EXEC_TIMEOUT. Tests: make build Tests: make test Tests: make lint-go Closes #404. Closes #405. Closes #406.
1 parent 6f4534f commit 3e8d0a6

15 files changed

Lines changed: 252 additions & 42 deletions

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,8 @@ Cross-reference commands require `refresh catalog full` to populate reference da
10471047
| Setup mxcli | `mxcli setup mxcli [--os linux]` | Download platform-specific mxcli binary |
10481048
| LSP server | `mxcli lsp --stdio` | Language server for VS Code |
10491049

1050+
Set `MXCLI_EXEC_TIMEOUT` to override the per-statement execution timeout used by `mxcli exec` (for example `MXCLI_EXEC_TIMEOUT=12m` or `MXCLI_EXEC_TIMEOUT=900`).
1051+
10501052
## ANTLR4 Parser Architecture
10511053

10521054
The MDL parser uses ANTLR4 for grammar definition, enabling cross-language grammar sharing (Go, TypeScript, Java, Python).

mdl/executor/bugfix_regression_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,28 @@ func TestEmptyChangeObjectRefreshesInClient(t *testing.T) {
694694
if !action.RefreshInClient {
695695
t.Fatal("empty change object must refresh in client to remain valid without member changes or commit")
696696
}
697+
698+
id = fb.addChangeObjectAction(&ast.ChangeObjectStmt{
699+
Variable: "Object",
700+
Changes: []ast.ChangeItem{{
701+
Attribute: "Name",
702+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "changed"},
703+
}},
704+
})
705+
if id == "" || len(fb.objects) != 2 {
706+
t.Fatalf("expected second change object activity, got id=%q objects=%d", id, len(fb.objects))
707+
}
708+
activity, ok = fb.objects[1].(*microflows.ActionActivity)
709+
if !ok {
710+
t.Fatalf("object type = %T, want *microflows.ActionActivity", fb.objects[1])
711+
}
712+
action, ok = activity.Action.(*microflows.ChangeObjectAction)
713+
if !ok {
714+
t.Fatalf("action type = %T, want *microflows.ChangeObjectAction", activity.Action)
715+
}
716+
if action.RefreshInClient {
717+
t.Fatal("non-empty change object must not infer refresh in client")
718+
}
697719
}
698720

699721
func TestCallMicroflowUnknownResultTypeStillDeclaresVariable(t *testing.T) {

mdl/executor/cmd_microflows_builder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ type flowBuilder struct {
5353
microflowsCacheLoaded bool
5454
nanoflowsCache []*microflows.Nanoflow
5555
nanoflowsCacheLoaded bool
56-
manualLoopBackTarget model.ID
56+
manualLoopBackTarget model.ID
5757
}
5858

5959
// addError records a validation error during flow building.

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,14 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
308308
outputUsedAsObject := fb.objectInputVariables != nil && fb.objectInputVariables[s.Variable]
309309
// Owner-both Reference associations need later usage context: the same
310310
// compact retrieve can be consumed as either a list or a single object.
311+
// Owner="" means metadata was unavailable, so keep the association source.
311312
expandReverseReference := assocInfo != nil &&
312313
assocInfo.Type == domainmodel.AssociationTypeReference &&
313314
assocInfo.Owner != "" &&
314315
assocInfo.parentPersistable &&
315316
assocInfo.childEntityQN != "" &&
316317
startVarType == assocInfo.childEntityQN &&
317-
(assocInfo.Owner != domainmodel.AssociationOwnerBoth || outputUsedAsList && !outputUsedAsObject)
318+
(assocInfo.Owner != domainmodel.AssociationOwnerBoth || (outputUsedAsList && !outputUsedAsObject))
318319

319320
if expandReverseReference {
320321
// Reverse traversal on Reference: child → parent (one-to-many)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
)
10+
11+
// TestCollectListInputVariables_AddRemoveFromList pins issue #405:
12+
// `add $X to $List` and `remove $Y from $List` consume the target list, so
13+
// the list variable must be tracked as a list input. Without it, the output
14+
// of an Owner=Both reverse retrieve fed straight into add/remove was
15+
// misclassified as object-only and the AssociationRetrieveSource was
16+
// suppressed, re-introducing the original #383 bug for this usage shape.
17+
func TestCollectListInputVariables_AddRemoveFromList(t *testing.T) {
18+
stmts := []ast.MicroflowStatement{
19+
&ast.AddToListStmt{Item: "NewItem", List: "Items"},
20+
&ast.RemoveFromListStmt{Item: "OldItem", List: "Backlog"},
21+
}
22+
23+
got := collectListInputVariables(stmts)
24+
25+
if !got["Items"] {
26+
t.Errorf("AddToListStmt target `Items` must be marked as list input; got %v", got)
27+
}
28+
if !got["Backlog"] {
29+
t.Errorf("RemoveFromListStmt target `Backlog` must be marked as list input; got %v", got)
30+
}
31+
}

mdl/executor/cmd_microflows_builder_control.go

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
441441
}
442442

443443
func isManualWhileTrueCandidate(s *ast.WhileStmt) bool {
444-
if s == nil || containsBreakForCurrentLoop(s.Body) || (!containsContinueStmt(s.Body) && !containsTerminalStmt(s.Body)) {
444+
if s == nil || containsBreakForCurrentLoop(s.Body) || (!containsContinueForCurrentLoop(s.Body) && !containsTerminalStmt(s.Body)) {
445445
return false
446446
}
447447
lit, ok := s.Condition.(*ast.LiteralExpr)
@@ -470,23 +470,19 @@ func containsBreakForCurrentLoop(stmts []ast.MicroflowStatement) bool {
470470
return false
471471
}
472472

473-
func containsContinueStmt(stmts []ast.MicroflowStatement) bool {
473+
// containsContinueForCurrentLoop mirrors containsBreakForCurrentLoop:
474+
// a continue inside a nested loop targets that nested loop, not this one.
475+
func containsContinueForCurrentLoop(stmts []ast.MicroflowStatement) bool {
474476
for _, stmt := range stmts {
475477
switch s := stmt.(type) {
476478
case *ast.ContinueStmt:
477479
return true
478480
case *ast.IfStmt:
479-
if containsContinueStmt(s.ThenBody) || containsContinueStmt(s.ElseBody) {
480-
return true
481-
}
482-
case *ast.LoopStmt:
483-
if containsContinueStmt(s.Body) {
484-
return true
485-
}
486-
case *ast.WhileStmt:
487-
if containsContinueStmt(s.Body) {
481+
if containsContinueForCurrentLoop(s.ThenBody) || containsContinueForCurrentLoop(s.ElseBody) {
488482
return true
489483
}
484+
case *ast.LoopStmt, *ast.WhileStmt:
485+
continue
490486
}
491487
}
492488
return false

mdl/executor/cmd_microflows_builder_graph.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a
116116

117117
// Handle leftover pending annotations (free-floating annotation text)
118118
if fb.pendingAnnotations != nil {
119+
// Free annotations before a statement stay unattached; trailing free
120+
// annotations are drained after the statement loop below.
119121
for _, text := range freeAnnotationTexts(fb.pendingAnnotations) {
120122
fb.attachFreeAnnotation(text)
121123
}
@@ -185,6 +187,14 @@ func collectListInputVariables(stmts []ast.MicroflowStatement) map[string]bool {
185187
inputs[s.ListVariable] = true
186188
}
187189
walk(s.Body)
190+
case *ast.AddToListStmt:
191+
if s.List != "" {
192+
inputs[s.List] = true
193+
}
194+
case *ast.RemoveFromListStmt:
195+
if s.List != "" {
196+
inputs[s.List] = true
197+
}
188198
case *ast.WhileStmt:
189199
walk(s.Body)
190200
case *ast.IfStmt:
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
// TestBuildFlowGraph_ManualWhileTrueIgnoresNestedLoopContinue pins issue #404:
13+
// a `while true` whose only `continue` lives inside a nested collection loop
14+
// must NOT be classified as a manual back-edge candidate. The outer flow
15+
// should be built as a regular LoopedActivity (with a WhileLoopCondition).
16+
//
17+
// Before the fix, containsContinueStmt recursed into nested LoopStmt bodies
18+
// asymmetrically with containsBreakForCurrentLoop, so isManualWhileTrueCandidate
19+
// returned true and the outer while was rebuilt as an ExclusiveMerge back-edge,
20+
// creating an unconditional infinite loop in the BSON graph.
21+
func TestBuildFlowGraph_ManualWhileTrueIgnoresNestedLoopContinue(t *testing.T) {
22+
body := []ast.MicroflowStatement{
23+
&ast.WhileStmt{
24+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
25+
Body: []ast.MicroflowStatement{
26+
&ast.LoopStmt{
27+
LoopVariable: "item",
28+
ListVariable: "items",
29+
Body: []ast.MicroflowStatement{
30+
&ast.ContinueStmt{},
31+
},
32+
},
33+
// No outer-scope continue / return / raise: the outer while
34+
// has no terminal signal of its own.
35+
},
36+
},
37+
}
38+
39+
fb := &flowBuilder{
40+
posX: 100,
41+
posY: 100,
42+
spacing: HorizontalSpacing,
43+
measurer: &layoutMeasurer{},
44+
varTypes: map[string]string{"items": "List of Sample.Item"},
45+
declaredVars: map[string]string{"items": "List of Sample.Item"},
46+
}
47+
oc := fb.buildFlowGraph(body, nil)
48+
49+
var (
50+
outerLoop *microflows.LoopedActivity
51+
mergeCount int
52+
)
53+
for _, obj := range oc.Objects {
54+
switch o := obj.(type) {
55+
case *microflows.LoopedActivity:
56+
// The first looped activity at this scope is the outer while.
57+
if outerLoop == nil {
58+
outerLoop = o
59+
}
60+
case *microflows.ExclusiveMerge:
61+
mergeCount++
62+
}
63+
}
64+
65+
if outerLoop == nil {
66+
t.Fatal("outer `while true` must be built as a LoopedActivity, not an ExclusiveMerge back-edge")
67+
}
68+
if outerLoop.LoopSource == nil {
69+
t.Fatal("outer LoopedActivity must have a LoopSource (WhileLoopCondition for `while true`)")
70+
}
71+
if mergeCount != 0 {
72+
t.Errorf("manual back-edge ExclusiveMerge must not be emitted; got %d ExclusiveMerge node(s)", mergeCount)
73+
}
74+
}

mdl/executor/cmd_microflows_format_action.go

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func formatActivity(
7979
if ctx != nil && ctx.DescribingMicroflowHasReturnValue {
8080
return ""
8181
}
82+
// Without render context, default to the void-flow form.
8283
return "return;"
8384

8485
case *microflows.ActionActivity:
@@ -351,30 +352,30 @@ func formatAction(
351352

352353
stmt := fmt.Sprintf("retrieve $%s from %s", outputVar, entityName)
353354

354-
if dbSource.XPathConstraint != "" {
355-
constraint := strings.TrimSpace(dbSource.XPathConstraint)
356-
// XPath may contain multiple predicates like [a][b] or [a]\n[b].
357-
// Split them and join with MDL 'and' so the parser sees
358-
// separate xpathConstraint nodes.
359-
if strings.HasPrefix(constraint, "[") && strings.HasSuffix(constraint, "]") {
360-
// Split on "][" boundary (possibly separated by \n literals),
361-
// then re-wrap each predicate.
362-
inner := constraint[1 : len(constraint)-1]
363-
// Normalise real newlines between predicates: ]\n[ → ][
364-
inner = strings.ReplaceAll(inner, "]\n[", "][")
365-
parts := strings.Split(inner, "][")
366-
if len(parts) > 1 {
367-
var wrapped []string
368-
for _, p := range parts {
369-
wrapped = append(wrapped, "["+strings.TrimSpace(p)+"]")
355+
if dbSource.XPathConstraint != "" {
356+
constraint := strings.TrimSpace(dbSource.XPathConstraint)
357+
// XPath may contain multiple predicates like [a][b] or [a]\n[b].
358+
// Split them and join with MDL 'and' so the parser sees
359+
// separate xpathConstraint nodes.
360+
if strings.HasPrefix(constraint, "[") && strings.HasSuffix(constraint, "]") {
361+
// Split on "][" boundary (possibly separated by \n literals),
362+
// then re-wrap each predicate.
363+
inner := constraint[1 : len(constraint)-1]
364+
// Normalise real newlines between predicates: ]\n[ to ][
365+
inner = strings.ReplaceAll(inner, "]\n[", "][")
366+
parts := strings.Split(inner, "][")
367+
if len(parts) > 1 {
368+
var wrapped []string
369+
for _, p := range parts {
370+
wrapped = append(wrapped, "["+strings.TrimSpace(p)+"]")
371+
}
372+
constraint = strings.Join(wrapped, "\n ")
373+
} else {
374+
constraint = parts[0]
370375
}
371-
constraint = strings.Join(wrapped, "\n ")
372-
} else {
373-
constraint = parts[0]
374376
}
377+
stmt += fmt.Sprintf("\n where %s", constraint)
375378
}
376-
stmt += fmt.Sprintf("\n where %s", constraint)
377-
}
378379

379380
// Output SORT BY clause if present
380381
if len(dbSource.Sorting) > 0 {
@@ -1378,7 +1379,13 @@ func isSimpleMendixName(name string) bool {
13781379
return false
13791380
}
13801381
for i, r := range name {
1381-
if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
1382+
if i == 0 {
1383+
if r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' {
1384+
continue
1385+
}
1386+
return false
1387+
}
1388+
if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
13821389
continue
13831390
}
13841391
return false

mdl/executor/cmd_microflows_format_action_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,19 @@ func TestFormatAction_DownloadFile(t *testing.T) {
507507
}
508508
}
509509

510+
func TestFormatAction_DownloadFileWithoutBrowserFlag(t *testing.T) {
511+
e := newTestExecutor()
512+
action := &microflows.DownloadFileAction{
513+
FileDocument: "GeneratedReport",
514+
}
515+
516+
got := e.formatAction(action, nil, nil)
517+
want := "download file $GeneratedReport;"
518+
if got != want {
519+
t.Errorf("got %q, want %q", got, want)
520+
}
521+
}
522+
510523
func TestFormatAction_ValidationFeedback(t *testing.T) {
511524
e := newTestExecutor()
512525
action := &microflows.ValidationFeedbackAction{
@@ -752,6 +765,9 @@ func TestParseReverseAssociationXPathRejectsComplexPredicates(t *testing.T) {
752765
"[SampleRuntime.Domain_Runtime != $Runtime]",
753766
"[SampleRuntime.Domain_Runtime = $Runtime/Other.Assoc]",
754767
"[SampleRuntime.Domain_Runtime = 'literal']",
768+
"[_SampleRuntime.Domain_Runtime = $Runtime]",
769+
"[SampleRuntime._Domain_Runtime = $Runtime]",
770+
"[SampleRuntime.Domain_Runtime = $_Runtime]",
755771
"SampleRuntime.Domain_Runtime = $Runtime",
756772
}
757773

0 commit comments

Comments
 (0)