Skip to content

Commit c669a39

Browse files
authored
Merge pull request #264 from hjotha/submit/microflow-roundtrip-log-node
fix: support log node expressions and harden microflow describe roundtrip
2 parents 5097e46 + 16515e1 commit c669a39

24 files changed

Lines changed: 597 additions & 83 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1313
- **Path normalization** — Relative paths in `MetadataUrl` are automatically converted to absolute `file://` URLs for Studio Pro compatibility
1414
- **ServiceUrl validation**`ServiceUrl` parameter must now be a constant reference (e.g., `@Module.ConstantName`) to enforce Mendix best practice
1515
- **Shared URL utilities**`internal/pathutil` package with `NormalizeURL()`, `URIToPath()`, and `PathFromURL()` for reuse across components
16+
17+
### Changed
18+
19+
- **MDL string literal escapes**`mdlQuote`/`unquoteString` now treat `\n`, `\r`, `\t`, and `\\` inside single-quoted literals as escape sequences (previously a literal backslash followed by the letter). This is a compatibility break for any MDL script that intentionally embedded a raw `\n` / `\t` / `\\` as two characters; such scripts must now double the backslash (`\\n` to preserve the two-character form). Applies to `LOG` messages, `@caption`/`@annotation` text, and other string literals round-tripped via the describer.
20+
1621
## [0.7.0] - 2026-04-21
1722

1823
### Added
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- ============================================================================
2+
-- Bug #264: Log node expressions and microflow describe roundtrip
3+
-- ============================================================================
4+
--
5+
-- Three related regressions surfaced together while exercising real .mpr files:
6+
--
7+
-- 1. Legacy `NewCaseValue` on sequence flows (Mx 9) was being dropped by the
8+
-- parser, so re-describing a Mx 9 project silently lost decision labels.
9+
-- 2. The describer bailed or emitted malformed MDL when it hit partially
10+
-- understood activity shapes — a single unknown subfield broke the entire
11+
-- microflow roundtrip.
12+
-- 3. The MDL grammar didn't accept the `log` activity's expression-typed NODE
13+
-- parameter, so any microflow containing a log node with a variable or
14+
-- constant reference failed to re-parse after describe.
15+
--
16+
-- Usage:
17+
-- mxcli exec mdl-examples/bug-tests/264-log-node-expression-roundtrip.mdl -p app.mpr
18+
-- Then: mxcli describe microflow BugTest264.MF_LogWithNode -p app.mpr
19+
-- The output must re-execute cleanly against the same project.
20+
-- ============================================================================
21+
22+
create module BugTest264;
23+
24+
-- String-literal node (backwards-compatible shape)
25+
create microflow BugTest264.MF_LogWithLiteralNode ()
26+
begin
27+
log info node 'BugTest264' 'started';
28+
end;
29+
/
30+
31+
-- Variable-ref node — exercises Node expression path
32+
create microflow BugTest264.MF_LogWithNode (
33+
$nodeName: string
34+
)
35+
begin
36+
log info node $nodeName 'hello';
37+
end;
38+
/
39+
40+
-- Multi-line message with embedded newline — exercises the mdlQuote escape
41+
-- round-trip (`\n` is decoded on parse, re-encoded on describe).
42+
create microflow BugTest264.MF_LogMultilineMessage ()
43+
begin
44+
log info node 'BugTest264' 'line 1\nline 2\nline 3';
45+
end;
46+
/

mdl/ast/ast_microflow.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,10 @@ func (p *TemplateParam) IsDataSourceRef() bool {
261261
return p.DataSourceName != ""
262262
}
263263

264-
// LogStmt represents: LOG LEVEL NODE 'node' 'message' [WITH params]
264+
// LogStmt represents: LOG LEVEL [NODE expr] message [WITH params]
265265
type LogStmt struct {
266266
Level LogLevel // Log level (INFO, WARNING, etc.)
267-
Node string // Log node name
267+
Node Expression // Optional log node expression
268268
Message Expression // Message expression
269269
Template []TemplateParam // Optional WITH template params
270270
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation

mdl/executor/bugfix_regression_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,157 @@ func TestFormatActivity_WhileLoop(t *testing.T) {
6565
}
6666
}
6767

68+
func TestAddLoopStatement_PreservesAnnotatedPosition(t *testing.T) {
69+
fb := &flowBuilder{
70+
posX: 350,
71+
posY: 200,
72+
spacing: HorizontalSpacing,
73+
varTypes: map[string]string{"Items": "List of Test.Item"},
74+
declaredVars: map[string]string{},
75+
measurer: &layoutMeasurer{varTypes: map[string]string{"Items": "List of Test.Item"}},
76+
}
77+
78+
stmt := &ast.LoopStmt{
79+
LoopVariable: "Item",
80+
ListVariable: "Items",
81+
Annotations: &ast.ActivityAnnotations{
82+
Position: &ast.Position{X: 350, Y: 200},
83+
},
84+
}
85+
86+
id := fb.addLoopStatement(stmt)
87+
if id == "" {
88+
t.Fatal("expected loop activity ID")
89+
}
90+
91+
loop, ok := fb.objects[len(fb.objects)-1].(*microflows.LoopedActivity)
92+
if !ok {
93+
t.Fatalf("expected LoopedActivity, got %T", fb.objects[len(fb.objects)-1])
94+
}
95+
if loop.Position.X != 350 || loop.Position.Y != 200 {
96+
t.Fatalf("got loop position (%d, %d), want (350, 200)", loop.Position.X, loop.Position.Y)
97+
}
98+
wantNextX := 350 + loop.Size.Width/2 + HorizontalSpacing
99+
if fb.posX != wantNextX {
100+
t.Fatalf("got next posX %d, want %d", fb.posX, wantNextX)
101+
}
102+
}
103+
104+
func TestAddWhileStatement_PreservesAnnotatedPosition(t *testing.T) {
105+
fb := &flowBuilder{
106+
posX: 420,
107+
posY: 180,
108+
spacing: HorizontalSpacing,
109+
varTypes: map[string]string{},
110+
declaredVars: map[string]string{},
111+
measurer: &layoutMeasurer{varTypes: map[string]string{}},
112+
}
113+
114+
stmt := &ast.WhileStmt{
115+
Condition: &ast.BinaryExpr{
116+
Left: &ast.VariableExpr{Name: "Count"},
117+
Operator: "<",
118+
Right: &ast.LiteralExpr{Kind: ast.LiteralInteger, Value: int64(10)},
119+
},
120+
Annotations: &ast.ActivityAnnotations{
121+
Position: &ast.Position{X: 420, Y: 180},
122+
},
123+
}
124+
125+
id := fb.addWhileStatement(stmt)
126+
if id == "" {
127+
t.Fatal("expected while activity ID")
128+
}
129+
130+
loop, ok := fb.objects[len(fb.objects)-1].(*microflows.LoopedActivity)
131+
if !ok {
132+
t.Fatalf("expected LoopedActivity, got %T", fb.objects[len(fb.objects)-1])
133+
}
134+
if loop.Position.X != 420 || loop.Position.Y != 180 {
135+
t.Fatalf("got while position (%d, %d), want (420, 180)", loop.Position.X, loop.Position.Y)
136+
}
137+
wantNextX := 420 + loop.Size.Width/2 + HorizontalSpacing
138+
if fb.posX != wantNextX {
139+
t.Fatalf("got next posX %d, want %d", fb.posX, wantNextX)
140+
}
141+
}
142+
143+
func TestAddLogMessageAction_PreservesNodeExpression(t *testing.T) {
144+
fb := &flowBuilder{
145+
posX: 100,
146+
posY: 200,
147+
spacing: HorizontalSpacing,
148+
measurer: &layoutMeasurer{},
149+
}
150+
151+
stmt := &ast.LogStmt{
152+
Level: ast.LogInfo,
153+
Node: &ast.ConstantRefExpr{
154+
QualifiedName: ast.QualifiedName{Module: "TestModule", Name: "SecurityLogNode"},
155+
},
156+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "User added"},
157+
}
158+
159+
id := fb.addLogMessageAction(stmt)
160+
if id == "" {
161+
t.Fatal("expected log activity ID")
162+
}
163+
164+
activity, ok := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity)
165+
if !ok {
166+
t.Fatalf("expected ActionActivity, got %T", fb.objects[len(fb.objects)-1])
167+
}
168+
169+
action, ok := activity.Action.(*microflows.LogMessageAction)
170+
if !ok {
171+
t.Fatalf("expected LogMessageAction, got %T", activity.Action)
172+
}
173+
174+
if action.LogNodeName != "@TestModule.SecurityLogNode" {
175+
t.Fatalf("got log node %q, want %q", action.LogNodeName, "@TestModule.SecurityLogNode")
176+
}
177+
}
178+
179+
func TestAddLogMessageAction_TemplateLiteralDoesNotKeepQuotes(t *testing.T) {
180+
fb := &flowBuilder{
181+
posX: 100,
182+
posY: 200,
183+
spacing: HorizontalSpacing,
184+
measurer: &layoutMeasurer{},
185+
}
186+
187+
stmt := &ast.LogStmt{
188+
Level: ast.LogInfo,
189+
Node: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "App"},
190+
Message: &ast.LiteralExpr{
191+
Kind: ast.LiteralString,
192+
Value: "Order {1}",
193+
},
194+
Template: []ast.TemplateParam{
195+
{Index: 1, Value: &ast.VariableExpr{Name: "OrderNumber"}},
196+
},
197+
}
198+
199+
id := fb.addLogMessageAction(stmt)
200+
if id == "" {
201+
t.Fatal("expected log activity ID")
202+
}
203+
204+
activity, ok := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity)
205+
if !ok {
206+
t.Fatalf("expected ActionActivity, got %T", fb.objects[len(fb.objects)-1])
207+
}
208+
209+
action, ok := activity.Action.(*microflows.LogMessageAction)
210+
if !ok {
211+
t.Fatalf("expected LogMessageAction, got %T", activity.Action)
212+
}
213+
214+
if got := action.MessageTemplate.Translations["en_US"]; got != "Order {1}" {
215+
t.Fatalf("got message template %q, want %q", got, "Order {1}")
216+
}
217+
}
218+
68219
// =============================================================================
69220
// Issue #19: Long type must not be downgraded to Integer
70221
// =============================================================================

mdl/executor/cmd_diff_mdl.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,9 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde
345345
lines = append(lines, indentStr+"end loop;")
346346

347347
case *ast.LogStmt:
348-
nodeStr := s.Node
349-
if !strings.HasPrefix(nodeStr, "'") {
350-
nodeStr = "'" + nodeStr + "'"
348+
nodeStr := defaultLogNodeExpression
349+
if s.Node != nil {
350+
nodeStr = diffExpressionToString(ctx, s.Node)
351351
}
352352
msgStr := diffExpressionToString(ctx, s.Message)
353353
stmt := fmt.Sprintf("%slog %s node %s %s", indentStr, strings.ToLower(s.Level.String()), nodeStr, msgStr)

mdl/executor/cmd_microflows_builder_calls.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import (
1414
"github.com/mendixlabs/mxcli/sdk/microflows"
1515
)
1616

17+
// defaultLogNodeExpression is the quoted Mendix expression used for the log
18+
// node when none is specified on a LOG statement. Single source of truth shared
19+
// by the builder, the formatter, and cmd_diff_mdl.
20+
const defaultLogNodeExpression = "'Application'"
21+
1722
// addLogMessageAction creates a LOG statement as a LogMessageAction.
1823
func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID {
1924
logLevel := microflows.LogLevelInfo
@@ -38,7 +43,11 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID {
3843

3944
if len(s.Template) > 0 {
4045
// Use provided template parameters
41-
templateText = fb.exprToString(s.Message)
46+
if lit, ok := s.Message.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString {
47+
templateText = fmt.Sprintf("%v", lit.Value)
48+
} else {
49+
templateText = fb.exprToString(s.Message)
50+
}
4251
// Sort parameters by index to ensure correct order
4352
maxIndex := 0
4453
for _, p := range s.Template {
@@ -61,10 +70,15 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID {
6170
templateParams = []string{fb.exprToString(s.Message)}
6271
}
6372

73+
logNodeName := defaultLogNodeExpression
74+
if s.Node != nil {
75+
logNodeName = fb.exprToString(s.Node)
76+
}
77+
6478
action := &microflows.LogMessageAction{
6579
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
6680
LogLevel: logLevel,
67-
LogNodeName: "'" + s.Node + "'", // Store as expression (e.g., 'TEST')
81+
LogNodeName: logNodeName,
6882
MessageTemplate: &model.Text{
6983
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
7084
Translations: map[string]string{

mdl/executor/cmd_microflows_builder_control.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,13 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
288288
innerStartX := LoopPadding + iteratorSpace // Extra offset for iterator icon and label
289289
innerStartY := LoopPadding + ActivityHeight/2 // Center activities vertically with some padding
290290

291+
loopLeftX := fb.posX
292+
loopCenterX := loopLeftX + loopWidth/2
293+
if s.Annotations != nil && s.Annotations.Position != nil {
294+
loopCenterX = s.Annotations.Position.X
295+
loopLeftX = loopCenterX - loopWidth/2
296+
}
297+
291298
// Add loop variable to varTypes with element type derived from list type
292299
// If $ProductList is "List of MfTest.Product", then $Product is "MfTest.Product"
293300
if fb.varTypes != nil {
@@ -335,7 +342,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
335342
loop := &microflows.LoopedActivity{
336343
BaseMicroflowObject: microflows.BaseMicroflowObject{
337344
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
338-
Position: model.Point{X: fb.posX + loopWidth/2, Y: fb.posY},
345+
Position: model.Point{X: loopCenterX, Y: fb.posY},
339346
Size: model.Size{Width: loopWidth, Height: loopHeight},
340347
},
341348
LoopSource: &microflows.IterableList{
@@ -362,7 +369,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
362369
fb.applyAnnotations(loop.ID, savedLoopAnnotations)
363370
}
364371

365-
fb.posX += loopWidth + HorizontalSpacing
372+
fb.posX = loopLeftX + loopWidth + HorizontalSpacing
366373

367374
return loop.ID
368375
}
@@ -383,6 +390,13 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
383390
innerStartX := LoopPadding
384391
innerStartY := LoopPadding + ActivityHeight/2
385392

393+
loopLeftX := fb.posX
394+
loopCenterX := loopLeftX + loopWidth/2
395+
if s.Annotations != nil && s.Annotations.Position != nil {
396+
loopCenterX = s.Annotations.Position.X
397+
loopLeftX = loopCenterX - loopWidth/2
398+
}
399+
386400
loopBuilder := &flowBuilder{
387401
posX: innerStartX,
388402
posY: innerStartY,
@@ -417,7 +431,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
417431
loop := &microflows.LoopedActivity{
418432
BaseMicroflowObject: microflows.BaseMicroflowObject{
419433
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
420-
Position: model.Point{X: fb.posX + loopWidth/2, Y: fb.posY},
434+
Position: model.Point{X: loopCenterX, Y: fb.posY},
421435
Size: model.Size{Width: loopWidth, Height: loopHeight},
422436
},
423437
LoopSource: &microflows.WhileLoopCondition{
@@ -439,7 +453,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
439453
fb.applyAnnotations(loop.ID, savedWhileAnnotations)
440454
}
441455

442-
fb.posX += loopWidth + HorizontalSpacing
456+
fb.posX = loopLeftX + loopWidth + HorizontalSpacing
443457

444458
return loop.ID
445459
}

0 commit comments

Comments
 (0)