From 15e0d0ff2b02cc98c2dd05ddca12fcc18b3a475a Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 09:49:53 +0200 Subject: [PATCH 1/6] 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 eae2912def8b8d9e65b3cb69eca4509c222720af Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 13:10:33 +0200 Subject: [PATCH 2/6] 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 6cfbf3ed2164c9e36e7c92b258d235f8f33dd65e Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 13:31:34 +0200 Subject: [PATCH 3/6] 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 b51fe81ea853de9df070ca2f4f1753a6b6e52baa Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 14:33:09 +0200 Subject: [PATCH 4/6] 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 8182391cc8f8793331f1a0dbfbb4eae454b954d9 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 15:22:03 +0200 Subject: [PATCH 5/6] 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 6557d08b6f846fd1b848332c855ebf8d4f2c9efe Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 5 May 2026 16:49:34 +0200 Subject: [PATCH 6/6] 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 {