From 0ec688aa3ad58395f566e9e63d0254d00aa47b13 Mon Sep 17 00:00:00 2001 From: engalar Date: Fri, 10 Apr 2026 07:56:19 +0800 Subject: [PATCH] fix: resolve association navigation expression missing target entity and extra spaces Fixes two bugs in microflow expression generation (issue #120): 1. Parser: tryBuildAttributePath only handled LiteralExpr/VariableExpr as path components, so $Var/Module.Assoc/Attr was parsed as nested BinaryExpr with "/" operator, producing spaces: "$Var / Module.Assoc / Attr". Added IdentifierExpr and QualifiedNameExpr handling. 2. Builder: association navigation paths lacked the target entity qualifier required by Mendix (CE0117). Added resolveAssociationPaths on flowBuilder to auto-insert the target entity after association segments using the existing lookupAssociation infrastructure. --- mdl/executor/bugfix_test.go | 137 ++++++++++++++++++ mdl/executor/cmd_microflows_builder.go | 96 ++++++++++++ .../cmd_microflows_builder_actions.go | 12 +- .../cmd_microflows_builder_annotations.go | 2 +- mdl/executor/cmd_microflows_builder_calls.go | 44 +++--- .../cmd_microflows_builder_control.go | 6 +- .../cmd_microflows_builder_workflow.go | 4 +- mdl/visitor/visitor_microflow_expression.go | 7 +- 8 files changed, 272 insertions(+), 36 deletions(-) diff --git a/mdl/executor/bugfix_test.go b/mdl/executor/bugfix_test.go index 54c018c5..d04abf76 100644 --- a/mdl/executor/bugfix_test.go +++ b/mdl/executor/bugfix_test.go @@ -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) + } + }) + } +} diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index c988a2ed..ce70bf75 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -5,6 +5,7 @@ package executor import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/model" @@ -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 +} diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index ea6776c0..1b299895 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -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 := µflows.ActionActivity{ @@ -64,7 +64,7 @@ func (fb *flowBuilder) addChangeVariableAction(s *ast.MfSetStmt) model.ID { action := µflows.ChangeVariableAction{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, VariableName: s.Target, - Value: expressionToString(s.Value), + Value: fb.exprToString(s.Value), } activity := µflows.ActionActivity{ @@ -108,7 +108,7 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID { memberChange := µflows.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) @@ -257,7 +257,7 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID { memberChange := µflows.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) @@ -463,13 +463,13 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID operation = µflows.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 = µflows.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 diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 1b3988ac..668b9511 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -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 := µflows.EndEvent{ diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 1c1fdab0..33aef8ee 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -38,7 +38,7 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID { if len(s.Template) > 0 { // Use provided template parameters - templateText = expressionToString(s.Message) + templateText = fb.exprToString(s.Message) // Sort parameters by index to ensure correct order maxIndex := 0 for _, p := range s.Template { @@ -49,7 +49,7 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID { templateParams = make([]string, maxIndex) for _, p := range s.Template { if p.Index > 0 && p.Index <= maxIndex { - templateParams[p.Index-1] = expressionToString(p.Value) + templateParams[p.Index-1] = fb.exprToString(p.Value) } } } else if lit, ok := s.Message.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString { @@ -58,7 +58,7 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID { } else { // Complex expression - use {1} placeholder and add expression as parameter templateText = "{1}" - templateParams = []string{expressionToString(s.Message)} + templateParams = []string{fb.exprToString(s.Message)} } action := µflows.LogMessageAction{ @@ -103,7 +103,7 @@ func (fb *flowBuilder) addCallMicroflowAction(s *ast.CallMicroflowStmt) model.ID mapping := µflows.MicroflowCallParameterMapping{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, Parameter: paramQN, - Argument: expressionToString(arg.Value), + Argument: fb.exprToString(arg.Value), } mappings = append(mappings, mapping) } @@ -181,7 +181,7 @@ func (fb *flowBuilder) addCallJavaActionAction(s *ast.CallJavaActionStmt) model. if entityTypeParams[arg.Name] { // Entity type parameter: value is the entity qualified name, not the variable reference. // When the argument is a variable like $Email, resolve its entity type from varTypes. - valueExpr := expressionToString(arg.Value) + valueExpr := fb.exprToString(arg.Value) entityName := strings.Trim(valueExpr, "'") if strings.HasPrefix(entityName, "$") { varName := strings.TrimPrefix(entityName, "$") @@ -195,7 +195,7 @@ func (fb *flowBuilder) addCallJavaActionAction(s *ast.CallJavaActionStmt) model. } } else { // Regular parameter: expression-based value - valueExpr := expressionToString(arg.Value) + valueExpr := fb.exprToString(arg.Value) value = µflows.BasicCodeActionParameterValue{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, Argument: valueExpr, @@ -255,7 +255,7 @@ func (fb *flowBuilder) addCallExternalActionAction(s *ast.CallExternalActionStmt mapping := µflows.ExternalActionParameterMapping{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, ParameterName: arg.Name, - Argument: expressionToString(arg.Value), + Argument: fb.exprToString(arg.Value), } mappings = append(mappings, mapping) } @@ -310,7 +310,7 @@ func (fb *flowBuilder) addShowPageAction(s *ast.ShowPageStmt) model.ID { mapping := µflows.PageParameterMapping{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, Parameter: paramQN, - Argument: expressionToString(arg.Value), + Argument: fb.exprToString(arg.Value), } mappings = append(mappings, mapping) } @@ -411,12 +411,12 @@ func (fb *flowBuilder) addShowMessageAction(s *ast.ShowMessageStmt) model.ID { templateText = fmt.Sprintf("%v", lit.Value) } else { templateText = "{1}" - templateParams = []string{expressionToString(s.Message)} + templateParams = []string{fb.exprToString(s.Message)} } // Append template parameters from TemplateArgs (e.g., OBJECTS [$Var1, $Var2]) for _, arg := range s.TemplateArgs { - templateParams = append(templateParams, expressionToString(arg)) + templateParams = append(templateParams, fb.exprToString(arg)) } template := &model.Text{ @@ -497,7 +497,7 @@ func (fb *flowBuilder) addValidationFeedbackAction(s *ast.ValidationFeedbackStmt } else { // Complex expression - use {1} placeholder and add expression as parameter templateText = "{1}" - templateParams = []string{expressionToString(s.Message)} + templateParams = []string{fb.exprToString(s.Message)} } // Create template with translations map (default language "en_US") @@ -537,7 +537,7 @@ func (fb *flowBuilder) addValidationFeedbackAction(s *ast.ValidationFeedbackStmt // Append template parameters from TemplateArgs (e.g., OBJECTS [$Var1, $Var2]) for _, arg := range s.TemplateArgs { - templateParams = append(templateParams, expressionToString(arg)) + templateParams = append(templateParams, fb.exprToString(arg)) } // Strip the $ prefix from variable name for BSON storage @@ -599,12 +599,12 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { if lit, ok := s.URL.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString { httpConfig.LocationTemplate = fmt.Sprintf("%v", lit.Value) } else { - httpConfig.LocationTemplate = expressionToString(s.URL) + httpConfig.LocationTemplate = fb.exprToString(s.URL) } // Set URL template parameters for _, param := range s.URLParams { - httpConfig.LocationParams = append(httpConfig.LocationParams, expressionToString(param.Value)) + httpConfig.LocationParams = append(httpConfig.LocationParams, fb.exprToString(param.Value)) } // Set custom headers @@ -612,7 +612,7 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { h := µflows.HttpHeader{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, Name: header.Name, - Value: expressionToString(header.Value), + Value: fb.exprToString(header.Value), } httpConfig.CustomHeaders = append(httpConfig.CustomHeaders, h) } @@ -620,8 +620,8 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { // Set authentication if s.Auth != nil { httpConfig.UseAuthentication = true - httpConfig.Username = expressionToString(s.Auth.Username) - httpConfig.Password = expressionToString(s.Auth.Password) + httpConfig.Username = fb.exprToString(s.Auth.Username) + httpConfig.Password = fb.exprToString(s.Auth.Password) } // Build request handling @@ -634,12 +634,12 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { if lit, ok := s.Body.Template.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString { template = fmt.Sprintf("%v", lit.Value) } else { - template = expressionToString(s.Body.Template) + template = fb.exprToString(s.Body.Template) } // Extract template parameters var templateParams []string for _, param := range s.Body.TemplateParams { - templateParams = append(templateParams, expressionToString(param.Value)) + templateParams = append(templateParams, fb.exprToString(param.Value)) } requestHandling = µflows.CustomRequestHandling{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, @@ -721,7 +721,7 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { // Build timeout expression var timeoutExpr string if s.Timeout != nil { - timeoutExpr = expressionToString(s.Timeout) + timeoutExpr = fb.exprToString(s.Timeout) } else { timeoutExpr = "300" // Default 5 minutes } @@ -835,7 +835,7 @@ func (fb *flowBuilder) addExecuteDatabaseQueryAction(s *ast.ExecuteDatabaseQuery pm := µflows.DatabaseQueryParameterMapping{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, ParameterName: arg.Name, - Value: expressionToString(arg.Value), + Value: fb.exprToString(arg.Value), } action.ParameterMappings = append(action.ParameterMappings, pm) } @@ -845,7 +845,7 @@ func (fb *flowBuilder) addExecuteDatabaseQueryAction(s *ast.ExecuteDatabaseQuery cm := µflows.DatabaseConnectionParameterMapping{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, ParameterName: arg.Name, - Value: expressionToString(arg.Value), + Value: fb.exprToString(arg.Value), } action.ConnectionParameterMappings = append(action.ConnectionParameterMappings, cm) } diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 5e819142..4d002827 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -46,7 +46,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { // Create ExclusiveSplit with expression condition splitCondition := µflows.ExpressionSplitCondition{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, - Expression: expressionToString(s.Condition), + Expression: fb.exprToString(s.Condition), } split := µflows.ExclusiveSplit{ @@ -55,7 +55,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { Position: model.Point{X: splitX, Y: centerY}, Size: model.Size{Width: SplitWidth, Height: SplitHeight}, }, - Caption: expressionToString(s.Condition), + Caption: fb.exprToString(s.Condition), SplitCondition: splitCondition, ErrorHandlingType: microflows.ErrorHandlingTypeRollback, } @@ -343,7 +343,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { } } - whileExpr := expressionToString(s.Condition) + whileExpr := fb.exprToString(s.Condition) loop := µflows.LoopedActivity{ BaseMicroflowObject: microflows.BaseMicroflowObject{ diff --git a/mdl/executor/cmd_microflows_builder_workflow.go b/mdl/executor/cmd_microflows_builder_workflow.go index 2ff452d5..a14d6ef9 100644 --- a/mdl/executor/cmd_microflows_builder_workflow.go +++ b/mdl/executor/cmd_microflows_builder_workflow.go @@ -38,7 +38,7 @@ func (fb *flowBuilder) addCallWorkflowAction(s *ast.CallWorkflowStmt) model.ID { wfQN := s.Workflow.Module + "." + s.Workflow.Name ctxVar := "" if len(s.Arguments) > 0 { - ctxVar = expressionToString(s.Arguments[0].Value) + ctxVar = fb.exprToString(s.Arguments[0].Value) // Strip leading $ if present if len(ctxVar) > 0 && ctxVar[0] == '$' { ctxVar = ctxVar[1:] @@ -93,7 +93,7 @@ func (fb *flowBuilder) addWorkflowOperationAction(s *ast.WorkflowOperationStmt) case "ABORT": reason := "" if s.Reason != nil { - reason = expressionToString(s.Reason) + reason = fb.exprToString(s.Reason) } op = µflows.AbortOperation{ BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())}, diff --git a/mdl/visitor/visitor_microflow_expression.go b/mdl/visitor/visitor_microflow_expression.go index ea4c9235..0ac8ab19 100644 --- a/mdl/visitor/visitor_microflow_expression.go +++ b/mdl/visitor/visitor_microflow_expression.go @@ -266,15 +266,18 @@ func buildMultiplicativeExpression(ctx parser.IMultiplicativeExpressionContext) // tryBuildAttributePath attempts to build an AttributePathExpr from a left expression // and a right identifier. Returns nil if not an XPath-style path. func tryBuildAttributePath(left ast.Expression, right ast.Expression) *ast.AttributePathExpr { - // Right must be an identifier (LiteralExpr with string kind representing an identifier) + // Right must be a path component: identifier, qualified name, or string literal var pathPart string switch r := right.(type) { + case *ast.IdentifierExpr: + pathPart = r.Name + case *ast.QualifiedNameExpr: + pathPart = r.QualifiedName.String() case *ast.LiteralExpr: if r.Kind == ast.LiteralString { pathPart, _ = r.Value.(string) } case *ast.VariableExpr: - // This shouldn't normally happen, but handle it pathPart = r.Name }