Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
-- ============================================================================
-- Bug #322: Multiline source expression whitespace lost on roundtrip
-- ============================================================================
--
-- Symptom (before fix):
-- The visitor rebuilt expression strings from parsed expression nodes,
-- so original line breaks and whitespace inside DECLARE initial values
-- and LOG template parameters were normalized away. A multiline
-- `declare $X string = ...` statement that authors had carefully
-- formatted across several lines came back as a single very long line
-- after describe → exec → describe. Inter-parameter blank lines in
-- `LOG ... WITH (...)` were similarly collapsed, making real-world
-- microflows non-fixpoint and producing noisy `mxcli diff-local` output.
--
-- After fix:
-- Added a `SourceExpr` AST node that wraps an expression with its
-- original source text. The visitor uses `buildSourceExpression` for
-- source-sensitive declare/log/while expressions, and the executor
-- serializes `SourceExpr` through to MDL output so the original
-- whitespace and line breaks survive every roundtrip.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/322-multiline-source-expression-whitespace.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe microflow BugTest322.MF_MultilineDeclare"
-- mxcli -p app.mpr -c "describe microflow BugTest322.MF_MultilineLogTemplate"
-- The describe outputs must keep the multiline shape and must be
-- fixpoints under describe → exec → describe.
-- ============================================================================

create module BugTest322;

-- DECLARE initial value spread across several lines with leading `+`. The
-- describer must preserve the line breaks rather than collapse them onto
-- one line.
create microflow BugTest322.MF_MultilineDeclare (
$Page: integer,
$Token: string
)
returns string as $Endpoint
begin
declare $Endpoint string = '/api/v1'
+ '/items?page=' + toString($Page)
+ '&token=' + $Token;

return $Endpoint;
end;
/

-- LOG template parameters separated by a blank line. The newline-only
-- whitespace between `{1} = toString($Count)` and the next parameter must
-- survive the roundtrip.
create microflow BugTest322.MF_MultilineLogTemplate (
$Count: integer,
$Endpoint: string
)
begin
log info node 'BugTest322' 'Processed {1} items for {2}' with ({1} = toString($Count)

, {2} = $Endpoint);
end;
/
9 changes: 9 additions & 0 deletions mdl/ast/ast_expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ type IfThenElseExpr struct {

func (e *IfThenElseExpr) isExpression() {}

// SourceExpr preserves original expression source text while keeping the parsed
// expression tree available for callers that need semantic inspection.
type SourceExpr struct {
Expression Expression
Source string
}

func (e *SourceExpr) isExpression() {}

// ============================================================================
// XPath-Specific Expression Types
// ============================================================================
Expand Down
34 changes: 34 additions & 0 deletions mdl/executor/bugfix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,40 @@ func TestResolveAssociationPaths(t *testing.T) {
}
}

func TestResolveAssociationPathsUnwrapsEmptySourceExpr(t *testing.T) {
fb := &flowBuilder{}
resolved := fb.resolveAssociationPaths(&ast.SourceExpr{
Expression: &ast.VariableExpr{Name: "CurrentObject"},
})

if _, ok := resolved.(*ast.SourceExpr); ok {
t.Fatalf("empty SourceExpr should unwrap to resolved inner expression, got %T", resolved)
}
if got := expressionToString(resolved); got != "$CurrentObject" {
t.Fatalf("resolved expression = %q, want $CurrentObject", got)
}
}

func TestResolveAssociationPathsKeepsNonEmptySourceExprVerbatim(t *testing.T) {
source := "$CurrentObject/Module.Assoc/Name\n"
fb := &flowBuilder{}
resolved := fb.resolveAssociationPaths(&ast.SourceExpr{
Expression: &ast.AttributePathExpr{
Variable: "CurrentObject",
Path: []string{"Module.Assoc", "Name"},
},
Source: source,
})

sourceExpr, ok := resolved.(*ast.SourceExpr)
if !ok {
t.Fatalf("non-empty SourceExpr should remain SourceExpr, got %T", resolved)
}
if sourceExpr.Source != source {
t.Fatalf("source = %q, want %q", sourceExpr.Source, source)
}
}

// TestExprToStringNoSpaces verifies that association navigation expressions
// produce no extra spaces around separators after parsing.
// Issue #120: generated $Order / Module.Assoc / Name instead of $Order/Module.Assoc/Name
Expand Down
11 changes: 11 additions & 0 deletions mdl/executor/cmd_microflows_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,15 @@ func (fb *flowBuilder) resolveAssociationPaths(expr ast.Expression) ast.Expressi
ThenExpr: fb.resolveAssociationPaths(e.ThenExpr),
ElseExpr: fb.resolveAssociationPaths(e.ElseExpr),
}
case *ast.SourceExpr:
if e.Source != "" {
// Non-empty Source is the exact expression text to write back.
// Rebuilding it here would defeat the whitespace-preservation
// purpose of SourceExpr, so keep the parsed tree only for callers
// that need semantic inspection.
return e
}
return fb.resolveAssociationPaths(e.Expression)
default:
return expr
}
Expand Down Expand Up @@ -443,6 +452,8 @@ func unwrapParenCall(expr ast.Expression) *ast.FunctionCallExpr {
return e
case *ast.ParenExpr:
expr = e.Inner
case *ast.SourceExpr:
expr = e.Expression
default:
return nil
}
Expand Down
10 changes: 10 additions & 0 deletions mdl/executor/cmd_microflows_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,11 @@ func expressionToString(expr ast.Expression) string {
thenStr := expressionToString(e.ThenExpr)
elseStr := expressionToString(e.ElseExpr)
return "if " + cond + " then " + thenStr + " else " + elseStr
case *ast.SourceExpr:
if e.Source != "" {
return e.Source
}
return expressionToString(e.Expression)
default:
return ""
}
Expand Down Expand Up @@ -379,6 +384,11 @@ func expressionToXPath(expr ast.Expression) string {
return expressionToString(expr)
case *ast.QualifiedNameExpr:
return qualifiedNameToXPath(e)
case *ast.SourceExpr:
if e.Source != "" {
return e.Source
}
return expressionToXPath(e.Expression)
default:
// For all other expression types, the standard serialization is correct
return expressionToString(expr)
Expand Down
Loading
Loading