diff --git a/mdl-examples/bug-tests/343-list-attribute-find-filter.mdl b/mdl-examples/bug-tests/343-list-attribute-find-filter.mdl new file mode 100644 index 00000000..f639b29e --- /dev/null +++ b/mdl-examples/bug-tests/343-list-attribute-find-filter.mdl @@ -0,0 +1,56 @@ +-- ============================================================================ +-- Bug #343: Attribute-based find/filter list operations rebuilt as expressions +-- ============================================================================ +-- +-- Symptom (before fix): +-- `find($List, Attribute = expression)` and +-- `filter($List, Attribute = expression)` could be described correctly +-- from existing models, but `mxcli exec` always rebuilt them as +-- `FindByExpression` / `FilterByExpression`. Studio Pro's expression +-- validator then surfaced CE0117 on the list operation activity even +-- though the MDL source had not been edited. +-- +-- After fix: +-- The builder resolves the equality LHS to an attribute (or association) +-- on the input list's element type and emits +-- `Microflows$Find` / `Microflows$Filter` (`FindByAttributeOperation` / +-- `FilterByAttributeOperation`) when possible. Complex conditions still +-- fall back to the expression-based shape. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/343-list-attribute-find-filter.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest343.MF_FindByAttribute" +-- `mx check` against the resulting MPR must report 0 errors and the +-- activities must not surface CE0117 in Studio Pro. +-- ============================================================================ + +create module BugTest343; + +create entity BugTest343.Item ( + Name : string(100), + Active : boolean +); +/ + +-- find by attribute equality — rebuild must emit Microflows$Find with +-- attribute-operation shape, not FindByExpression. +create microflow BugTest343.MF_FindByAttribute ( + $Items: list of BugTest343.Item, + $Target: string +) +returns BugTest343.Item as $Found +begin + $Found = find($Items, Name = $Target); +end; +/ + +-- filter by attribute equality — rebuild must emit Microflows$Filter +-- with attribute-operation shape, not FilterByExpression. +create microflow BugTest343.MF_FilterByAttribute ( + $Items: list of BugTest343.Item +) +returns list of BugTest343.Item as $Active +begin + $Active = filter($Items, Active = true); +end; +/ diff --git a/mdl/executor/bugfix_regression_test.go b/mdl/executor/bugfix_regression_test.go index 0e1ed366..7af5c63c 100644 --- a/mdl/executor/bugfix_regression_test.go +++ b/mdl/executor/bugfix_regression_test.go @@ -675,6 +675,53 @@ func TestCallMicroflowResultType_ResolvesSubsequentChangeMember(t *testing.T) { } } +func TestListFindAttributeEqualsExpressionUsesAttributeOperation(t *testing.T) { + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{ + "Items": "List of Demo.Item", + }, + } + + id := fb.addListOperationAction(&ast.ListOperationStmt{ + OutputVariable: "ExistingItem", + Operation: ast.ListOpFind, + InputVariable: "Items", + Condition: &ast.BinaryExpr{ + Left: &ast.IdentifierExpr{Name: "Code"}, + Operator: "=", + Right: &ast.AttributePathExpr{ + Variable: "IteratorItem", + Path: []string{"ExternalCode"}, + }, + }, + }) + if id == "" || len(fb.objects) != 1 { + t.Fatalf("expected one list operation activity, got id=%q objects=%d", id, len(fb.objects)) + } + + activity, ok := fb.objects[0].(*microflows.ActionActivity) + if !ok { + t.Fatalf("object type = %T, want *microflows.ActionActivity", fb.objects[0]) + } + action, ok := activity.Action.(*microflows.ListOperationAction) + if !ok { + t.Fatalf("action type = %T, want *microflows.ListOperationAction", activity.Action) + } + op, ok := action.Operation.(*microflows.FindByAttributeOperation) + if !ok { + t.Fatalf("operation type = %T, want *microflows.FindByAttributeOperation", action.Operation) + } + if op.Attribute != "Demo.Item.Code" { + t.Fatalf("Attribute = %q, want Demo.Item.Code", op.Attribute) + } + if op.Expression != "$IteratorItem/ExternalCode" { + t.Fatalf("Expression = %q, want $IteratorItem/ExternalCode", op.Expression) + } +} + func TestCallMicroflowUnknownResultTypeStillDeclaresVariable(t *testing.T) { fb := &flowBuilder{ varTypes: map[string]string{"Result": "Old.ModuleEntity"}, diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 9559eea2..088be8d6 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -460,16 +460,24 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID ListVariable: s.InputVariable, } case ast.ListOpFind: - operation = µflows.FindOperation{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - ListVariable: s.InputVariable, - Expression: fb.exprToString(s.Condition), + if op := fb.listAttributeOperation(s, false); op != nil { + operation = op + } else { + operation = µflows.FindOperation{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + ListVariable: s.InputVariable, + Expression: fb.exprToString(s.Condition), + } } case ast.ListOpFilter: - operation = µflows.FilterOperation{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - ListVariable: s.InputVariable, - Expression: fb.exprToString(s.Condition), + if op := fb.listAttributeOperation(s, true); op != nil { + operation = op + } else { + operation = µflows.FilterOperation{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + ListVariable: s.InputVariable, + Expression: fb.exprToString(s.Condition), + } } case ast.ListOpSort: // Resolve entity type from input variable for qualified attribute names @@ -591,6 +599,62 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID return activity.ID } +func (fb *flowBuilder) listAttributeOperation(s *ast.ListOperationStmt, filter bool) microflows.ListOperation { + binary, ok := s.Condition.(*ast.BinaryExpr) + if !ok || binary.Operator != "=" { + return nil + } + fieldName, ok := listOperationFieldName(binary.Left) + if !ok || fieldName == "" { + return nil + } + expression := fb.exprToString(binary.Right) + if expression == "" { + return nil + } + + attributeName, associationName := fb.resolveListOperationMember(s.InputVariable, fieldName) + if filter { + return µflows.FilterByAttributeOperation{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + ListVariable: s.InputVariable, + Attribute: attributeName, + Association: associationName, + Expression: expression, + } + } + return µflows.FindByAttributeOperation{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + ListVariable: s.InputVariable, + Attribute: attributeName, + Association: associationName, + Expression: expression, + } +} + +func listOperationFieldName(expr ast.Expression) (string, bool) { + switch e := expr.(type) { + case *ast.IdentifierExpr: + return e.Name, true + case *ast.QualifiedNameExpr: + return e.QualifiedName.String(), true + default: + return "", false + } +} + +func (fb *flowBuilder) resolveListOperationMember(listVariable, memberName string) (attributeName, associationName string) { + entityQN := "" + if fb.varTypes != nil { + if listType := fb.varTypes[listVariable]; strings.HasPrefix(listType, "List of ") { + entityQN = strings.TrimPrefix(listType, "List of ") + } + } + memberChange := µflows.MemberChange{} + fb.resolveMemberChange(memberChange, memberName, entityQN) + return memberChange.AttributeQualifiedName, memberChange.AssociationQualifiedName +} + // addAggregateListAction creates aggregate operations like COUNT, SUM, AVERAGE, etc. func (fb *flowBuilder) addAggregateListAction(s *ast.AggregateListStmt) model.ID { var function microflows.AggregateFunction diff --git a/sdk/mpr/writer_listoperation_test.go b/sdk/mpr/writer_listoperation_test.go new file mode 100644 index 00000000..dd8739a5 --- /dev/null +++ b/sdk/mpr/writer_listoperation_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "go.mongodb.org/mongo-driver/bson" +) + +func TestSerializeListOperation_FindByAttribute(t *testing.T) { + doc := serializeListOperation(µflows.FindByAttributeOperation{ + BaseElement: model.BaseElement{ID: "operation-id"}, + ListVariable: "Items", + Attribute: "Demo.Item.Code", + Expression: "$IteratorItem/ExternalCode", + }) + fields := listOperationDocMap(doc) + + if got := fields["$Type"]; got != "Microflows$Find" { + t.Fatalf("$Type = %v, want Microflows$Find", got) + } + if got := fields["Attribute"]; got != "Demo.Item.Code" { + t.Fatalf("Attribute = %v, want Demo.Item.Code", got) + } + if got := fields["Expression"]; got != "$IteratorItem/ExternalCode" { + t.Fatalf("Expression = %v, want $IteratorItem/ExternalCode", got) + } + if got := fields["ListName"]; got != "Items" { + t.Fatalf("ListName = %v, want Items", got) + } +} + +func TestSerializeListOperation_FilterByAssociation(t *testing.T) { + doc := serializeListOperation(µflows.FilterByAttributeOperation{ + BaseElement: model.BaseElement{ID: "operation-id"}, + ListVariable: "Items", + Association: "Demo.Item_Category", + Expression: "$Category", + }) + fields := listOperationDocMap(doc) + + if got := fields["$Type"]; got != "Microflows$Filter" { + t.Fatalf("$Type = %v, want Microflows$Filter", got) + } + if got := fields["Association"]; got != "Demo.Item_Category" { + t.Fatalf("Association = %v, want Demo.Item_Category", got) + } + if got := fields["Expression"]; got != "$Category" { + t.Fatalf("Expression = %v, want $Category", got) + } +} + +func listOperationDocMap(doc bson.D) map[string]any { + fields := make(map[string]any, len(doc)) + for _, elem := range doc { + fields[elem.Key] = elem.Value + } + return fields +} diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go index 25c7ab08..6dfd7307 100644 --- a/sdk/mpr/writer_microflow_actions.go +++ b/sdk/mpr/writer_microflow_actions.go @@ -868,6 +868,24 @@ func serializeListOperation(op microflows.ListOperation) bson.D { {Key: "Expression", Value: o.Expression}, {Key: "ListName", Value: o.ListVariable}, // storageName: ListName } + case *microflows.FindByAttributeOperation: + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, + {Key: "$Type", Value: "Microflows$Find"}, + {Key: "Association", Value: o.Association}, + {Key: "Attribute", Value: o.Attribute}, + {Key: "Expression", Value: o.Expression}, + {Key: "ListName", Value: o.ListVariable}, + } + case *microflows.FilterByAttributeOperation: + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, + {Key: "$Type", Value: "Microflows$Filter"}, + {Key: "Association", Value: o.Association}, + {Key: "Attribute", Value: o.Attribute}, + {Key: "Expression", Value: o.Expression}, + {Key: "ListName", Value: o.ListVariable}, + } case *microflows.SortOperation: // Build sorting items sortings := bson.A{int32(3)} // Array with items marker