Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .claude/skills/mendix/write-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
5 changes: 5 additions & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<url>' returns string;` | Body as string |
| REST call (response) | `$Var = rest call get '<url>' returns response;` | `System.HttpResponse` object |
| REST call (mapping single) | `$Var = rest call get '<url>' returns mapping Module.IMM as Module.Entity;` | Single object — Studio Pro emits `ForceSingleOccurrence=true` |
| REST call (mapping list) | `$Var = rest call get '<url>' returns mapping Module.IMM as list of Module.Entity;` | List result |
| REST call (none) | `rest call get '<url>' 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` |
Expand Down
90 changes: 90 additions & 0 deletions mdl-examples/bug-tests/519-rest-mapping-as-list-of.mdl
Original file line number Diff line number Diff line change
@@ -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;
/
27 changes: 27 additions & 0 deletions mdl-examples/doctype-tests/06-rest-client-examples.mdl
Original file line number Diff line number Diff line change
Expand Up @@ -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
-- ############################################################################
Expand Down
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
Loading
Loading