diff --git a/mdl-examples/bug-tests/369-rest-mapping-result-cardinality.mdl b/mdl-examples/bug-tests/369-rest-mapping-result-cardinality.mdl new file mode 100644 index 00000000..8657f06a --- /dev/null +++ b/mdl-examples/bug-tests/369-rest-mapping-result-cardinality.mdl @@ -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; +/ diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index bd06f670..42b81608 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -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. diff --git a/mdl/executor/cmd_microflows_builder_rest_response_test.go b/mdl/executor/cmd_microflows_builder_rest_response_test.go index d0f1fb04..e1d111ff 100644 --- a/mdl/executor/cmd_microflows_builder_rest_response_test.go +++ b/mdl/executor/cmd_microflows_builder_rest_response_test.go @@ -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" ) @@ -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) + } +} diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index 02cf9b2d..968592cb 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -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() {} diff --git a/sdk/mpr/parser_microflow_actions.go b/sdk/mpr/parser_microflow_actions.go index 6bbcf8c4..eafb63e6 100644 --- a/sdk/mpr/parser_microflow_actions.go +++ b/sdk/mpr/parser_microflow_actions.go @@ -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": @@ -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 } diff --git a/sdk/mpr/parser_microflow_test.go b/sdk/mpr/parser_microflow_test.go index e0589827..df39d7d1 100644 --- a/sdk/mpr/parser_microflow_test.go +++ b/sdk/mpr/parser_microflow_test.go @@ -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" @@ -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(µflows.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(µflows.ImportXmlAction{ + BaseElement: model.BaseElement{ID: model.ID("import-action-1")}, + ResultHandling: µflows.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"}, diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go index 6e1a4be4..c23146bc 100644 --- a/sdk/mpr/writer_microflow_actions.go +++ b/sdk/mpr/writer_microflow_actions.go @@ -894,15 +894,18 @@ func serializeRestResultHandling(rh microflows.ResultHandling, outputVar string) {Key: "$Type", Value: "Microflows$ResultHandling"}, {Key: "Bind", Value: true}, } - // ImportMappingCall - uses ReturnValueMapping (Studio Pro field name) - // with all required fields to make the mapping link visible in Studio Pro. - // SingleObject drives ForceSingleOccurrence and Range.SingleObject. + // ImportMappingCall uses ReturnValueMapping (Studio Pro field name) with + // all required fields to make the mapping link visible in Studio Pro. + forceSingleOccurrence := h.SingleObject + if h.ForceSingleOccurrence != nil { + forceSingleOccurrence = *h.ForceSingleOccurrence + } importCall := bson.D{ {Key: "$ID", Value: idToBsonBinary(GenerateID())}, {Key: "$Type", Value: "Microflows$ImportMappingCall"}, {Key: "Commit", Value: "YesWithoutEvents"}, {Key: "ContentType", Value: "Json"}, - {Key: "ForceSingleOccurrence", Value: h.SingleObject}, + {Key: "ForceSingleOccurrence", Value: forceSingleOccurrence}, {Key: "ObjectHandlingBackup", Value: "Create"}, {Key: "ParameterVariableName", Value: ""}, {Key: "Range", Value: bson.D{ @@ -1314,19 +1317,24 @@ func serializeExecuteDatabaseQueryAction(a *microflows.ExecuteDatabaseQueryActio } func serializeImportXmlAction(a *microflows.ImportXmlAction) bson.D { + forceSingleOccurrence := false + if a.ResultHandling.ForceSingleOccurrence != nil { + forceSingleOccurrence = *a.ResultHandling.ForceSingleOccurrence + } + // Build ImportMappingCall importCall := bson.D{ {Key: "$ID", Value: idToBsonBinary(GenerateID())}, {Key: "$Type", Value: "Microflows$ImportMappingCall"}, {Key: "Commit", Value: "YesWithoutEvents"}, {Key: "ContentType", Value: "Json"}, - {Key: "ForceSingleOccurrence", Value: false}, + {Key: "ForceSingleOccurrence", Value: forceSingleOccurrence}, {Key: "ObjectHandlingBackup", Value: "Create"}, {Key: "ParameterVariableName", Value: ""}, {Key: "Range", Value: bson.D{ {Key: "$ID", Value: idToBsonBinary(GenerateID())}, {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: false}, + {Key: "SingleObject", Value: a.ResultHandling.SingleObject}, }}, {Key: "ReturnValueMapping", Value: string(a.ResultHandling.MappingID)}, }