Skip to content
Open
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
56 changes: 56 additions & 0 deletions mdl-examples/bug-tests/343-list-attribute-find-filter.mdl
Original file line number Diff line number Diff line change
@@ -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;
/
47 changes: 47 additions & 0 deletions mdl/executor/bugfix_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,53 @@ func TestEmptyChangeObjectRefreshesInClient(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"},
Expand Down
80 changes: 72 additions & 8 deletions mdl/executor/cmd_microflows_builder_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,16 +494,24 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID
ListVariable: s.InputVariable,
}
case ast.ListOpFind:
operation = &microflows.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 = &microflows.FindOperation{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
ListVariable: s.InputVariable,
Expression: fb.exprToString(s.Condition),
}
}
case ast.ListOpFilter:
operation = &microflows.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 = &microflows.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
Expand Down Expand Up @@ -625,6 +633,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 &microflows.FilterByAttributeOperation{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
ListVariable: s.InputVariable,
Attribute: attributeName,
Association: associationName,
Expression: expression,
}
}
return &microflows.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 := &microflows.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
Expand Down
62 changes: 62 additions & 0 deletions sdk/mpr/writer_listoperation_test.go
Original file line number Diff line number Diff line change
@@ -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(&microflows.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(&microflows.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
}
18 changes: 18 additions & 0 deletions sdk/mpr/writer_microflow_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,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
Expand Down
Loading