Skip to content

Commit 48c1030

Browse files
authored
Merge pull request #165 from engalar/fix/issue-120-association-nav-expression
fix: association navigation expression missing target entity and extra spaces
2 parents fe6e61f + 0ec688a commit 48c1030

8 files changed

Lines changed: 272 additions & 36 deletions

mdl/executor/bugfix_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,140 @@ func validateMicroflowFromMDL(t *testing.T, input string) []string {
422422

423423
return ValidateMicroflowBody(stmt)
424424
}
425+
426+
// TestAssociationNavParsing verifies that $Var/Module.Assoc/Attr parses as
427+
// AttributePathExpr (not nested BinaryExpr with "/" operator).
428+
// Issue #120: extra spaces around path separators.
429+
func TestAssociationNavParsing(t *testing.T) {
430+
input := `CREATE MICROFLOW Test.MF_Nav()
431+
RETURNS String AS $Result
432+
BEGIN
433+
DECLARE $CustName String = $Order/Test.Order_Customer/Name;
434+
RETURN $CustName;
435+
END;`
436+
437+
prog, errs := visitor.Build(input)
438+
if len(errs) > 0 {
439+
t.Fatalf("Parse error: %v", errs[0])
440+
}
441+
442+
stmt := prog.Statements[0].(*ast.CreateMicroflowStmt)
443+
declStmt := stmt.Body[0].(*ast.DeclareStmt)
444+
445+
// The expression should be an AttributePathExpr, not a BinaryExpr
446+
pathExpr, ok := declStmt.InitialValue.(*ast.AttributePathExpr)
447+
if !ok {
448+
t.Fatalf("Expected AttributePathExpr, got %T", declStmt.InitialValue)
449+
}
450+
451+
if pathExpr.Variable != "Order" {
452+
t.Errorf("Variable = %q, want %q", pathExpr.Variable, "Order")
453+
}
454+
if len(pathExpr.Path) != 2 {
455+
t.Fatalf("Path length = %d, want 2", len(pathExpr.Path))
456+
}
457+
if pathExpr.Path[0] != "Test.Order_Customer" {
458+
t.Errorf("Path[0] = %q, want %q", pathExpr.Path[0], "Test.Order_Customer")
459+
}
460+
if pathExpr.Path[1] != "Name" {
461+
t.Errorf("Path[1] = %q, want %q", pathExpr.Path[1], "Name")
462+
}
463+
464+
// Serialized form should have no extra spaces
465+
got := expressionToString(pathExpr)
466+
want := "$Order/Test.Order_Customer/Name"
467+
if got != want {
468+
t.Errorf("expressionToString() = %q, want %q", got, want)
469+
}
470+
}
471+
472+
// TestResolveAssociationPaths verifies that resolveAssociationPaths inserts
473+
// the target entity after an association segment.
474+
// Issue #120: missing target entity qualifier.
475+
func TestResolveAssociationPaths(t *testing.T) {
476+
tests := []struct {
477+
name string
478+
path []string
479+
want []string
480+
}{
481+
{
482+
name: "simple_attribute",
483+
path: []string{"Name"},
484+
want: []string{"Name"},
485+
},
486+
{
487+
name: "assoc_then_attr",
488+
path: []string{"Test.Order_Customer", "Name"},
489+
want: []string{"Test.Order_Customer", "Test.Customer", "Name"},
490+
},
491+
{
492+
name: "already_has_target_entity",
493+
path: []string{"Test.Order_Customer", "Test.Customer", "Name"},
494+
want: []string{"Test.Order_Customer", "Test.Customer", "Name"},
495+
},
496+
{
497+
name: "assoc_at_end",
498+
path: []string{"Test.Order_Customer"},
499+
want: []string{"Test.Order_Customer"},
500+
},
501+
}
502+
503+
for _, tt := range tests {
504+
t.Run(tt.name, func(t *testing.T) {
505+
fb := &flowBuilder{
506+
reader: nil, // nil reader → no resolution, path unchanged
507+
}
508+
got := fb.resolvePathSegments(tt.path)
509+
510+
// With nil reader, all paths should be unchanged
511+
if len(got) != len(tt.path) {
512+
t.Errorf("resolvePathSegments() length = %d, want %d", len(got), len(tt.path))
513+
}
514+
})
515+
}
516+
}
517+
518+
// TestExprToStringNoSpaces verifies that association navigation expressions
519+
// produce no extra spaces around separators after parsing.
520+
// Issue #120: generated $Order / Module.Assoc / Name instead of $Order/Module.Assoc/Name
521+
func TestExprToStringNoSpaces(t *testing.T) {
522+
tests := []struct {
523+
name string
524+
expr ast.Expression
525+
want string
526+
}{
527+
{
528+
name: "simple_path",
529+
expr: &ast.AttributePathExpr{
530+
Variable: "Order",
531+
Path: []string{"OrderNumber"},
532+
},
533+
want: "$Order/OrderNumber",
534+
},
535+
{
536+
name: "assoc_path",
537+
expr: &ast.AttributePathExpr{
538+
Variable: "Order",
539+
Path: []string{"Test.Order_Customer", "Name"},
540+
},
541+
want: "$Order/Test.Order_Customer/Name",
542+
},
543+
{
544+
name: "multi_segment_path",
545+
expr: &ast.AttributePathExpr{
546+
Variable: "Invoice",
547+
Path: []string{"Billing.Invoice_Order", "Billing.Order_Customer", "Name"},
548+
},
549+
want: "$Invoice/Billing.Invoice_Order/Billing.Order_Customer/Name",
550+
},
551+
}
552+
553+
for _, tt := range tests {
554+
t.Run(tt.name, func(t *testing.T) {
555+
got := expressionToString(tt.expr)
556+
if got != tt.want {
557+
t.Errorf("expressionToString() = %q, want %q", got, tt.want)
558+
}
559+
})
560+
}
561+
}

mdl/executor/cmd_microflows_builder.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package executor
55

66
import (
77
"fmt"
8+
"strings"
89

910
"github.com/mendixlabs/mxcli/mdl/ast"
1011
"github.com/mendixlabs/mxcli/model"
@@ -72,3 +73,98 @@ func (fb *flowBuilder) isVariableDeclared(varName string) bool {
7273
}
7374
return false
7475
}
76+
77+
// exprToString converts an AST Expression to a Mendix expression string,
78+
// resolving association navigation paths to include the target entity qualifier.
79+
// e.g. $Order/MyModule.Order_Customer/Name → $Order/MyModule.Order_Customer/MyModule.Customer/Name
80+
func (fb *flowBuilder) exprToString(expr ast.Expression) string {
81+
resolved := fb.resolveAssociationPaths(expr)
82+
return expressionToString(resolved)
83+
}
84+
85+
// resolveAssociationPaths walks an expression tree and, for any AttributePathExpr
86+
// whose path contains an association (qualified name like Module.AssocName), inserts
87+
// the association's target entity after the association segment.
88+
func (fb *flowBuilder) resolveAssociationPaths(expr ast.Expression) ast.Expression {
89+
if expr == nil {
90+
return nil
91+
}
92+
93+
switch e := expr.(type) {
94+
case *ast.AttributePathExpr:
95+
resolved := fb.resolvePathSegments(e.Path)
96+
return &ast.AttributePathExpr{
97+
Variable: e.Variable,
98+
Path: resolved,
99+
Segments: e.Segments,
100+
}
101+
case *ast.BinaryExpr:
102+
return &ast.BinaryExpr{
103+
Left: fb.resolveAssociationPaths(e.Left),
104+
Operator: e.Operator,
105+
Right: fb.resolveAssociationPaths(e.Right),
106+
}
107+
case *ast.UnaryExpr:
108+
return &ast.UnaryExpr{
109+
Operator: e.Operator,
110+
Operand: fb.resolveAssociationPaths(e.Operand),
111+
}
112+
case *ast.FunctionCallExpr:
113+
args := make([]ast.Expression, len(e.Arguments))
114+
for i, arg := range e.Arguments {
115+
args[i] = fb.resolveAssociationPaths(arg)
116+
}
117+
return &ast.FunctionCallExpr{
118+
Name: e.Name,
119+
Arguments: args,
120+
}
121+
case *ast.ParenExpr:
122+
return &ast.ParenExpr{Inner: fb.resolveAssociationPaths(e.Inner)}
123+
case *ast.IfThenElseExpr:
124+
return &ast.IfThenElseExpr{
125+
Condition: fb.resolveAssociationPaths(e.Condition),
126+
ThenExpr: fb.resolveAssociationPaths(e.ThenExpr),
127+
ElseExpr: fb.resolveAssociationPaths(e.ElseExpr),
128+
}
129+
default:
130+
return expr
131+
}
132+
}
133+
134+
// resolvePathSegments processes path segments in an attribute path expression.
135+
// For each segment that is a qualified association name (Module.AssocName), it looks up
136+
// the association's target entity and inserts it after the association.
137+
func (fb *flowBuilder) resolvePathSegments(path []string) []string {
138+
if fb.reader == nil || len(path) == 0 {
139+
return path
140+
}
141+
142+
var resolved []string
143+
for i, segment := range path {
144+
resolved = append(resolved, segment)
145+
146+
// A qualified name (contains ".") that isn't the last segment might be an association
147+
if !strings.Contains(segment, ".") {
148+
continue
149+
}
150+
// If the next segment is already a qualified name, the target entity is already present
151+
if i+1 < len(path) && strings.Contains(path[i+1], ".") {
152+
continue
153+
}
154+
// If this is the last segment, nothing to insert after
155+
if i == len(path)-1 {
156+
continue
157+
}
158+
159+
// Look up association target entity
160+
parts := strings.SplitN(segment, ".", 2)
161+
if len(parts) != 2 {
162+
continue
163+
}
164+
result := fb.lookupAssociation(parts[0], parts[1])
165+
if result != nil && result.childEntityQN != "" {
166+
resolved = append(resolved, result.childEntityQN)
167+
}
168+
}
169+
return resolved
170+
}

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func (fb *flowBuilder) addCreateVariableAction(s *ast.DeclareStmt) model.ID {
3232
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
3333
VariableName: s.Variable,
3434
DataType: convertASTToMicroflowDataType(declType, nil),
35-
InitialValue: expressionToString(s.InitialValue),
35+
InitialValue: fb.exprToString(s.InitialValue),
3636
}
3737

3838
activity := &microflows.ActionActivity{
@@ -64,7 +64,7 @@ func (fb *flowBuilder) addChangeVariableAction(s *ast.MfSetStmt) model.ID {
6464
action := &microflows.ChangeVariableAction{
6565
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
6666
VariableName: s.Target,
67-
Value: expressionToString(s.Value),
67+
Value: fb.exprToString(s.Value),
6868
}
6969

7070
activity := &microflows.ActionActivity{
@@ -108,7 +108,7 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID {
108108
memberChange := &microflows.MemberChange{
109109
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
110110
Type: microflows.MemberChangeTypeSet,
111-
Value: expressionToString(change.Value),
111+
Value: fb.exprToString(change.Value),
112112
}
113113
fb.resolveMemberChange(memberChange, change.Attribute, entityQN)
114114
action.InitialMembers = append(action.InitialMembers, memberChange)
@@ -257,7 +257,7 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID {
257257
memberChange := &microflows.MemberChange{
258258
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
259259
Type: microflows.MemberChangeTypeSet,
260-
Value: expressionToString(change.Value),
260+
Value: fb.exprToString(change.Value),
261261
}
262262
fb.resolveMemberChange(memberChange, change.Attribute, entityQN)
263263
action.Changes = append(action.Changes, memberChange)
@@ -463,13 +463,13 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID
463463
operation = &microflows.FindOperation{
464464
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
465465
ListVariable: s.InputVariable,
466-
Expression: expressionToString(s.Condition),
466+
Expression: fb.exprToString(s.Condition),
467467
}
468468
case ast.ListOpFilter:
469469
operation = &microflows.FilterOperation{
470470
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
471471
ListVariable: s.InputVariable,
472-
Expression: expressionToString(s.Condition),
472+
Expression: fb.exprToString(s.Condition),
473473
}
474474
case ast.ListOpSort:
475475
// Resolve entity type from input variable for qualified attribute names

mdl/executor/cmd_microflows_builder_annotations.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
146146
func (fb *flowBuilder) addEndEventWithReturn(s *ast.ReturnStmt) model.ID {
147147
retVal := ""
148148
if s.Value != nil {
149-
retVal = expressionToString(s.Value)
149+
retVal = fb.exprToString(s.Value)
150150
}
151151

152152
endEvent := &microflows.EndEvent{

0 commit comments

Comments
 (0)