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
72 changes: 72 additions & 0 deletions mdl-examples/bug-tests/369-rest-mapping-result-cardinality.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- ============================================================================
-- Bug #369: REST/import mapping result range cardinality conflated
-- ============================================================================
--
-- Symptom (before fix):
-- Studio Pro stores two related but distinct bits of import-mapping
-- metadata in `Forms$ResultHandlingMapping`:
-- - `ForceSingleOccurrence` controls force-single-occurrence behavior.
-- - `Range.SingleObject` (with object/list variable type) describes the
-- result cardinality.
-- The mxcli SDK shape collapsed these to a single `SingleObject` field.
-- Roundtripping a REST client call action whose Studio Pro source had
-- `ForceSingleOccurrence = false` and `Range.SingleObject = true`
-- forced both fields to the same value. The resulting MPR had subtly
-- different runtime behaviour than the original.
--
-- After fix:
-- - `microflows.ResultHandlingMapping` stores `ForceSingleOccurrence`
-- separately from `SingleObject`.
-- - Parser reads `Range.SingleObject` and the object/list ObjectType as
-- result-cardinality signals; ForceSingleOccurrence stays its own bit.
-- - Writer emits ForceSingleOccurrence independently of Range for both
-- REST and XML import mappings.
--
-- Reproducibility note:
-- This PR is a parser/writer roundtrip preservation fix at the BSON
-- level. There is no new MDL syntax. The negative case (a real Studio
-- Pro REST client call with the divergent flags) is covered by the
-- SDK-level Go tests in `sdk/mpr/parser_microflow_test.go`.
--
-- This script provides a positive control: an `import from mapping`
-- call whose result handling exercises the related codepath and must
-- keep producing a valid MPR.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/369-rest-mapping-result-cardinality.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe microflow BugTest369.MF_ImportSinglePet"
-- `mx check` against the resulting MPR must report 0 errors.
-- ============================================================================

create module BugTest369;

create json structure BugTest369.JSON_Pet
snippet '{"id": 1, "name": "Fido", "status": "available"}';

create non-persistent entity BugTest369.PetResponse (
PetId : integer,
Name : string,
Status : string
);
/

create import mapping BugTest369.IMM_Pet
with json structure BugTest369.JSON_Pet
{
create BugTest369.PetResponse {
PetId = id,
Name = name,
Status = status
}
};

-- import from mapping result handling — exercises the same
-- ResultHandlingMapping BSON shape that #372 splits apart.
create microflow BugTest369.MF_ImportSinglePet (
$Json: string
)
returns BugTest369.PetResponse as $Pet
begin
$Pet = import from mapping BugTest369.IMM_Pet ($Json);
end;
/
8 changes: 5 additions & 3 deletions mdl/executor/cmd_microflows_builder_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -990,9 +990,11 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID {
case ast.RestResultMapping:
mappingQN := s.Result.MappingName.Module + "." + s.Result.MappingName.Name
entityQN := s.Result.ResultEntity.Module + "." + s.Result.ResultEntity.Name
// Derive the output variable name from the root entity's short name so
// callers don't need to hard-code it in the MDL assignment.
s.OutputVariable = s.Result.ResultEntity.Name
if s.OutputVariable == "" {
// Derive a fallback output variable from the root entity only when the
// 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.
Expand Down
62 changes: 62 additions & 0 deletions mdl/executor/cmd_microflows_builder_rest_response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
package executor

import (
"fmt"
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/mdl/backend/mock"
mdltypes "github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/microflows"
)

Expand Down Expand Up @@ -53,3 +57,61 @@ func TestAddRestCallAction_ReturnsResponseUsesHttpResponseHandling(t *testing.T)
t.Errorf("VariableName = %q, want %q", httpResponse.VariableName, "Response")
}
}

func TestAddRestCallAction_MappingResultPreservesExplicitOutputVariable(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 != "ImportItems" {
return nil, fmt.Errorf("unexpected import mapping %s.%s", moduleName, name)
}
return &model.ImportMapping{JsonStructure: "Synthetic.ItemsPayload"}, nil
},
GetJsonStructureByQualifiedNameFunc: func(moduleName, name string) (*mdltypes.JsonStructure, error) {
if moduleName != "Synthetic" || name != "ItemsPayload" {
return nil, fmt.Errorf("unexpected json structure %s.%s", moduleName, name)
}
return &mdltypes.JsonStructure{
Elements: []*mdltypes.JsonElement{{ElementType: "Array"}},
}, 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: "ImportItems"},
ResultEntity: ast.QualifiedName{Module: "Synthetic", Name: "Item"},
},
}
fb.addRestCallAction(stmt)

activity, ok := fb.objects[0].(*microflows.ActionActivity)
if !ok {
t.Fatalf("first object is %T, want *microflows.ActionActivity", fb.objects[0])
}
action, ok := activity.Action.(*microflows.RestCallAction)
if !ok {
t.Fatalf("activity.Action is %T, want *microflows.RestCallAction", activity.Action)
}
mapping, ok := action.ResultHandling.(*microflows.ResultHandlingMapping)
if !ok {
t.Fatalf("ResultHandling is %T, want *microflows.ResultHandlingMapping", action.ResultHandling)
}
if action.OutputVariable != "Items" {
t.Fatalf("OutputVariable = %q, want Items", action.OutputVariable)
}
if mapping.ResultVariable != "Items" {
t.Fatalf("ResultVariable = %q, want Items", mapping.ResultVariable)
}
}
9 changes: 5 additions & 4 deletions sdk/microflows/microflows_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -824,10 +824,11 @@ func (ResultHandlingHttpResponse) isResultHandling() {}
// ResultHandlingMapping uses an import mapping.
type ResultHandlingMapping struct {
model.BaseElement
MappingID model.ID `json:"mappingId"`
ResultEntityID model.ID `json:"resultEntityId,omitempty"`
ResultVariable string `json:"resultVariable,omitempty"`
SingleObject bool `json:"singleObject,omitempty"` // true when mapping returns a single object (not a list)
MappingID model.ID `json:"mappingId"`
ResultEntityID model.ID `json:"resultEntityId,omitempty"`
ResultVariable string `json:"resultVariable,omitempty"`
SingleObject bool `json:"singleObject,omitempty"` // true when mapping returns a single object (not a list)
ForceSingleOccurrence *bool `json:"forceSingleOccurrence,omitempty"`
}

func (ResultHandlingMapping) isResultHandling() {}
Expand Down
21 changes: 20 additions & 1 deletion sdk/mpr/parser_microflow_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,9 +620,17 @@ func parseResultHandling(raw map[string]any, handlingType string) microflows.Res
mappingRef = extractString(call["ReturnValueMapping"])
}
result.MappingID = model.ID(mappingRef)
forceSingleOccurrence := extractBool(call["ForceSingleOccurrence"], false)
result.ForceSingleOccurrence = &forceSingleOccurrence
if rangeMap := toMap(call["Range"]); rangeMap != nil {
result.SingleObject = extractBool(rangeMap["SingleObject"], false)
}
}
if varType := toMap(raw["VariableType"]); varType != nil {
result.ResultEntityID = model.ID(extractString(varType["Entity"]))
if extractString(varType["$Type"]) == "DataTypes$ObjectType" {
result.SingleObject = true
}
}
return result
case "None":
Expand Down Expand Up @@ -724,7 +732,18 @@ func parseImportXmlAction(raw map[string]any) *microflows.ImportXmlAction {
if varType := toMap(call["VariableType"]); varType != nil {
handling.ResultEntityID = model.ID(extractString(varType["Entity"]))
}
handling.SingleObject = extractBool(call["ForceSingleOccurrence"], false)
forceSingleOccurrence := extractBool(call["ForceSingleOccurrence"], false)
handling.ForceSingleOccurrence = &forceSingleOccurrence
if rangeMap := toMap(call["Range"]); rangeMap != nil {
handling.SingleObject = extractBool(rangeMap["SingleObject"], false)
}
// Older XML import mappings may omit Range and encode single-object
// handling only through ForceSingleOccurrence. REST result handling
// stores Range consistently, so this compatibility fallback stays
// XML-specific.
if !handling.SingleObject {
handling.SingleObject = forceSingleOccurrence
}
}
action.ResultHandling = handling
}
Expand Down
141 changes: 141 additions & 0 deletions sdk/mpr/parser_microflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"bytes"
"testing"

"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/microflows"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
Expand Down Expand Up @@ -84,6 +85,146 @@ func TestParseCommitAction_ErrorHandlingTypeDefaultsToRollback(t *testing.T) {
}
}

func TestParseResultHandlingMappingUsesRangeForSingleObject(t *testing.T) {
got := parseResultHandling(map[string]any{
"$ID": "result-handling-1",
"ResultVariableName": "RemoteApp",
"ImportMappingCall": map[string]any{
"ReturnValueMapping": "SampleRuntimeApi.IMM_RemoteApp",
"ForceSingleOccurrence": false,
"Range": map[string]any{
"SingleObject": true,
},
},
"VariableType": map[string]any{
"$Type": "DataTypes$ObjectType",
"Entity": "SampleRuntimeApi.RemoteApp",
},
}, "Mapping")

rh, ok := got.(*microflows.ResultHandlingMapping)
if !ok {
t.Fatalf("got %T, want *microflows.ResultHandlingMapping", got)
}
if !rh.SingleObject {
t.Fatal("Range.SingleObject=true must make the result object-valued")
}
if rh.ForceSingleOccurrence == nil || *rh.ForceSingleOccurrence {
t.Fatalf("ForceSingleOccurrence = %v, want explicit false", rh.ForceSingleOccurrence)
}
}

func TestSerializeRestResultHandlingPreservesForceSingleOccurrenceSeparately(t *testing.T) {
forceSingleOccurrence := false
doc := serializeRestResultHandling(&microflows.ResultHandlingMapping{
BaseElement: model.BaseElement{ID: model.ID("result-handling-1")},
MappingID: model.ID("SampleRuntimeApi.IMM_RemoteApp"),
ResultEntityID: model.ID("SampleRuntimeApi.RemoteApp"),
ResultVariable: "RemoteApp",
SingleObject: true,
ForceSingleOccurrence: &forceSingleOccurrence,
}, "RemoteApp")

importCall, ok := bsonDMap(doc)["ImportMappingCall"].(primitive.D)
if !ok {
t.Fatalf("ImportMappingCall missing or wrong type: %T", bsonDMap(doc)["ImportMappingCall"])
}
callFields := bsonDMap(importCall)
if got := callFields["ForceSingleOccurrence"]; got != false {
t.Fatalf("ForceSingleOccurrence = %v, want false", got)
}
rangeDoc, ok := callFields["Range"].(primitive.D)
if !ok {
t.Fatalf("Range missing or wrong type: %T", callFields["Range"])
}
if got := bsonDMap(rangeDoc)["SingleObject"]; got != true {
t.Fatalf("Range.SingleObject = %v, want true", got)
}
varType, ok := bsonDMap(doc)["VariableType"].(primitive.D)
if !ok {
t.Fatalf("VariableType missing or wrong type: %T", bsonDMap(doc)["VariableType"])
}
if got := bsonDMap(varType)["$Type"]; got != "DataTypes$ObjectType" {
t.Fatalf("VariableType.$Type = %v, want DataTypes$ObjectType", got)
}
}

func TestSerializeImportXmlActionPreservesSingleObjectRange(t *testing.T) {
forceSingleOccurrence := false
doc := serializeImportXmlAction(&microflows.ImportXmlAction{
BaseElement: model.BaseElement{ID: model.ID("import-action-1")},
ResultHandling: &microflows.ResultHandlingMapping{
BaseElement: model.BaseElement{ID: model.ID("result-handling-1")},
MappingID: model.ID("SampleRest.IMM_ErrorResponse"),
ResultEntityID: model.ID("SampleRest.Error"),
ResultVariable: "ErrorResponse",
SingleObject: true,
ForceSingleOccurrence: &forceSingleOccurrence,
},
XmlDocumentVariable: "LatestHttpResponse",
})

resultHandling, ok := bsonDMap(doc)["ResultHandling"].(primitive.D)
if !ok {
t.Fatalf("ResultHandling missing or wrong type: %T", bsonDMap(doc)["ResultHandling"])
}
importCall, ok := bsonDMap(resultHandling)["ImportMappingCall"].(primitive.D)
if !ok {
t.Fatalf("ImportMappingCall missing or wrong type: %T", bsonDMap(resultHandling)["ImportMappingCall"])
}
callFields := bsonDMap(importCall)
if got := callFields["ForceSingleOccurrence"]; got != false {
t.Fatalf("ForceSingleOccurrence = %v, want false", got)
}
rangeDoc, ok := callFields["Range"].(primitive.D)
if !ok {
t.Fatalf("Range missing or wrong type: %T", callFields["Range"])
}
if got := bsonDMap(rangeDoc)["SingleObject"]; got != true {
t.Fatalf("Range.SingleObject = %v, want true", got)
}
}

func TestParseImportXmlActionUsesRangeForSingleObject(t *testing.T) {
got := parseImportXmlAction(map[string]any{
"$ID": "import-action-1",
"XmlDocumentVariable": "LatestHttpResponse",
"XmlDocumentVariableName": "LatestHttpResponse",
"ResultHandling": map[string]any{
"$ID": "result-handling-1",
"ResultVariableName": "ErrorResponse",
"ImportMappingCall": map[string]any{
"ReturnValueMapping": "SampleRest.IMM_ErrorResponse",
"ForceSingleOccurrence": false,
"Range": map[string]any{
"SingleObject": true,
},
"VariableType": map[string]any{
"$Type": "DataTypes$ObjectType",
"Entity": "SampleRest.Error",
},
},
},
})

if got.ResultHandling == nil {
t.Fatal("ResultHandling missing")
}
if !got.ResultHandling.SingleObject {
t.Fatal("Range.SingleObject=true must make XML import result object-valued")
}
if got.ResultHandling.ForceSingleOccurrence == nil || *got.ResultHandling.ForceSingleOccurrence {
t.Fatalf("ForceSingleOccurrence = %v, want explicit false", got.ResultHandling.ForceSingleOccurrence)
}
}

func bsonDMap(doc primitive.D) map[string]any {
out := make(map[string]any, len(doc))
for _, elem := range doc {
out[elem.Key] = elem.Value
}
return out
}
func TestParseActionActivityPreservesWebServiceActionRawBSONOrder(t *testing.T) {
rawAction := primitive.D{
{Key: "$ID", Value: "web-service-action-ordered"},
Expand Down
Loading
Loading