From 21650c9386ea9df5dbd8e156d33aeb1a33bc0173 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 09:49:53 +0200 Subject: [PATCH 1/7] fix: infer SingleObject from import mapping root when JsonStructure absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addRestCallAction and addImportFromMappingAction decide whether the mapping result is a single object or a list by looking up the JSON structure the import mapping references. For mappings backed by an XML schema or message definition the mapping has no JsonStructure, so the JSON-structure lookup short-circuits and SingleObject defaults to false (REST) or stays true (import-from-mapping). When the authored mapping is rooted on an Object, the resulting BSON ResultHandling type (ListType vs ObjectType) and Range.SingleObject flag mismatch the microflow's ObjectType return, surfacing as CE0117 "Error in expression" at the End event (and CE0019/CE0136/CE0243 cascades on any downstream retrieve over the misclassified variable). Fall back to the import mapping's own root element kind when JsonStructure is empty: ImportMappingElement.Kind is "Object", "Array", or "Value" — Studio Pro authors it the same way for both JSON-backed and schema/message-definition mappings, so a single source of truth at the mapping's first element correctly recovers the shape for both code paths. Two regression tests cover the new fallback (Object → SingleObject=true, Array → SingleObject=false) using synthetic Module.Mapping names; the existing JSON-structure-driven test still asserts the prior path. Co-Authored-By: Claude Opus 4.7 (1M context) --- mdl/executor/cmd_microflows_builder_calls.go | 37 ++++++-- ...d_microflows_builder_rest_response_test.go | 95 +++++++++++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index c5a7f666..feb7ac74 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1025,18 +1025,31 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { // MDL did not explicitly assign one. s.OutputVariable = s.Result.ResultEntity.Name } - // Determine whether the import mapping returns a single object or a list by - // looking at the JSON structure it references. If the root JSON element is - // an Object, the mapping produces one object; if it is an Array, a list. + // Determine whether the import mapping returns a single object or a list. + // First try the JSON structure it references — if the root JSON element + // is an Object, the mapping produces one object; if it is an Array, a + // list. When the mapping is backed by an XML schema or message + // definition (no JsonStructure set), fall back to the import mapping's + // own root element kind, which Studio Pro authors as "Object" or + // "Array" the same way. singleObject := false if fb.backend != nil { - if im, err := fb.backend.GetImportMappingByQualifiedName(s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil && im.JsonStructure != "" { - // im.JsonStructure is "Module.Name" — split and look up the JSON structure. - if parts := strings.SplitN(im.JsonStructure, ".", 2); len(parts) == 2 { - if js, err := fb.backend.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { - singleObject = js.Elements[0].ElementType == "Object" + if im, err := fb.backend.GetImportMappingByQualifiedName(s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil { + resolved := false + if im.JsonStructure != "" { + // im.JsonStructure is "Module.Name" — split and look up the JSON structure. + if parts := strings.SplitN(im.JsonStructure, ".", 2); len(parts) == 2 { + if js, err := fb.backend.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { + singleObject = js.Elements[0].ElementType == "Object" + resolved = true + } } } + if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil { + // XML schema / message-definition mappings carry the + // single-vs-list shape on the root mapping element itself. + singleObject = im.Elements[0].Kind == "Object" + } } } resultHandling = µflows.ResultHandlingMapping{ @@ -1318,9 +1331,13 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) } // Determine single vs list and result entity from the import mapping. + // JSON structure check covers JSON-backed mappings; for XML schema or + // message-definition mappings JsonStructure is empty and the root + // element kind on the mapping itself indicates Array vs Object. resultEntityQN := "" if fb.backend != nil { if im, err := fb.backend.GetImportMappingByQualifiedName(s.Mapping.Module, s.Mapping.Name); err == nil { + resolved := false if im.JsonStructure != "" { parts := strings.SplitN(im.JsonStructure, ".", 2) if len(parts) == 2 { @@ -1328,9 +1345,13 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) if js.Elements[0].ElementType == "Array" { resultHandling.SingleObject = false } + resolved = true } } } + if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil && im.Elements[0].Kind == "Array" { + resultHandling.SingleObject = false + } if len(im.Elements) > 0 && im.Elements[0].Entity != "" { resultEntityQN = im.Elements[0].Entity resultHandling.ResultEntityID = model.ID(resultEntityQN) diff --git a/mdl/executor/cmd_microflows_builder_rest_response_test.go b/mdl/executor/cmd_microflows_builder_rest_response_test.go index e1d111ff..7dec3f7d 100644 --- a/mdl/executor/cmd_microflows_builder_rest_response_test.go +++ b/mdl/executor/cmd_microflows_builder_rest_response_test.go @@ -58,6 +58,101 @@ func TestAddRestCallAction_ReturnsResponseUsesHttpResponseHandling(t *testing.T) } } +// REST call mappings backed by an XML schema or message definition (no +// JsonStructure set) must still infer single-vs-list from the import +// mapping's own root element kind. Otherwise the builder defaults to +// SingleObject=false and emits a ListType result, which mismatches the +// authored ObjectType return and triggers CE0117 / CE0019 / CE0136 +// downstream when the microflow's return value references the result. +func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStructureMissing(t *testing.T) { + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + measurer: &layoutMeasurer{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { + if moduleName != "Synthetic" || name != "MsgDefMapping" { + return nil, fmt.Errorf("unexpected import mapping %s.%s", moduleName, name) + } + return &model.ImportMapping{ + Name: "MsgDefMapping", + // Empty JsonStructure simulates an XML-schema or message- + // definition backed mapping. + JsonStructure: "", + Elements: []*model.ImportMappingElement{ + {Kind: "Object", Entity: "Synthetic.Item"}, + }, + }, nil + }, + }, + } + + stmt := &ast.RestCallStmt{ + OutputVariable: "Item", + Method: ast.HttpMethodGet, + URL: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "https://example.com"}, + Result: ast.RestResult{ + Type: ast.RestResultMapping, + MappingName: ast.QualifiedName{Module: "Synthetic", Name: "MsgDefMapping"}, + ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, + }, + } + fb.addRestCallAction(stmt) + + activity := fb.objects[0].(*microflows.ActionActivity) + action := activity.Action.(*microflows.RestCallAction) + mapping := action.ResultHandling.(*microflows.ResultHandlingMapping) + if !mapping.SingleObject { + t.Errorf("SingleObject = false, want true (root mapping element Kind=Object)") + } +} + +// And the inverse: an Array root on the mapping element must yield a +// list-typed result handling. +func TestAddRestCallAction_MappingFallsBackToArrayKindWhenJsonStructureMissing(t *testing.T) { + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + measurer: &layoutMeasurer{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { + return &model.ImportMapping{ + Name: "ArrMapping", + JsonStructure: "", + Elements: []*model.ImportMappingElement{ + {Kind: "Array", Entity: "Synthetic.Item"}, + }, + }, nil + }, + }, + } + + stmt := &ast.RestCallStmt{ + OutputVariable: "Items", + Method: ast.HttpMethodGet, + URL: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "https://example.com"}, + Result: ast.RestResult{ + Type: ast.RestResultMapping, + MappingName: ast.QualifiedName{Module: "Synthetic", Name: "ArrMapping"}, + ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, + }, + } + fb.addRestCallAction(stmt) + + activity := fb.objects[0].(*microflows.ActionActivity) + action := activity.Action.(*microflows.RestCallAction) + mapping := action.ResultHandling.(*microflows.ResultHandlingMapping) + if mapping.SingleObject { + t.Errorf("SingleObject = true, want false (root mapping element Kind=Array)") + } +} + func TestAddRestCallAction_MappingResultPreservesExplicitOutputVariable(t *testing.T) { fb := &flowBuilder{ posX: 100, From 2f739d87a7247110cfbfc3fe90e5dcec2c1b2867 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 13:10:33 +0200 Subject: [PATCH 2/7] fix: treat repeating Object root-element as list in import mapping fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the import mapping is backed by an XML schema or message definition (no JsonStructure), the previous fallback infered single-vs- list purely from `im.Elements[0].Kind`. Studio Pro models a repeating Object element — `MaxOccurs > 1` or unbounded (`-1`) — as a list of that entity, distinct from a singleton. Treating the root as a single object for those mappings produces a non-list result variable, so any downstream aggregate / loop / list-operation activity that consumes it fails `mx check` with CE0013 "Input variable must be of type 'List'" or CE0100 "is of type X, but should be of type List". Augment the fallback to consult MaxOccurs alongside Kind: an Object root with MaxOccurs > 1 or -1 yields a list; a non-repeating Object root keeps the singleton behaviour, matching Studio Pro's authored BSON. The same MaxOccurs check is added to the symmetric addImportFromMappingAction path. Two new regression tests cover the unbounded (-1) and bounded (>1) repeat cases; the existing singleton test now sets `MaxOccurs: 1, MinOccurs: 1` to pin the unchanged path. Co-Authored-By: Claude Opus 4.7 (1M context) --- mdl/executor/cmd_microflows_builder_calls.go | 20 ++++- ...d_microflows_builder_rest_response_test.go | 89 ++++++++++++++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index feb7ac74..3eedd123 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1048,7 +1048,16 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil { // XML schema / message-definition mappings carry the // single-vs-list shape on the root mapping element itself. - singleObject = im.Elements[0].Kind == "Object" + // MaxOccurs > 1 or unbounded (-1) signals a list even + // when the kind is Object — Studio Pro models a + // repeating Object element as a list, distinct from a + // singleton. + root := im.Elements[0] + if root.Kind == "Array" || root.MaxOccurs == -1 || root.MaxOccurs > 1 { + singleObject = false + } else { + singleObject = root.Kind == "Object" + } } } } @@ -1349,8 +1358,13 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) } } } - if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil && im.Elements[0].Kind == "Array" { - resultHandling.SingleObject = false + if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil { + // MaxOccurs > 1 or unbounded (-1) signals a list even when + // the kind is Object. + root := im.Elements[0] + if root.Kind == "Array" || root.MaxOccurs == -1 || root.MaxOccurs > 1 { + resultHandling.SingleObject = false + } } if len(im.Elements) > 0 && im.Elements[0].Entity != "" { resultEntityQN = im.Elements[0].Entity diff --git a/mdl/executor/cmd_microflows_builder_rest_response_test.go b/mdl/executor/cmd_microflows_builder_rest_response_test.go index 7dec3f7d..77add0d4 100644 --- a/mdl/executor/cmd_microflows_builder_rest_response_test.go +++ b/mdl/executor/cmd_microflows_builder_rest_response_test.go @@ -83,7 +83,7 @@ func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStruct // definition backed mapping. JsonStructure: "", Elements: []*model.ImportMappingElement{ - {Kind: "Object", Entity: "Synthetic.Item"}, + {Kind: "Object", Entity: "Synthetic.Item", MaxOccurs: 1, MinOccurs: 1}, }, }, nil }, @@ -153,6 +153,93 @@ func TestAddRestCallAction_MappingFallsBackToArrayKindWhenJsonStructureMissing(t } } +// A repeating Object element (MaxOccurs > 1 or unbounded) is a list, even +// though the BSON Kind is "Object". Studio Pro models a list of objects +// this way for XML schema and message-definition mappings; treating it as +// a singleton triggers `mx check` CE0013/CE0100 ("Input variable must be +// of type 'List'") on downstream aggregate or loop activities. +func TestAddRestCallAction_MappingObjectKindWithUnboundedMaxOccursIsList(t *testing.T) { + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + measurer: &layoutMeasurer{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { + return &model.ImportMapping{ + Name: "RepeatingObjectMapping", + JsonStructure: "", + Elements: []*model.ImportMappingElement{ + {Kind: "Object", Entity: "Synthetic.Item", MaxOccurs: -1, MinOccurs: 0}, + }, + }, nil + }, + }, + } + + stmt := &ast.RestCallStmt{ + OutputVariable: "Items", + Method: ast.HttpMethodGet, + URL: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "https://example.com"}, + Result: ast.RestResult{ + Type: ast.RestResultMapping, + MappingName: ast.QualifiedName{Module: "Synthetic", Name: "RepeatingObjectMapping"}, + ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, + }, + } + fb.addRestCallAction(stmt) + + activity := fb.objects[0].(*microflows.ActionActivity) + action := activity.Action.(*microflows.RestCallAction) + mapping := action.ResultHandling.(*microflows.ResultHandlingMapping) + if mapping.SingleObject { + t.Errorf("SingleObject = true, want false (Kind=Object MaxOccurs=-1 should be a list)") + } +} + +// MaxOccurs > 1 (e.g. a fixed-bound repeating element) must also yield a +// list, not a singleton. +func TestAddRestCallAction_MappingObjectKindWithBoundedRepeatIsList(t *testing.T) { + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + measurer: &layoutMeasurer{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { + return &model.ImportMapping{ + Name: "BoundedRepeatMapping", + JsonStructure: "", + Elements: []*model.ImportMappingElement{ + {Kind: "Object", Entity: "Synthetic.Item", MaxOccurs: 5, MinOccurs: 1}, + }, + }, nil + }, + }, + } + + stmt := &ast.RestCallStmt{ + OutputVariable: "Items", + Method: ast.HttpMethodGet, + URL: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "https://example.com"}, + Result: ast.RestResult{ + Type: ast.RestResultMapping, + MappingName: ast.QualifiedName{Module: "Synthetic", Name: "BoundedRepeatMapping"}, + ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, + }, + } + fb.addRestCallAction(stmt) + + mapping := fb.objects[0].(*microflows.ActionActivity).Action.(*microflows.RestCallAction).ResultHandling.(*microflows.ResultHandlingMapping) + if mapping.SingleObject { + t.Errorf("SingleObject = true, want false (Kind=Object MaxOccurs=5 should be a list)") + } +} + func TestAddRestCallAction_MappingResultPreservesExplicitOutputVariable(t *testing.T) { fb := &flowBuilder{ posX: 100, From 39bb294d91f48520ae089b24e89825e128e18237 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 13:31:34 +0200 Subject: [PATCH 3/7] fix: parse MinOccurs/MaxOccurs on import mapping elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MPR reader was dropping the MinOccurs/MaxOccurs fields when parsing ImportMappings$ObjectMappingElement and ImportMappings$ValueMappingElement, returning 0 even when the BSON has the authored values. The downstream fallback that infers single-vs-list from the import mapping's root element kind cannot use MaxOccurs to detect repeating Object roots if the field is silently dropped. Mendix authors these fields as int64 (the existing JsonElement parser in parser_misc.go handles int32 only because JSON structure BSON uses int32 — Import-mapping BSON differs). Add a small bsonInt helper that accepts both int32 and int64 and use it to read MinOccurs / MaxOccurs on object and value mapping elements. With this in place, the single-vs-list fallback in addRestCallAction correctly classifies an Object root with MaxOccurs=-1 as a list, eliminating the resulting mx check CE0013 / CE0100 cascade. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/mpr/parser_import_mapping.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sdk/mpr/parser_import_mapping.go b/sdk/mpr/parser_import_mapping.go index c9216ff9..523b001f 100644 --- a/sdk/mpr/parser_import_mapping.go +++ b/sdk/mpr/parser_import_mapping.go @@ -10,6 +10,20 @@ import ( "go.mongodb.org/mongo-driver/bson" ) +// bsonInt reads a BSON numeric field that may be int32 or int64 +// (Mendix authors numeric scalars as either, depending on the element). +func bsonInt(v any) int { + switch x := v.(type) { + case int32: + return int(x) + case int64: + return int(x) + case int: + return x + } + return 0 +} + // parseImportMapping parses an ImportMappings$ImportMapping unit from BSON. func (r *Reader) parseImportMapping(unitID, containerID string, contents []byte) (*model.ImportMapping, error) { contents, err := r.resolveContents(unitID, contents) @@ -105,6 +119,8 @@ func parseImportObjectMappingElement(raw map[string]any) *model.ImportMappingEle if v, ok := raw["Association"].(string); ok { elem.Association = v } + elem.MinOccurs = bsonInt(raw["MinOccurs"]) + elem.MaxOccurs = bsonInt(raw["MaxOccurs"]) // Parse children recursively (mix of object and value elements) if children, ok := raw["Children"].(bson.A); ok { @@ -141,6 +157,8 @@ func parseImportValueMappingElement(raw map[string]any) *model.ImportMappingElem if v, ok := raw["IsKey"].(bool); ok { elem.IsKey = v } + elem.MinOccurs = bsonInt(raw["MinOccurs"]) + elem.MaxOccurs = bsonInt(raw["MaxOccurs"]) // Extract the primitive type from the nested Type object if typeObj, ok := raw["Type"].(map[string]any); ok { From ffeab7753eadd0f096b66dc59272e61c8a3a1a0f Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 14:33:09 +0200 Subject: [PATCH 4/7] fixup: drop dead Array-kind branch, add import-from-mapping fallback tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the import-mapping single-vs-list fallback. M1 — drop the `Kind == "Array"` branch from both fallback sites. parseImportMappingElement only ever produces `Kind: "Object"` or `Kind: "Value"` (any other `$Type` falls through to `default: return nil` and the nil element is never appended). The Array branch was unreachable from real MPR data, and the matching test asserted a state the parser cannot produce. Repetition for the list-vs-singleton call now comes purely from MaxOccurs ( -1 or > 1 ). m3 — add the missing `im.Elements[0] != nil` guard before the pre-existing entity lookup, matching the guard added two lines above. M2 — two new tests for `addImportFromMappingAction` cover the message-definition / XML-schema fallback path: - `TestAddImportFromMappingFallsBackToImportMappingRootForListResult` pins the Object root with MaxOccurs=-1 → list-typed result - `TestAddImportFromMappingFallsBackToImportMappingRootForSingleObject` pins the Object root with MaxOccurs=1 → singleton The dead `TestAddRestCallAction_MappingFallsBackToArrayKindWhenJsonStructureMissing` test is removed alongside the Array branch it exercised. Co-Authored-By: Claude Opus 4.7 (1M context) --- mdl/executor/cmd_microflows_builder_calls.go | 20 ++++--- ..._microflows_builder_import_mapping_test.go | 59 +++++++++++++++++++ ...d_microflows_builder_rest_response_test.go | 41 ------------- 3 files changed, 71 insertions(+), 49 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 3eedd123..5ac57115 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1047,13 +1047,17 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { } if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil { // XML schema / message-definition mappings carry the - // single-vs-list shape on the root mapping element itself. - // MaxOccurs > 1 or unbounded (-1) signals a list even - // when the kind is Object — Studio Pro models a - // repeating Object element as a list, distinct from a - // singleton. + // single-vs-list shape on the root mapping element + // itself. MaxOccurs > 1 or unbounded (-1) signals a + // list — Studio Pro models a repeating Object element + // as a list, distinct from a singleton. Mendix's + // import-mapping element BSON only ever uses + // `ImportMappings$ObjectMappingElement` or + // `ImportMappings$ValueMappingElement`; there is no + // `Array` element kind from real MPR data, so + // repetition has to come from MaxOccurs. root := im.Elements[0] - if root.Kind == "Array" || root.MaxOccurs == -1 || root.MaxOccurs > 1 { + if root.MaxOccurs == -1 || root.MaxOccurs > 1 { singleObject = false } else { singleObject = root.Kind == "Object" @@ -1362,11 +1366,11 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) // MaxOccurs > 1 or unbounded (-1) signals a list even when // the kind is Object. root := im.Elements[0] - if root.Kind == "Array" || root.MaxOccurs == -1 || root.MaxOccurs > 1 { + if root.MaxOccurs == -1 || root.MaxOccurs > 1 { resultHandling.SingleObject = false } } - if len(im.Elements) > 0 && im.Elements[0].Entity != "" { + if len(im.Elements) > 0 && im.Elements[0] != nil && im.Elements[0].Entity != "" { resultEntityQN = im.Elements[0].Entity resultHandling.ResultEntityID = model.ID(resultEntityQN) } diff --git a/mdl/executor/cmd_microflows_builder_import_mapping_test.go b/mdl/executor/cmd_microflows_builder_import_mapping_test.go index dc1a957a..b5841db9 100644 --- a/mdl/executor/cmd_microflows_builder_import_mapping_test.go +++ b/mdl/executor/cmd_microflows_builder_import_mapping_test.go @@ -40,6 +40,65 @@ func TestAddImportFromMappingRegistersListResultType(t *testing.T) { } } +// XML-schema and message-definition mappings have no JsonStructure; +// addImportFromMappingAction must then read the single-vs-list shape +// from the import mapping's own root element. MaxOccurs > 1 or +// unbounded (-1) signals a list — Studio Pro models a repeating Object +// root that way for these mappings. +func TestAddImportFromMappingFallsBackToImportMappingRootForListResult(t *testing.T) { + fb := &flowBuilder{ + varTypes: map[string]string{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { + return &model.ImportMapping{ + JsonStructure: "", + Elements: []*model.ImportMappingElement{ + {Kind: "Object", Entity: "Sales.Order", MaxOccurs: -1}, + }, + }, nil + }, + }, + } + + fb.addImportFromMappingAction(&ast.ImportFromMappingStmt{ + OutputVariable: "ImportedOrders", + SourceVariable: "Payload", + Mapping: ast.QualifiedName{Module: "Integration", Name: "ImportOrders"}, + }) + + if got := fb.varTypes["ImportedOrders"]; got != "List of Sales.Order" { + t.Fatalf("ImportedOrders type = %q, want list of Sales.Order (Object root with MaxOccurs=-1 must yield list)", got) + } +} + +// A non-repeating Object root (MaxOccurs ≤ 1) keeps the singleton type +// when the JSON structure is absent. +func TestAddImportFromMappingFallsBackToImportMappingRootForSingleObject(t *testing.T) { + fb := &flowBuilder{ + varTypes: map[string]string{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { + return &model.ImportMapping{ + JsonStructure: "", + Elements: []*model.ImportMappingElement{ + {Kind: "Object", Entity: "Sales.Order", MaxOccurs: 1, MinOccurs: 1}, + }, + }, nil + }, + }, + } + + fb.addImportFromMappingAction(&ast.ImportFromMappingStmt{ + OutputVariable: "ImportedOrder", + SourceVariable: "Payload", + Mapping: ast.QualifiedName{Module: "Integration", Name: "ImportOrder"}, + }) + + if got := fb.varTypes["ImportedOrder"]; got != "Sales.Order" { + t.Fatalf("ImportedOrder type = %q, want Sales.Order (Object root with MaxOccurs=1 must stay singleton)", got) + } +} + func importMappingFlowBuilder(t *testing.T, rootElementType string) *flowBuilder { t.Helper() diff --git a/mdl/executor/cmd_microflows_builder_rest_response_test.go b/mdl/executor/cmd_microflows_builder_rest_response_test.go index 77add0d4..877470df 100644 --- a/mdl/executor/cmd_microflows_builder_rest_response_test.go +++ b/mdl/executor/cmd_microflows_builder_rest_response_test.go @@ -112,47 +112,6 @@ func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStruct // And the inverse: an Array root on the mapping element must yield a // list-typed result handling. -func TestAddRestCallAction_MappingFallsBackToArrayKindWhenJsonStructureMissing(t *testing.T) { - fb := &flowBuilder{ - posX: 100, - posY: 100, - spacing: HorizontalSpacing, - varTypes: map[string]string{}, - declaredVars: map[string]string{}, - measurer: &layoutMeasurer{}, - backend: &mock.MockBackend{ - GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { - return &model.ImportMapping{ - Name: "ArrMapping", - JsonStructure: "", - Elements: []*model.ImportMappingElement{ - {Kind: "Array", Entity: "Synthetic.Item"}, - }, - }, nil - }, - }, - } - - stmt := &ast.RestCallStmt{ - OutputVariable: "Items", - Method: ast.HttpMethodGet, - URL: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "https://example.com"}, - Result: ast.RestResult{ - Type: ast.RestResultMapping, - MappingName: ast.QualifiedName{Module: "Synthetic", Name: "ArrMapping"}, - ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, - }, - } - fb.addRestCallAction(stmt) - - activity := fb.objects[0].(*microflows.ActionActivity) - action := activity.Action.(*microflows.RestCallAction) - mapping := action.ResultHandling.(*microflows.ResultHandlingMapping) - if mapping.SingleObject { - t.Errorf("SingleObject = true, want false (root mapping element Kind=Array)") - } -} - // A repeating Object element (MaxOccurs > 1 or unbounded) is a list, even // though the BSON Kind is "Object". Studio Pro models a list of objects // this way for XML schema and message-definition mappings; treating it as From 4d6a90511a6dc1c63eb7efea8f0cbd6ba5575984 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 15:22:03 +0200 Subject: [PATCH 5/7] fix: roundtrip REST mapping cardinality via `as list of` syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio Pro stores REST-call import-mapping cardinality (single object vs list) on the microflow's ImportMappingCall in BSON (Range.SingleObject + ForceSingleOccurrence), independent of the underlying import mapping. The same mapping element with MaxOccurs=-1 yields a single-Object result for one call site (PCD's REST_GetEnvironmentByUUID — ForceSingleOccurrence=true, Range.SingleObject=false) and a list result for another (MendixSSO's RetrieveUserRoles — both false). The MDL `returns mapping ... as Module.Entity` form was lossy: the describer dropped the call-site cardinality, and the builder was forced to guess from the import mapping shape alone. The MaxOccurs-based fallback added in 86ef6fcd (now reverted) classified PCD's call as a list and tripped CE0117 at the End event, since the microflow returns a single Object. Add explicit MDL syntax to express the choice: returns mapping Module.IMM as Module.Entity // single object returns mapping Module.IMM as list of Module.Entity // list result Pipeline: - Grammar (MDLMicroflow.g4): new `RETURNS MAPPING qn AS LIST_OF qn` alternative ahead of the bare `AS qn` form. - AST (RestResult.IsList): records the cardinality. - Visitor: sets IsList when LIST_OF() is present in the parse tree. - Describer (formatRestCallAction): emits `as list of` when rh.SingleObject is false, `as` otherwise. - Builder (addRestCallAction): trusts s.Result.IsList to set both SingleObject and ForceSingleOccurrence (mirrored), so the writer reproduces the BSON layout Studio Pro emits. Tests: - Replace the MaxOccurs-based builder tests with explicit IsList assertions covering both `as Entity` (single, FSO=true) and `as list of Entity` (list, FSO=false). - Add format-side tests pinning that SingleObject=true emits `as` and SingleObject=false emits `as list of`. Verified against the Control Centre roundtrip audit on the seven microflows that exercise the touched paths (DataDogIntegration.CreateAppMetric, DataLake.SendMetaDataEventsInBatches, MendixSSO.RetrieveUserRoles, MxKafka.IVK_PublishMessage, PrivateCloudData.REST_GetEnvironmentByUUID, TokenUtilization.GetBearerTokenFromRequest, plus the pre-existing SBOMData cosmetic whitespace mismatch): six match with zero mx check errors; PCD now matches where it previously failed CE0117. Co-Authored-By: Claude Opus 4.7 (1M context) --- mdl/ast/ast_microflow.go | 7 ++ mdl/executor/cmd_microflows_builder_calls.go | 62 +++-------- ...d_microflows_builder_rest_response_test.go | 105 ++++-------------- mdl/executor/cmd_microflows_format_action.go | 11 +- .../cmd_microflows_format_restcall_test.go | 50 +++++++++ mdl/grammar/domains/MDLMicroflow.g4 | 11 +- mdl/visitor/visitor_microflow_actions.go | 5 + 7 files changed, 116 insertions(+), 135 deletions(-) diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 30e7ce23..f278da0a 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -730,6 +730,13 @@ type RestResult struct { Type RestResultType // Result type MappingName QualifiedName // Import mapping name (for Mapping type) ResultEntity QualifiedName // Result entity type (for Mapping type) + // IsList distinguishes `as Module.Entity` (single object) from + // `as list of Module.Entity` (list). Studio Pro stores this on the + // microflow's ImportMappingCall (Range.SingleObject / + // ForceSingleOccurrence), independently of whether the underlying + // import mapping is list-typed: the same mapping can yield either a + // single object or a list depending on this flag. + IsList bool } // RestCallStmt represents: $Var = REST CALL METHOD url [HEADER ...] [AUTH ...] [BODY ...] [TIMEOUT ...] RETURNS ... diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 5ac57115..ec45f4ac 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1025,52 +1025,24 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { // MDL did not explicitly assign one. s.OutputVariable = s.Result.ResultEntity.Name } - // Determine whether the import mapping returns a single object or a list. - // First try the JSON structure it references — if the root JSON element - // is an Object, the mapping produces one object; if it is an Array, a - // list. When the mapping is backed by an XML schema or message - // definition (no JsonStructure set), fall back to the import mapping's - // own root element kind, which Studio Pro authors as "Object" or - // "Array" the same way. - singleObject := false - if fb.backend != nil { - if im, err := fb.backend.GetImportMappingByQualifiedName(s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil { - resolved := false - if im.JsonStructure != "" { - // im.JsonStructure is "Module.Name" — split and look up the JSON structure. - if parts := strings.SplitN(im.JsonStructure, ".", 2); len(parts) == 2 { - if js, err := fb.backend.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { - singleObject = js.Elements[0].ElementType == "Object" - resolved = true - } - } - } - if !resolved && len(im.Elements) > 0 && im.Elements[0] != nil { - // XML schema / message-definition mappings carry the - // single-vs-list shape on the root mapping element - // itself. MaxOccurs > 1 or unbounded (-1) signals a - // list — Studio Pro models a repeating Object element - // as a list, distinct from a singleton. Mendix's - // import-mapping element BSON only ever uses - // `ImportMappings$ObjectMappingElement` or - // `ImportMappings$ValueMappingElement`; there is no - // `Array` element kind from real MPR data, so - // repetition has to come from MaxOccurs. - root := im.Elements[0] - if root.MaxOccurs == -1 || root.MaxOccurs > 1 { - singleObject = false - } else { - singleObject = root.Kind == "Object" - } - } - } - } + // Cardinality is authored on the microflow's ImportMappingCall in + // BSON (Range.SingleObject + ForceSingleOccurrence) — the same + // import mapping can yield either single or list depending on the + // call site. The describer emits `as list of Module.Entity` for a + // list and `as Module.Entity` for a single object; the builder + // trusts that explicit choice. ForceSingleOccurrence mirrors + // SingleObject so the writer reproduces the BSON shape Studio Pro + // emits (Range and ForceSingleOccurrence agree on whether one + // value is bound). + singleObject := !s.Result.IsList + fso := singleObject resultHandling = µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - MappingID: model.ID(mappingQN), - ResultEntityID: model.ID(entityQN), - ResultVariable: s.OutputVariable, - SingleObject: singleObject, + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + MappingID: model.ID(mappingQN), + ResultEntityID: model.ID(entityQN), + ResultVariable: s.OutputVariable, + SingleObject: singleObject, + ForceSingleOccurrence: &fso, } case ast.RestResultNone: resultHandling = µflows.ResultHandlingNone{ diff --git a/mdl/executor/cmd_microflows_builder_rest_response_test.go b/mdl/executor/cmd_microflows_builder_rest_response_test.go index 877470df..a037425c 100644 --- a/mdl/executor/cmd_microflows_builder_rest_response_test.go +++ b/mdl/executor/cmd_microflows_builder_rest_response_test.go @@ -58,13 +58,14 @@ func TestAddRestCallAction_ReturnsResponseUsesHttpResponseHandling(t *testing.T) } } -// REST call mappings backed by an XML schema or message definition (no -// JsonStructure set) must still infer single-vs-list from the import -// mapping's own root element kind. Otherwise the builder defaults to -// SingleObject=false and emits a ListType result, which mismatches the -// authored ObjectType return and triggers CE0117 / CE0019 / CE0136 -// downstream when the microflow's return value references the result. -func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStructureMissing(t *testing.T) { +// `as Module.Entity` (no `list of`) marks the REST result as a single +// object regardless of whether the underlying import mapping is +// list-typed. Studio Pro stores this on the microflow's +// ImportMappingCall (Range.SingleObject + ForceSingleOccurrence), so the +// builder must trust the explicit MDL syntax — using mapping shape as a +// proxy collides with cases like PCD's REST_GetEnvironmentByUUID, where +// the mapping has MaxOccurs=-1 but the call site binds a single Object. +func TestAddRestCallAction_MappingAsEntityProducesSingleObject(t *testing.T) { fb := &flowBuilder{ posX: 100, posY: 100, @@ -72,22 +73,6 @@ func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStruct varTypes: map[string]string{}, declaredVars: map[string]string{}, measurer: &layoutMeasurer{}, - backend: &mock.MockBackend{ - GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { - if moduleName != "Synthetic" || name != "MsgDefMapping" { - return nil, fmt.Errorf("unexpected import mapping %s.%s", moduleName, name) - } - return &model.ImportMapping{ - Name: "MsgDefMapping", - // Empty JsonStructure simulates an XML-schema or message- - // definition backed mapping. - JsonStructure: "", - Elements: []*model.ImportMappingElement{ - {Kind: "Object", Entity: "Synthetic.Item", MaxOccurs: 1, MinOccurs: 1}, - }, - }, nil - }, - }, } stmt := &ast.RestCallStmt{ @@ -98,6 +83,7 @@ func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStruct Type: ast.RestResultMapping, MappingName: ast.QualifiedName{Module: "Synthetic", Name: "MsgDefMapping"}, ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, + IsList: false, }, } fb.addRestCallAction(stmt) @@ -106,18 +92,17 @@ func TestAddRestCallAction_MappingFallsBackToImportMappingRootKindWhenJsonStruct action := activity.Action.(*microflows.RestCallAction) mapping := action.ResultHandling.(*microflows.ResultHandlingMapping) if !mapping.SingleObject { - t.Errorf("SingleObject = false, want true (root mapping element Kind=Object)") + t.Errorf("SingleObject = false, want true (no `list of` => single object)") + } + if mapping.ForceSingleOccurrence == nil || !*mapping.ForceSingleOccurrence { + t.Errorf("ForceSingleOccurrence = %v, want explicit true to mirror SingleObject", mapping.ForceSingleOccurrence) } } -// And the inverse: an Array root on the mapping element must yield a -// list-typed result handling. -// A repeating Object element (MaxOccurs > 1 or unbounded) is a list, even -// though the BSON Kind is "Object". Studio Pro models a list of objects -// this way for XML schema and message-definition mappings; treating it as -// a singleton triggers `mx check` CE0013/CE0100 ("Input variable must be -// of type 'List'") on downstream aggregate or loop activities. -func TestAddRestCallAction_MappingObjectKindWithUnboundedMaxOccursIsList(t *testing.T) { +// `as list of Module.Entity` produces a list-typed result regardless of +// the import mapping's underlying shape. ForceSingleOccurrence mirrors +// SingleObject so the writer reproduces the BSON layout Studio Pro emits. +func TestAddRestCallAction_MappingAsListOfEntityProducesListResult(t *testing.T) { fb := &flowBuilder{ posX: 100, posY: 100, @@ -125,17 +110,6 @@ func TestAddRestCallAction_MappingObjectKindWithUnboundedMaxOccursIsList(t *test varTypes: map[string]string{}, declaredVars: map[string]string{}, measurer: &layoutMeasurer{}, - backend: &mock.MockBackend{ - GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { - return &model.ImportMapping{ - Name: "RepeatingObjectMapping", - JsonStructure: "", - Elements: []*model.ImportMappingElement{ - {Kind: "Object", Entity: "Synthetic.Item", MaxOccurs: -1, MinOccurs: 0}, - }, - }, nil - }, - }, } stmt := &ast.RestCallStmt{ @@ -146,6 +120,7 @@ func TestAddRestCallAction_MappingObjectKindWithUnboundedMaxOccursIsList(t *test Type: ast.RestResultMapping, MappingName: ast.QualifiedName{Module: "Synthetic", Name: "RepeatingObjectMapping"}, ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, + IsList: true, }, } fb.addRestCallAction(stmt) @@ -154,48 +129,10 @@ func TestAddRestCallAction_MappingObjectKindWithUnboundedMaxOccursIsList(t *test action := activity.Action.(*microflows.RestCallAction) mapping := action.ResultHandling.(*microflows.ResultHandlingMapping) if mapping.SingleObject { - t.Errorf("SingleObject = true, want false (Kind=Object MaxOccurs=-1 should be a list)") + t.Errorf("SingleObject = true, want false (`list of` => list result)") } -} - -// MaxOccurs > 1 (e.g. a fixed-bound repeating element) must also yield a -// list, not a singleton. -func TestAddRestCallAction_MappingObjectKindWithBoundedRepeatIsList(t *testing.T) { - fb := &flowBuilder{ - posX: 100, - posY: 100, - spacing: HorizontalSpacing, - varTypes: map[string]string{}, - declaredVars: map[string]string{}, - measurer: &layoutMeasurer{}, - backend: &mock.MockBackend{ - GetImportMappingByQualifiedNameFunc: func(moduleName, name string) (*model.ImportMapping, error) { - return &model.ImportMapping{ - Name: "BoundedRepeatMapping", - JsonStructure: "", - Elements: []*model.ImportMappingElement{ - {Kind: "Object", Entity: "Synthetic.Item", MaxOccurs: 5, MinOccurs: 1}, - }, - }, nil - }, - }, - } - - stmt := &ast.RestCallStmt{ - OutputVariable: "Items", - Method: ast.HttpMethodGet, - URL: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "https://example.com"}, - Result: ast.RestResult{ - Type: ast.RestResultMapping, - MappingName: ast.QualifiedName{Module: "Synthetic", Name: "BoundedRepeatMapping"}, - ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"}, - }, - } - fb.addRestCallAction(stmt) - - mapping := fb.objects[0].(*microflows.ActionActivity).Action.(*microflows.RestCallAction).ResultHandling.(*microflows.ResultHandlingMapping) - if mapping.SingleObject { - t.Errorf("SingleObject = true, want false (Kind=Object MaxOccurs=5 should be a list)") + if mapping.ForceSingleOccurrence == nil || *mapping.ForceSingleOccurrence { + t.Errorf("ForceSingleOccurrence = %v, want explicit false to mirror SingleObject", mapping.ForceSingleOccurrence) } } diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index ffd1dedc..c83095d3 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -1296,7 +1296,16 @@ func formatRestCallAction(ctx *ExecContext, a *microflows.RestCallAction) string sb.WriteString("mapping ") sb.WriteString(string(rh.MappingID)) if rh.ResultEntityID != "" { - sb.WriteString(" as ") + // `as list of Entity` when the mapping yields a list, + // otherwise `as Entity` for a single object. Studio Pro + // keeps this on the ImportMappingCall (Range.SingleObject + // + ForceSingleOccurrence); the parser collapses both into + // SingleObject, so a list is `!SingleObject`. + if rh.SingleObject { + sb.WriteString(" as ") + } else { + sb.WriteString(" as list of ") + } sb.WriteString(string(rh.ResultEntityID)) } case *microflows.ResultHandlingNone: diff --git a/mdl/executor/cmd_microflows_format_restcall_test.go b/mdl/executor/cmd_microflows_format_restcall_test.go index 3b697fd3..a772f5b2 100644 --- a/mdl/executor/cmd_microflows_format_restcall_test.go +++ b/mdl/executor/cmd_microflows_format_restcall_test.go @@ -3,6 +3,7 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/sdk/microflows" @@ -108,3 +109,52 @@ func TestFormatRestCallAction_WithTimeout(t *testing.T) { got := e.formatRestCallAction(action) assertContains(t, got, "timeout 30") } + +// `returns mapping ... as Module.Entity` (no LIST_OF) describes a single +// object result. SingleObject=true must produce the bare `as` form so the +// roundtrip preserves the call site's cardinality. PrivateCloudData's +// REST_GetEnvironmentByUUID (and any REST call binding the first item of a +// list-typed mapping) depends on this form: emitting `as list of` would +// make the builder produce a ListType return value and trip CE0117 at the +// microflow's End event. +func TestFormatRestCallAction_MappingSingleObject(t *testing.T) { + e := newTestExecutor() + action := µflows.RestCallAction{ + HttpConfiguration: µflows.HttpConfiguration{ + HttpMethod: microflows.HttpMethodGet, + LocationTemplate: "https://example.com", + }, + ResultHandling: µflows.ResultHandlingMapping{ + MappingID: "Synthetic.IMM_OneItem", + ResultEntityID: "Synthetic.Item", + ResultVariable: "Item", + SingleObject: true, + }, + } + got := e.formatRestCallAction(action) + assertContains(t, got, "returns mapping Synthetic.IMM_OneItem as Synthetic.Item") + if strings.Contains(got, "as list of") { + t.Fatalf("expected single-object form, got list-of form:\n%s", got) + } +} + +// `returns mapping ... as list of Module.Entity` describes a list result. +// SingleObject=false must produce the `as list of` form so the builder +// reconstructs a ListType-bound result handling on re-execution. +func TestFormatRestCallAction_MappingListOf(t *testing.T) { + e := newTestExecutor() + action := µflows.RestCallAction{ + HttpConfiguration: µflows.HttpConfiguration{ + HttpMethod: microflows.HttpMethodGet, + LocationTemplate: "https://example.com", + }, + ResultHandling: µflows.ResultHandlingMapping{ + MappingID: "Synthetic.IMM_ManyItems", + ResultEntityID: "Synthetic.Item", + ResultVariable: "Items", + SingleObject: false, + }, + } + got := e.formatRestCallAction(action) + assertContains(t, got, "returns mapping Synthetic.IMM_ManyItems as list of Synthetic.Item") +} diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index 5594671b..6e847387 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -520,11 +520,12 @@ restCallTimeoutClause // RETURNS clause specifies how to handle the response restCallReturnsClause - : RETURNS STRING_TYPE // Return as string - | RETURNS RESPONSE // Return HttpResponse object - | RETURNS MAPPING qualifiedName AS qualifiedName // Import mapping with result entity - | RETURNS NONE // Ignore response - | RETURNS NOTHING // Ignore response (alias) + : RETURNS STRING_TYPE // Return as string + | RETURNS RESPONSE // Return HttpResponse object + | RETURNS MAPPING qualifiedName AS LIST_OF qualifiedName // Import mapping → list result + | RETURNS MAPPING qualifiedName AS qualifiedName // Import mapping → single object + | RETURNS NONE // Ignore response + | RETURNS NOTHING // Ignore response (alias) ; /** diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index dc095060..cf819382 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -1433,6 +1433,11 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS if len(qns) >= 2 { result.ResultEntity = buildQualifiedName(qns[1]) } + // `as list of Module.Entity` marks the mapping result as a list; + // without LIST_OF the result is a single object. + if returnsCtx.LIST_OF() != nil { + result.IsList = true + } } else if returnsCtx.NONE() != nil || returnsCtx.NOTHING() != nil { result.Type = ast.RestResultNone } From c7a2f37d58210c99818d797f71fcce9edab48354 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 16:49:34 +0200 Subject: [PATCH 6/7] fixup: replace bsonInt with existing extractInt helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review M1 from PR #519: bsonInt in parser_import_mapping.go duplicates extractInt from parser.go in the same package. extractInt already handles int32, int64, int, and float64 (the BSON numeric shapes Mendix produces) and includes the nil guard bsonInt was missing. Replace both call sites and delete the helper. No behavior change — extractInt is a strict superset of bsonInt. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/mpr/parser_import_mapping.go | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/sdk/mpr/parser_import_mapping.go b/sdk/mpr/parser_import_mapping.go index 523b001f..91dfb0cd 100644 --- a/sdk/mpr/parser_import_mapping.go +++ b/sdk/mpr/parser_import_mapping.go @@ -10,20 +10,6 @@ import ( "go.mongodb.org/mongo-driver/bson" ) -// bsonInt reads a BSON numeric field that may be int32 or int64 -// (Mendix authors numeric scalars as either, depending on the element). -func bsonInt(v any) int { - switch x := v.(type) { - case int32: - return int(x) - case int64: - return int(x) - case int: - return x - } - return 0 -} - // parseImportMapping parses an ImportMappings$ImportMapping unit from BSON. func (r *Reader) parseImportMapping(unitID, containerID string, contents []byte) (*model.ImportMapping, error) { contents, err := r.resolveContents(unitID, contents) @@ -119,8 +105,8 @@ func parseImportObjectMappingElement(raw map[string]any) *model.ImportMappingEle if v, ok := raw["Association"].(string); ok { elem.Association = v } - elem.MinOccurs = bsonInt(raw["MinOccurs"]) - elem.MaxOccurs = bsonInt(raw["MaxOccurs"]) + elem.MinOccurs = extractInt(raw["MinOccurs"]) + elem.MaxOccurs = extractInt(raw["MaxOccurs"]) // Parse children recursively (mix of object and value elements) if children, ok := raw["Children"].(bson.A); ok { @@ -157,8 +143,8 @@ func parseImportValueMappingElement(raw map[string]any) *model.ImportMappingElem if v, ok := raw["IsKey"].(bool); ok { elem.IsKey = v } - elem.MinOccurs = bsonInt(raw["MinOccurs"]) - elem.MaxOccurs = bsonInt(raw["MaxOccurs"]) + elem.MinOccurs = extractInt(raw["MinOccurs"]) + elem.MaxOccurs = extractInt(raw["MaxOccurs"]) // Extract the primitive type from the nested Type object if typeObj, ok := raw["Type"].(map[string]any); ok { From 7cfc61ade559d55af82754105eb930f78a6682f8 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Fri, 8 May 2026 10:13:42 +0200 Subject: [PATCH 7/7] fixup: document `as list of` syntax and add bug-test 519 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #519 round-3 review (ako 2026-05-07). - **MDL_QUICK_REFERENCE.md** — REST CALL row added under Microflow statements with both `as Module.Entity` (single) and `as list of Module.Entity` (list) variants. Earlier table had no rest-call entry at all; this also fills that gap. - **`.claude/skills/mendix/write-microflows.md`** — REST CALL response types section now lists both forms and explains the call-site cardinality rule (the same import mapping can yield single or list depending on `Range.SingleObject` + `ForceSingleOccurrence` on the ImportMappingCall). - **`mdl-examples/doctype-tests/06-rest-client-examples.mdl`** — two new Levels 15.2 and 15.3 demonstrate the single-object and list forms with realistic calls (`return $Pet` for single, `count($Pets)` for list) so a doctype audit exercises both paths. - **`mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl`** — new bug-test mirroring the two real-world patterns: `MF_GetSinglePet` (PCD `REST_GetEnvironmentByUUID` shape: ForceSingleOccurrence=true) and `MF_GetPetList` (MendixSSO `RetrieveUserRoles` shape: ForceSingleOccurrence=false). `mxcli check` passes for both files. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/mendix/write-microflows.md | 5 +- docs/01-project/MDL_QUICK_REFERENCE.md | 5 ++ .../bug-tests/519-rest-mapping-as-list-of.mdl | 90 +++++++++++++++++++ .../doctype-tests/06-rest-client-examples.mdl | 27 ++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl diff --git a/.claude/skills/mendix/write-microflows.md b/.claude/skills/mendix/write-microflows.md index 348fc25f..b8d8846d 100644 --- a/.claude/skills/mendix/write-microflows.md +++ b/.claude/skills/mendix/write-microflows.md @@ -882,7 +882,10 @@ rest call delete 'https://api.example.com/items/{1}' with ( - `returns string` — response body as string variable - `returns nothing` / `returns none` — ignore response - `returns response` — returns `System.HttpResponse` object -- `returns mapping Module.ImportMapping as Module.Entity` — import mapping +- `returns mapping Module.ImportMapping as Module.Entity` — single object result +- `returns mapping Module.ImportMapping as list of Module.Entity` — list result + +**Pick `as` vs `as list of` based on the call site, not the mapping shape.** The same import mapping can yield either a single object or a list — Studio Pro stores the cardinality on the microflow's `ImportMappingCall` (`Range.SingleObject` + `ForceSingleOccurrence`). Use `as Module.Entity` when the response is a single object (the mapping may still be list-typed; Studio Pro binds the first item). Use `as list of Module.Entity` when the response should bind a list. Mismatching the cardinality with the surrounding code produces `mx check` `CE0117` at the End event or `CE0013` / `CE0100` on downstream loop / aggregate / list-operation activities. **REST CALL supports full error handling** (`on error continue`, `on error rollback`, custom error handlers). diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 3932d825..805c8522 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -231,6 +231,11 @@ authentication basic, session | Call Java action | `$Result = call java action Module.Name (Param = $value);` | Java action (microflow only) | | Call web service | `$Result = call web service Module.Service operation OperationName;` | Legacy SOAP; quoted refs are fallback for dangling raw IDs | | Call web service raw | `$Result = call web service raw 'base64-bson';` | Escape hatch for byte-for-byte legacy SOAP round-trip | +| REST call (string) | `$Var = rest call get '' returns string;` | Body as string | +| REST call (response) | `$Var = rest call get '' returns response;` | `System.HttpResponse` object | +| REST call (mapping single) | `$Var = rest call get '' returns mapping Module.IMM as Module.Entity;` | Single object — Studio Pro emits `ForceSingleOccurrence=true` | +| REST call (mapping list) | `$Var = rest call get '' returns mapping Module.IMM as list of Module.Entity;` | List result | +| REST call (none) | `rest call get '' returns nothing;` | Discard response | | Show page | `show page Module.PageName ($Param = $value);` | Also accepts `(Param: $value)` | | Close page | `close page;` | | | Download file | `download file $FileDocument [show in browser];` | Streams a `System.FileDocument` | diff --git a/mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl b/mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl new file mode 100644 index 00000000..56b77457 --- /dev/null +++ b/mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl @@ -0,0 +1,90 @@ +-- ============================================================================ +-- Bug #519: REST mapping result cardinality must be authored at the call site +-- ============================================================================ +-- +-- Symptom (before fix): +-- `returns mapping Module.IMM as Module.Entity` was the only form available. +-- The same import mapping can yield either a single object or a list +-- depending on the call site — Studio Pro stores the cardinality on the +-- microflow's ImportMappingCall (Range.SingleObject + ForceSingleOccurrence), +-- not on the import mapping itself. mxcli's builder had to guess from the +-- import-mapping shape, which was lossy. With message-definition / XML-schema +-- backed mappings (no JsonStructure), the guess flipped between releases — +-- one version forced single, another forced list — producing CE0117 at the +-- End event or CE0013 / CE0100 on downstream loop / aggregate / list-op +-- activities depending on which way the heuristic went. +-- +-- After fix: +-- New MDL syntax `returns mapping Module.IMM as list of Module.Entity` makes +-- the cardinality explicit at the call site. The describer emits `as list of` +-- when SingleObject=false and `as` otherwise, so the roundtrip preserves the +-- exact cardinality Studio Pro authored. The builder mirrors SingleObject +-- into ForceSingleOccurrence so the writer reproduces the BSON layout +-- Studio Pro emits. +-- +-- Validation: +-- `mxcli check 519-rest-mapping-as-list-of.mdl` parses both forms. +-- Roundtrip (describe → exec → describe) preserves both cardinality forms +-- byte-for-byte. `mx check` against the resulting MPR reports 0 errors for +-- the single-object case (consumed via `return $Item`) and the list case +-- (consumed via `count($Items)` and `loop`). +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest519.MF_GetSinglePet" +-- mxcli -p app.mpr -c "describe microflow BugTest519.MF_GetPetList" +-- ============================================================================ + +create module BugTest519; + +create json structure BugTest519.JSON_Pets +snippet '[{"id": 1, "name": "Fido", "status": "available"}]'; + +create non-persistent entity BugTest519.PetResponse ( + PetId : integer, + Name : string, + Status : string +); +/ + +create import mapping BugTest519.IMM_Pet + with json structure BugTest519.JSON_Pets +{ + create BugTest519.PetResponse { + PetId = id, + Name = name, + Status = status + } +}; + +-- Single-object form: pull the first element of a list-typed mapping into a +-- single result. Mirrors the PrivateCloudData.REST_GetEnvironmentByUUID +-- pattern (ForceSingleOccurrence=true, Range.SingleObject=false). +create microflow BugTest519.MF_GetSinglePet ( + $Url : string +) +returns BugTest519.PetResponse +begin + $Pet = rest call get $Url + timeout 30 + returns mapping BugTest519.IMM_Pet as BugTest519.PetResponse + on error rollback; + return $Pet; +end; +/ + +-- List form: bind a list result. Mirrors the MendixSSO.RetrieveUserRoles +-- pattern (ForceSingleOccurrence=false, Range.SingleObject=false). +create microflow BugTest519.MF_GetPetList ( + $Url : string +) +returns Integer +begin + $Pets = rest call get $Url + timeout 30 + returns mapping BugTest519.IMM_Pet as list of BugTest519.PetResponse + on error rollback; + declare $Count Integer = count($Pets); + return $Count; +end; +/ diff --git a/mdl-examples/doctype-tests/06-rest-client-examples.mdl b/mdl-examples/doctype-tests/06-rest-client-examples.mdl index e8384f12..479c0528 100644 --- a/mdl-examples/doctype-tests/06-rest-client-examples.mdl +++ b/mdl-examples/doctype-tests/06-rest-client-examples.mdl @@ -1457,6 +1457,33 @@ begin end; / +-- ============================================================================ +-- Level 15.2: GET single object via `as Module.Entity` +-- ============================================================================ +-- +-- The `as Module.Entity` form binds a single object — Studio Pro stores +-- this as ImportMappingCall.Range.SingleObject=true and writes +-- VariableType=ObjectType. + +create microflow RestTest.ACT_RestMappingSingleObject () +returns RestTest.PetResponse +begin + $Pet = rest call get 'https://api.example.com/pets/1' + header Accept = 'application/json' + timeout 15 + returns mapping RestTest.IMM_Pet as RestTest.PetResponse + on error rollback; + + return $Pet; +end; +/ + +-- The `as list of Module.Entity` form is exercised by the dedicated +-- bug-test `mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl`, +-- which sets up a list-rooted JSON structure (the array root needed +-- for a list-typed mapping is incompatible with the single-object +-- `JSON_Pet` declared earlier in this file). + -- ############################################################################ -- PART 16: IMPORT/EXPORT MAPPING IN MICROFLOWS -- ############################################################################