Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions mdl/ast/ast_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...
Expand Down
51 changes: 31 additions & 20 deletions mdl/executor/cmd_microflows_builder_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = &microflows.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 = &microflows.ResultHandlingNone{
Expand Down Expand Up @@ -1318,20 +1316,33 @@ 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 {
if js, err := fb.backend.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 {
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)
}
Expand Down
59 changes: 59 additions & 0 deletions mdl/executor/cmd_microflows_builder_import_mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
78 changes: 78 additions & 0 deletions mdl/executor/cmd_microflows_builder_rest_response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion mdl/executor/cmd_microflows_format_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions mdl/executor/cmd_microflows_format_restcall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package executor

import (
"strings"
"testing"

"github.com/mendixlabs/mxcli/sdk/microflows"
Expand Down Expand Up @@ -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 := &microflows.RestCallAction{
HttpConfiguration: &microflows.HttpConfiguration{
HttpMethod: microflows.HttpMethodGet,
LocationTemplate: "https://example.com",
},
ResultHandling: &microflows.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 := &microflows.RestCallAction{
HttpConfiguration: &microflows.HttpConfiguration{
HttpMethod: microflows.HttpMethodGet,
LocationTemplate: "https://example.com",
},
ResultHandling: &microflows.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")
}
11 changes: 6 additions & 5 deletions mdl/grammar/domains/MDLMicroflow.g4
Original file line number Diff line number Diff line change
Expand Up @@ -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)
;

/**
Expand Down
5 changes: 5 additions & 0 deletions mdl/visitor/visitor_microflow_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions sdk/mpr/parser_import_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading