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 c5a7f666..ec45f4ac 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1025,26 +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 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. - 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" - } - } - } - } + // 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{ @@ -1318,9 +1316,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,10 +1330,19 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) if js.Elements[0].ElementType == "Array" { resultHandling.SingleObject = false } + resolved = true } } } - if len(im.Elements) > 0 && im.Elements[0].Entity != "" { + 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.MaxOccurs == -1 || root.MaxOccurs > 1 { + resultHandling.SingleObject = false + } + } + 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 e1d111ff..a037425c 100644 --- a/mdl/executor/cmd_microflows_builder_rest_response_test.go +++ b/mdl/executor/cmd_microflows_builder_rest_response_test.go @@ -58,6 +58,84 @@ func TestAddRestCallAction_ReturnsResponseUsesHttpResponseHandling(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, + spacing: HorizontalSpacing, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + measurer: &layoutMeasurer{}, + } + + 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"}, + IsList: false, + }, + } + 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 (no `list of` => single object)") + } + if mapping.ForceSingleOccurrence == nil || !*mapping.ForceSingleOccurrence { + t.Errorf("ForceSingleOccurrence = %v, want explicit true to mirror SingleObject", mapping.ForceSingleOccurrence) + } +} + +// `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, + spacing: HorizontalSpacing, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + measurer: &layoutMeasurer{}, + } + + 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"}, + IsList: true, + }, + } + 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 (`list of` => list result)") + } + if mapping.ForceSingleOccurrence == nil || *mapping.ForceSingleOccurrence { + t.Errorf("ForceSingleOccurrence = %v, want explicit false to mirror SingleObject", mapping.ForceSingleOccurrence) + } +} + func TestAddRestCallAction_MappingResultPreservesExplicitOutputVariable(t *testing.T) { fb := &flowBuilder{ posX: 100, 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 } diff --git a/sdk/mpr/parser_import_mapping.go b/sdk/mpr/parser_import_mapping.go index c9216ff9..91dfb0cd 100644 --- a/sdk/mpr/parser_import_mapping.go +++ b/sdk/mpr/parser_import_mapping.go @@ -105,6 +105,8 @@ func parseImportObjectMappingElement(raw map[string]any) *model.ImportMappingEle if v, ok := raw["Association"].(string); ok { elem.Association = v } + 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 { @@ -141,6 +143,8 @@ func parseImportValueMappingElement(raw map[string]any) *model.ImportMappingElem if v, ok := raw["IsKey"].(bool); ok { elem.IsKey = v } + 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 {