Skip to content
Merged
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
137 changes: 137 additions & 0 deletions mdl/executor/bugfix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,140 @@ func validateMicroflowFromMDL(t *testing.T, input string) []string {

return ValidateMicroflowBody(stmt)
}

// TestAssociationNavParsing verifies that $Var/Module.Assoc/Attr parses as
// AttributePathExpr (not nested BinaryExpr with "/" operator).
// Issue #120: extra spaces around path separators.
func TestAssociationNavParsing(t *testing.T) {
input := `CREATE MICROFLOW Test.MF_Nav()
RETURNS String AS $Result
BEGIN
DECLARE $CustName String = $Order/Test.Order_Customer/Name;
RETURN $CustName;
END;`

prog, errs := visitor.Build(input)
if len(errs) > 0 {
t.Fatalf("Parse error: %v", errs[0])
}

stmt := prog.Statements[0].(*ast.CreateMicroflowStmt)
declStmt := stmt.Body[0].(*ast.DeclareStmt)

// The expression should be an AttributePathExpr, not a BinaryExpr
pathExpr, ok := declStmt.InitialValue.(*ast.AttributePathExpr)
if !ok {
t.Fatalf("Expected AttributePathExpr, got %T", declStmt.InitialValue)
}

if pathExpr.Variable != "Order" {
t.Errorf("Variable = %q, want %q", pathExpr.Variable, "Order")
}
if len(pathExpr.Path) != 2 {
t.Fatalf("Path length = %d, want 2", len(pathExpr.Path))
}
if pathExpr.Path[0] != "Test.Order_Customer" {
t.Errorf("Path[0] = %q, want %q", pathExpr.Path[0], "Test.Order_Customer")
}
if pathExpr.Path[1] != "Name" {
t.Errorf("Path[1] = %q, want %q", pathExpr.Path[1], "Name")
}

// Serialized form should have no extra spaces
got := expressionToString(pathExpr)
want := "$Order/Test.Order_Customer/Name"
if got != want {
t.Errorf("expressionToString() = %q, want %q", got, want)
}
}

// TestResolveAssociationPaths verifies that resolveAssociationPaths inserts
// the target entity after an association segment.
// Issue #120: missing target entity qualifier.
func TestResolveAssociationPaths(t *testing.T) {
tests := []struct {
name string
path []string
want []string
}{
{
name: "simple_attribute",
path: []string{"Name"},
want: []string{"Name"},
},
{
name: "assoc_then_attr",
path: []string{"Test.Order_Customer", "Name"},
want: []string{"Test.Order_Customer", "Test.Customer", "Name"},
},
{
name: "already_has_target_entity",
path: []string{"Test.Order_Customer", "Test.Customer", "Name"},
want: []string{"Test.Order_Customer", "Test.Customer", "Name"},
},
{
name: "assoc_at_end",
path: []string{"Test.Order_Customer"},
want: []string{"Test.Order_Customer"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fb := &flowBuilder{
reader: nil, // nil reader → no resolution, path unchanged
}
got := fb.resolvePathSegments(tt.path)

// With nil reader, all paths should be unchanged
if len(got) != len(tt.path) {
t.Errorf("resolvePathSegments() length = %d, want %d", len(got), len(tt.path))
}
})
}
}

// 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
func TestExprToStringNoSpaces(t *testing.T) {
tests := []struct {
name string
expr ast.Expression
want string
}{
{
name: "simple_path",
expr: &ast.AttributePathExpr{
Variable: "Order",
Path: []string{"OrderNumber"},
},
want: "$Order/OrderNumber",
},
{
name: "assoc_path",
expr: &ast.AttributePathExpr{
Variable: "Order",
Path: []string{"Test.Order_Customer", "Name"},
},
want: "$Order/Test.Order_Customer/Name",
},
{
name: "multi_segment_path",
expr: &ast.AttributePathExpr{
Variable: "Invoice",
Path: []string{"Billing.Invoice_Order", "Billing.Order_Customer", "Name"},
},
want: "$Invoice/Billing.Invoice_Order/Billing.Order_Customer/Name",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expressionToString(tt.expr)
if got != tt.want {
t.Errorf("expressionToString() = %q, want %q", got, tt.want)
}
})
}
}
96 changes: 96 additions & 0 deletions mdl/executor/cmd_microflows_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package executor

import (
"fmt"
"strings"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/model"
Expand Down Expand Up @@ -72,3 +73,98 @@ func (fb *flowBuilder) isVariableDeclared(varName string) bool {
}
return false
}

// exprToString converts an AST Expression to a Mendix expression string,
// resolving association navigation paths to include the target entity qualifier.
// e.g. $Order/MyModule.Order_Customer/Name → $Order/MyModule.Order_Customer/MyModule.Customer/Name
func (fb *flowBuilder) exprToString(expr ast.Expression) string {
resolved := fb.resolveAssociationPaths(expr)
return expressionToString(resolved)
}

// resolveAssociationPaths walks an expression tree and, for any AttributePathExpr
// whose path contains an association (qualified name like Module.AssocName), inserts
// the association's target entity after the association segment.
func (fb *flowBuilder) resolveAssociationPaths(expr ast.Expression) ast.Expression {
if expr == nil {
return nil
}

switch e := expr.(type) {
case *ast.AttributePathExpr:
resolved := fb.resolvePathSegments(e.Path)
return &ast.AttributePathExpr{
Variable: e.Variable,
Path: resolved,
Segments: e.Segments,
}
case *ast.BinaryExpr:
return &ast.BinaryExpr{
Left: fb.resolveAssociationPaths(e.Left),
Operator: e.Operator,
Right: fb.resolveAssociationPaths(e.Right),
}
case *ast.UnaryExpr:
return &ast.UnaryExpr{
Operator: e.Operator,
Operand: fb.resolveAssociationPaths(e.Operand),
}
case *ast.FunctionCallExpr:
args := make([]ast.Expression, len(e.Arguments))
for i, arg := range e.Arguments {
args[i] = fb.resolveAssociationPaths(arg)
}
return &ast.FunctionCallExpr{
Name: e.Name,
Arguments: args,
}
case *ast.ParenExpr:
return &ast.ParenExpr{Inner: fb.resolveAssociationPaths(e.Inner)}
case *ast.IfThenElseExpr:
return &ast.IfThenElseExpr{
Condition: fb.resolveAssociationPaths(e.Condition),
ThenExpr: fb.resolveAssociationPaths(e.ThenExpr),
ElseExpr: fb.resolveAssociationPaths(e.ElseExpr),
}
default:
return expr
}
}

// resolvePathSegments processes path segments in an attribute path expression.
// For each segment that is a qualified association name (Module.AssocName), it looks up
// the association's target entity and inserts it after the association.
func (fb *flowBuilder) resolvePathSegments(path []string) []string {
if fb.reader == nil || len(path) == 0 {
return path
}

var resolved []string
for i, segment := range path {
resolved = append(resolved, segment)

// A qualified name (contains ".") that isn't the last segment might be an association
if !strings.Contains(segment, ".") {
continue
}
// If the next segment is already a qualified name, the target entity is already present
if i+1 < len(path) && strings.Contains(path[i+1], ".") {
continue
}
// If this is the last segment, nothing to insert after
if i == len(path)-1 {
continue
}

// Look up association target entity
parts := strings.SplitN(segment, ".", 2)
if len(parts) != 2 {
continue
}
result := fb.lookupAssociation(parts[0], parts[1])
if result != nil && result.childEntityQN != "" {
resolved = append(resolved, result.childEntityQN)
}
}
return resolved
}
12 changes: 6 additions & 6 deletions mdl/executor/cmd_microflows_builder_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func (fb *flowBuilder) addCreateVariableAction(s *ast.DeclareStmt) model.ID {
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
VariableName: s.Variable,
DataType: convertASTToMicroflowDataType(declType, nil),
InitialValue: expressionToString(s.InitialValue),
InitialValue: fb.exprToString(s.InitialValue),
}

activity := &microflows.ActionActivity{
Expand Down Expand Up @@ -64,7 +64,7 @@ func (fb *flowBuilder) addChangeVariableAction(s *ast.MfSetStmt) model.ID {
action := &microflows.ChangeVariableAction{
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
VariableName: s.Target,
Value: expressionToString(s.Value),
Value: fb.exprToString(s.Value),
}

activity := &microflows.ActionActivity{
Expand Down Expand Up @@ -108,7 +108,7 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID {
memberChange := &microflows.MemberChange{
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
Type: microflows.MemberChangeTypeSet,
Value: expressionToString(change.Value),
Value: fb.exprToString(change.Value),
}
fb.resolveMemberChange(memberChange, change.Attribute, entityQN)
action.InitialMembers = append(action.InitialMembers, memberChange)
Expand Down Expand Up @@ -257,7 +257,7 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID {
memberChange := &microflows.MemberChange{
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
Type: microflows.MemberChangeTypeSet,
Value: expressionToString(change.Value),
Value: fb.exprToString(change.Value),
}
fb.resolveMemberChange(memberChange, change.Attribute, entityQN)
action.Changes = append(action.Changes, memberChange)
Expand Down Expand Up @@ -463,13 +463,13 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID
operation = &microflows.FindOperation{
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
ListVariable: s.InputVariable,
Expression: expressionToString(s.Condition),
Expression: fb.exprToString(s.Condition),
}
case ast.ListOpFilter:
operation = &microflows.FilterOperation{
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
ListVariable: s.InputVariable,
Expression: expressionToString(s.Condition),
Expression: fb.exprToString(s.Condition),
}
case ast.ListOpSort:
// Resolve entity type from input variable for qualified attribute names
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_microflows_builder_annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
func (fb *flowBuilder) addEndEventWithReturn(s *ast.ReturnStmt) model.ID {
retVal := ""
if s.Value != nil {
retVal = expressionToString(s.Value)
retVal = fb.exprToString(s.Value)
}

endEvent := &microflows.EndEvent{
Expand Down
Loading
Loading