From faa3f8cf27d540732696c70f8d4de1834f783731 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:37:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Delete=20sdk/mpr=20=E2=80=94=20the=20legacy?= =?UTF-8?q?=20engine=20is=20gone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 163 files, 41,674 lines. This finishes docs/plans/2026-09-14-retire-legacy-engine.md. Zero importers was not the same as safe to rm -rf. Two live dependencies survived, and neither is visible to a check written against the parent package's import path. sdk/mpr/version had six importers, two of them shipping code (cmd/mxcli/docker/build.go and patch.go) — a subpackage is a different import path. And cmd/mxcli/docker/update_widgets_test.go read sdk/mpr/testdata/v1-project by filesystem path, an os.DirFS string rather than an import. So: before deleting a package, search for three things — its own import path, its subpackages' paths, and its directory as a literal string (testdata, go:embed, scripts). The last two are invisible to any importer census. The six importers went to mdl/types, not to modelsdk/mpr/version. sdk/mpr/version.ProjectVersion is `type ProjectVersion = types.ProjectVersion`, an alias, so types.ProjectVersion is the same type — while modelsdk/mpr/version declares a duplicate struct that would have been a different one. Read the declaration, not the name. The v1 fixture moved to modelsdk/mpr/testdata/. Everything was repointed and proven green with the package still present, which separates "the repoint was wrong" from "the deletion was wrong". Two measurements worth keeping. The shipped binary is identical in size before and after, so the linker had already dropped the package: this removes source weight, not runtime behaviour. And sdk/widgets fell to zero importers as a side effect but is deliberately kept — modelsdk/widgets/dirty_template_test.go reads sdk/widgets/templates/mendix-11.6 by path. Same trap, caught by grepping the directory name rather than the import. The import guard added in the previous slice is removed: with the package gone, an import is a compile error, strictly stronger than a test asserting it. Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (560), check-findings, and the full repo-wide integration suite: exit 0, no failures. docker check and check --post-migration are byte-identical to the pre-deletion binary. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- .../fix-issue/findings/mdl-backend.jsonl | 1 + cmd/mxcli/docker/build.go | 4 +- cmd/mxcli/docker/build_integration_test.go | 4 +- cmd/mxcli/docker/build_test.go | 10 +- cmd/mxcli/docker/patch.go | 4 +- cmd/mxcli/docker/update_widgets_test.go | 2 +- docs/plans/2026-09-14-retire-legacy-engine.md | 32 + mdl/backend/sdkmpr_import_guard_test.go | 122 -- mdl/executor/doctype_version_gating_test.go | 4 +- mdl/executor/version_filter_test.go | 14 +- .../mpr/testdata/v1-project/App.mpr | Bin sdk/mpr/asyncapi.go | 18 - sdk/mpr/asyncapi_test.go | 160 -- sdk/mpr/bson_testutil_test.go | 37 - sdk/mpr/domainmodel_annotation_test.go | 116 -- sdk/mpr/download_file_test.go | 54 - sdk/mpr/edmx.go | 29 - sdk/mpr/edmx_test.go | 409 ---- sdk/mpr/get_raw_unit_v1_test.go | 52 - sdk/mpr/inheritance_roundtrip_test.go | 107 - ...avaactions_microflowactioninfo_656_test.go | 112 - sdk/mpr/jsonstructure_folder_lookup_test.go | 124 -- sdk/mpr/microflow_call_writer_test.go | 89 - sdk/mpr/microflow_parameter_position_test.go | 67 - sdk/mpr/parser.go | 253 --- sdk/mpr/parser_businessevents.go | 167 -- sdk/mpr/parser_customblob.go | 295 --- sdk/mpr/parser_datatransformer.go | 75 - sdk/mpr/parser_dbconnection.go | 136 -- sdk/mpr/parser_domainmodel.go | 831 -------- sdk/mpr/parser_domainmodel_test.go | 48 - sdk/mpr/parser_enumeration.go | 197 -- sdk/mpr/parser_export_mapping.go | 164 -- sdk/mpr/parser_import_mapping.go | 244 --- sdk/mpr/parser_import_mapping_test.go | 22 - sdk/mpr/parser_javaactions.go | 578 ------ sdk/mpr/parser_javaactions_test.go | 66 - sdk/mpr/parser_listoperation_test.go | 99 - sdk/mpr/parser_menu_signout_test.go | 56 - sdk/mpr/parser_microflow.go | 1246 ----------- sdk/mpr/parser_microflow_actions.go | 1073 ---------- ...rser_microflow_error_handling_1078_test.go | 96 - sdk/mpr/parser_microflow_import_range_test.go | 165 -- sdk/mpr/parser_microflow_test.go | 408 ---- sdk/mpr/parser_microflow_workflow.go | 171 -- sdk/mpr/parser_misc.go | 838 -------- sdk/mpr/parser_misc_test.go | 59 - sdk/mpr/parser_module.go | 55 - sdk/mpr/parser_nanoflow.go | 119 -- sdk/mpr/parser_odata.go | 364 ---- sdk/mpr/parser_page.go | 237 --- sdk/mpr/parser_queued_call_test.go | 54 - sdk/mpr/parser_range_test.go | 95 - sdk/mpr/parser_rest.go | 407 ---- sdk/mpr/parser_rule.go | 99 - sdk/mpr/parser_rule_test.go | 106 - sdk/mpr/parser_scheduledevent_test.go | 54 - sdk/mpr/parser_security.go | 170 -- sdk/mpr/parser_settings.go | 225 -- sdk/mpr/parser_settings_test.go | 89 - sdk/mpr/parser_unknown.go | 64 - sdk/mpr/parser_webservice_source_test.go | 53 - sdk/mpr/parser_workflow.go | 750 ------- sdk/mpr/placeholder_test.go | 57 - sdk/mpr/queues.go | 109 - sdk/mpr/reader.go | 269 --- sdk/mpr/reader_agenteditor.go | 122 -- sdk/mpr/reader_documents.go | 1105 ---------- sdk/mpr/reader_types.go | 451 ---- sdk/mpr/reader_units.go | 684 ------ sdk/mpr/reader_units_type_test.go | 103 - sdk/mpr/reader_widgets.go | 747 ------- sdk/mpr/reader_xmlschema_test.go | 114 - sdk/mpr/regularexpressions.go | 64 - sdk/mpr/roundtrip_test.go | 600 ------ sdk/mpr/scheduledevents.go | 52 - sdk/mpr/showpage_roundtrip_test.go | 261 --- sdk/mpr/system_java_actions.go | 23 - sdk/mpr/system_module.go | 454 ---- .../enumerations/PictureQuality.mxunit | Bin 1685 -> 0 bytes .../testdata/microflows/ChangePassword.mxunit | Bin 9696 -> 0 bytes .../testdata/pages/Account_Overview.mxunit | Bin 441141 -> 0 bytes .../testdata/pages/WidgetDemo_Showcase.mxunit | Bin 1219710 -> 0 bytes .../WorkflowBaseline.Sub_Workflow.bson | Bin 1170 -> 0 bytes .../workflows/WorkflowBaseline.Workflow.bson | Bin 13869 -> 0 bytes sdk/mpr/text_language_test.go | 65 - sdk/mpr/utils.go | 43 - sdk/mpr/version/version.go | 167 -- sdk/mpr/workflow_agent_test.go | 32 - sdk/mpr/workflow_endpath_serialize_test.go | 37 - sdk/mpr/workflow_handlers_test.go | 103 - sdk/mpr/workflow_parse_test.go | 388 ---- sdk/mpr/workflow_write_test.go | 404 ---- sdk/mpr/writer_agenteditor_agent.go | 184 -- sdk/mpr/writer_agenteditor_kb.go | 110 - sdk/mpr/writer_agenteditor_mcpservice.go | 89 - sdk/mpr/writer_agenteditor_model.go | 124 -- sdk/mpr/writer_businessevents.go | 205 -- sdk/mpr/writer_commit_rename_test.go | 160 -- sdk/mpr/writer_core.go | 321 --- sdk/mpr/writer_customblob.go | 130 -- sdk/mpr/writer_datatransformer.go | 120 -- sdk/mpr/writer_dbconnection.go | 213 -- sdk/mpr/writer_dbconnection_querytype_test.go | 122 -- sdk/mpr/writer_domainmodel.go | 1572 -------------- sdk/mpr/writer_domainmodel_test.go | 309 --- sdk/mpr/writer_elision_test.go | 206 -- sdk/mpr/writer_enumeration.go | 228 -- sdk/mpr/writer_export_mapping.go | 205 -- .../writer_export_mapping_properties_test.go | 124 -- sdk/mpr/writer_export_mapping_test.go | 201 -- .../writer_external_action_returntype_test.go | 74 - sdk/mpr/writer_formattinginfo_test.go | 39 - sdk/mpr/writer_id_order_test.go | 233 --- sdk/mpr/writer_imagecollection.go | 71 - sdk/mpr/writer_imagecollection_test.go | 81 - sdk/mpr/writer_import_mapping.go | 290 --- sdk/mpr/writer_import_mapping_test.go | 255 --- sdk/mpr/writer_javaactions.go | 433 ---- sdk/mpr/writer_javaactions_enum_680_test.go | 48 - sdk/mpr/writer_javascriptactions.go | 174 -- sdk/mpr/writer_javascriptactions_test.go | 93 - sdk/mpr/writer_jsonstructure.go | 100 - sdk/mpr/writer_listoperation_test.go | 174 -- sdk/mpr/writer_listview_source_test.go | 87 - sdk/mpr/writer_listview_template_test.go | 68 - sdk/mpr/writer_microflow.go | 864 -------- sdk/mpr/writer_microflow_action_items_test.go | 86 - sdk/mpr/writer_microflow_actions.go | 1841 ---------------- sdk/mpr/writer_microflow_flags_test.go | 45 - sdk/mpr/writer_microflow_version_test.go | 184 -- sdk/mpr/writer_microflow_workflow.go | 190 -- sdk/mpr/writer_modules.go | 347 ---- sdk/mpr/writer_navigation.go | 472 ----- sdk/mpr/writer_navigation_icon_test.go | 272 --- sdk/mpr/writer_navigation_notfound_test.go | 99 - sdk/mpr/writer_navigation_offline_test.go | 74 - sdk/mpr/writer_odata.go | 636 ------ sdk/mpr/writer_odata_test.go | 528 ----- sdk/mpr/writer_order.go | 23 - sdk/mpr/writer_pages.go | 355 ---- sdk/mpr/writer_pages_placeholder_test.go | 110 - sdk/mpr/writer_placement.go | 115 - sdk/mpr/writer_refs.go | 123 -- sdk/mpr/writer_rename.go | 240 --- sdk/mpr/writer_rest.go | 635 ------ sdk/mpr/writer_rest_httpresponse_test.go | 34 - sdk/mpr/writer_rest_inline_mapping_test.go | 175 -- sdk/mpr/writer_rest_test.go | 447 ---- sdk/mpr/writer_rule_split_test.go | 119 -- sdk/mpr/writer_security.go | 1843 ----------------- sdk/mpr/writer_security_inherited_test.go | 229 -- sdk/mpr/writer_security_reconcile_test.go | 230 -- sdk/mpr/writer_security_test.go | 384 ---- sdk/mpr/writer_settings.go | 116 -- sdk/mpr/writer_units.go | 293 --- sdk/mpr/writer_units_test.go | 154 -- sdk/mpr/writer_validationrule_test.go | 150 -- sdk/mpr/writer_webservice_body_test.go | 275 --- sdk/mpr/writer_widgets.go | 727 ------- sdk/mpr/writer_widgets_action.go | 275 --- sdk/mpr/writer_widgets_action_test.go | 286 --- sdk/mpr/writer_widgets_container_test.go | 89 - sdk/mpr/writer_widgets_custom.go | 472 ----- sdk/mpr/writer_widgets_display.go | 970 --------- sdk/mpr/writer_widgets_icon_test.go | 127 -- sdk/mpr/writer_widgets_image_test.go | 105 - sdk/mpr/writer_widgets_input.go | 183 -- sdk/mpr/writer_widgets_layout.go | 209 -- sdk/mpr/writer_widgets_linkbutton_test.go | 50 - sdk/mpr/writer_widgets_snippet_test.go | 110 - sdk/mpr/writer_widgets_test.go | 465 ----- sdk/mpr/writer_workflow.go | 876 -------- 173 files changed, 54 insertions(+), 41674 deletions(-) delete mode 100644 mdl/backend/sdkmpr_import_guard_test.go rename {sdk => modelsdk}/mpr/testdata/v1-project/App.mpr (100%) delete mode 100644 sdk/mpr/asyncapi.go delete mode 100644 sdk/mpr/asyncapi_test.go delete mode 100644 sdk/mpr/bson_testutil_test.go delete mode 100644 sdk/mpr/domainmodel_annotation_test.go delete mode 100644 sdk/mpr/download_file_test.go delete mode 100644 sdk/mpr/edmx.go delete mode 100644 sdk/mpr/edmx_test.go delete mode 100644 sdk/mpr/get_raw_unit_v1_test.go delete mode 100644 sdk/mpr/inheritance_roundtrip_test.go delete mode 100644 sdk/mpr/javaactions_microflowactioninfo_656_test.go delete mode 100644 sdk/mpr/jsonstructure_folder_lookup_test.go delete mode 100644 sdk/mpr/microflow_call_writer_test.go delete mode 100644 sdk/mpr/microflow_parameter_position_test.go delete mode 100644 sdk/mpr/parser.go delete mode 100644 sdk/mpr/parser_businessevents.go delete mode 100644 sdk/mpr/parser_customblob.go delete mode 100644 sdk/mpr/parser_datatransformer.go delete mode 100644 sdk/mpr/parser_dbconnection.go delete mode 100644 sdk/mpr/parser_domainmodel.go delete mode 100644 sdk/mpr/parser_domainmodel_test.go delete mode 100644 sdk/mpr/parser_enumeration.go delete mode 100644 sdk/mpr/parser_export_mapping.go delete mode 100644 sdk/mpr/parser_import_mapping.go delete mode 100644 sdk/mpr/parser_import_mapping_test.go delete mode 100644 sdk/mpr/parser_javaactions.go delete mode 100644 sdk/mpr/parser_javaactions_test.go delete mode 100644 sdk/mpr/parser_listoperation_test.go delete mode 100644 sdk/mpr/parser_menu_signout_test.go delete mode 100644 sdk/mpr/parser_microflow.go delete mode 100644 sdk/mpr/parser_microflow_actions.go delete mode 100644 sdk/mpr/parser_microflow_error_handling_1078_test.go delete mode 100644 sdk/mpr/parser_microflow_import_range_test.go delete mode 100644 sdk/mpr/parser_microflow_test.go delete mode 100644 sdk/mpr/parser_microflow_workflow.go delete mode 100644 sdk/mpr/parser_misc.go delete mode 100644 sdk/mpr/parser_misc_test.go delete mode 100644 sdk/mpr/parser_module.go delete mode 100644 sdk/mpr/parser_nanoflow.go delete mode 100644 sdk/mpr/parser_odata.go delete mode 100644 sdk/mpr/parser_page.go delete mode 100644 sdk/mpr/parser_queued_call_test.go delete mode 100644 sdk/mpr/parser_range_test.go delete mode 100644 sdk/mpr/parser_rest.go delete mode 100644 sdk/mpr/parser_rule.go delete mode 100644 sdk/mpr/parser_rule_test.go delete mode 100644 sdk/mpr/parser_scheduledevent_test.go delete mode 100644 sdk/mpr/parser_security.go delete mode 100644 sdk/mpr/parser_settings.go delete mode 100644 sdk/mpr/parser_settings_test.go delete mode 100644 sdk/mpr/parser_unknown.go delete mode 100644 sdk/mpr/parser_webservice_source_test.go delete mode 100644 sdk/mpr/parser_workflow.go delete mode 100644 sdk/mpr/placeholder_test.go delete mode 100644 sdk/mpr/queues.go delete mode 100644 sdk/mpr/reader.go delete mode 100644 sdk/mpr/reader_agenteditor.go delete mode 100644 sdk/mpr/reader_documents.go delete mode 100644 sdk/mpr/reader_types.go delete mode 100644 sdk/mpr/reader_units.go delete mode 100644 sdk/mpr/reader_units_type_test.go delete mode 100644 sdk/mpr/reader_widgets.go delete mode 100644 sdk/mpr/reader_xmlschema_test.go delete mode 100644 sdk/mpr/regularexpressions.go delete mode 100644 sdk/mpr/roundtrip_test.go delete mode 100644 sdk/mpr/scheduledevents.go delete mode 100644 sdk/mpr/showpage_roundtrip_test.go delete mode 100644 sdk/mpr/system_java_actions.go delete mode 100644 sdk/mpr/system_module.go delete mode 100644 sdk/mpr/testdata/enumerations/PictureQuality.mxunit delete mode 100644 sdk/mpr/testdata/microflows/ChangePassword.mxunit delete mode 100644 sdk/mpr/testdata/pages/Account_Overview.mxunit delete mode 100644 sdk/mpr/testdata/pages/WidgetDemo_Showcase.mxunit delete mode 100644 sdk/mpr/testdata/workflows/WorkflowBaseline.Sub_Workflow.bson delete mode 100644 sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson delete mode 100644 sdk/mpr/text_language_test.go delete mode 100644 sdk/mpr/utils.go delete mode 100644 sdk/mpr/version/version.go delete mode 100644 sdk/mpr/workflow_agent_test.go delete mode 100644 sdk/mpr/workflow_endpath_serialize_test.go delete mode 100644 sdk/mpr/workflow_handlers_test.go delete mode 100644 sdk/mpr/workflow_parse_test.go delete mode 100644 sdk/mpr/workflow_write_test.go delete mode 100644 sdk/mpr/writer_agenteditor_agent.go delete mode 100644 sdk/mpr/writer_agenteditor_kb.go delete mode 100644 sdk/mpr/writer_agenteditor_mcpservice.go delete mode 100644 sdk/mpr/writer_agenteditor_model.go delete mode 100644 sdk/mpr/writer_businessevents.go delete mode 100644 sdk/mpr/writer_commit_rename_test.go delete mode 100644 sdk/mpr/writer_core.go delete mode 100644 sdk/mpr/writer_customblob.go delete mode 100644 sdk/mpr/writer_datatransformer.go delete mode 100644 sdk/mpr/writer_dbconnection.go delete mode 100644 sdk/mpr/writer_dbconnection_querytype_test.go delete mode 100644 sdk/mpr/writer_domainmodel.go delete mode 100644 sdk/mpr/writer_domainmodel_test.go delete mode 100644 sdk/mpr/writer_elision_test.go delete mode 100644 sdk/mpr/writer_enumeration.go delete mode 100644 sdk/mpr/writer_export_mapping.go delete mode 100644 sdk/mpr/writer_export_mapping_properties_test.go delete mode 100644 sdk/mpr/writer_export_mapping_test.go delete mode 100644 sdk/mpr/writer_external_action_returntype_test.go delete mode 100644 sdk/mpr/writer_formattinginfo_test.go delete mode 100644 sdk/mpr/writer_id_order_test.go delete mode 100644 sdk/mpr/writer_imagecollection.go delete mode 100644 sdk/mpr/writer_imagecollection_test.go delete mode 100644 sdk/mpr/writer_import_mapping.go delete mode 100644 sdk/mpr/writer_import_mapping_test.go delete mode 100644 sdk/mpr/writer_javaactions.go delete mode 100644 sdk/mpr/writer_javaactions_enum_680_test.go delete mode 100644 sdk/mpr/writer_javascriptactions.go delete mode 100644 sdk/mpr/writer_javascriptactions_test.go delete mode 100644 sdk/mpr/writer_jsonstructure.go delete mode 100644 sdk/mpr/writer_listoperation_test.go delete mode 100644 sdk/mpr/writer_listview_source_test.go delete mode 100644 sdk/mpr/writer_listview_template_test.go delete mode 100644 sdk/mpr/writer_microflow.go delete mode 100644 sdk/mpr/writer_microflow_action_items_test.go delete mode 100644 sdk/mpr/writer_microflow_actions.go delete mode 100644 sdk/mpr/writer_microflow_flags_test.go delete mode 100644 sdk/mpr/writer_microflow_version_test.go delete mode 100644 sdk/mpr/writer_microflow_workflow.go delete mode 100644 sdk/mpr/writer_modules.go delete mode 100644 sdk/mpr/writer_navigation.go delete mode 100644 sdk/mpr/writer_navigation_icon_test.go delete mode 100644 sdk/mpr/writer_navigation_notfound_test.go delete mode 100644 sdk/mpr/writer_navigation_offline_test.go delete mode 100644 sdk/mpr/writer_odata.go delete mode 100644 sdk/mpr/writer_odata_test.go delete mode 100644 sdk/mpr/writer_order.go delete mode 100644 sdk/mpr/writer_pages.go delete mode 100644 sdk/mpr/writer_pages_placeholder_test.go delete mode 100644 sdk/mpr/writer_placement.go delete mode 100644 sdk/mpr/writer_refs.go delete mode 100644 sdk/mpr/writer_rename.go delete mode 100644 sdk/mpr/writer_rest.go delete mode 100644 sdk/mpr/writer_rest_httpresponse_test.go delete mode 100644 sdk/mpr/writer_rest_inline_mapping_test.go delete mode 100644 sdk/mpr/writer_rest_test.go delete mode 100644 sdk/mpr/writer_rule_split_test.go delete mode 100644 sdk/mpr/writer_security.go delete mode 100644 sdk/mpr/writer_security_inherited_test.go delete mode 100644 sdk/mpr/writer_security_reconcile_test.go delete mode 100644 sdk/mpr/writer_security_test.go delete mode 100644 sdk/mpr/writer_settings.go delete mode 100644 sdk/mpr/writer_units.go delete mode 100644 sdk/mpr/writer_units_test.go delete mode 100644 sdk/mpr/writer_validationrule_test.go delete mode 100644 sdk/mpr/writer_webservice_body_test.go delete mode 100644 sdk/mpr/writer_widgets.go delete mode 100644 sdk/mpr/writer_widgets_action.go delete mode 100644 sdk/mpr/writer_widgets_action_test.go delete mode 100644 sdk/mpr/writer_widgets_container_test.go delete mode 100644 sdk/mpr/writer_widgets_custom.go delete mode 100644 sdk/mpr/writer_widgets_display.go delete mode 100644 sdk/mpr/writer_widgets_icon_test.go delete mode 100644 sdk/mpr/writer_widgets_image_test.go delete mode 100644 sdk/mpr/writer_widgets_input.go delete mode 100644 sdk/mpr/writer_widgets_layout.go delete mode 100644 sdk/mpr/writer_widgets_linkbutton_test.go delete mode 100644 sdk/mpr/writer_widgets_snippet_test.go delete mode 100644 sdk/mpr/writer_widgets_test.go delete mode 100644 sdk/mpr/writer_workflow.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index 5cb3649c7e..911a51a7e7 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -111,3 +111,4 @@ {"area": "mdl/backend", "date": "2026-09-15", "symptom": "`create or modify workflow` over `--mcp` (Studio Pro 11.14) stored the flow's activities reversed, with an old activity left in and a new one missing ([Start, a, b, End] rewritten as A, B, C → Start, B, A, a, End; a larger flow also lost its parallel split and held a name twice); `alter workflow … replace activity X` left X in place. The update calls all reported SUCCESS", "cause": "One `ped_update_document` batch is not applied in the order sent. Every measured batch fits: ops run highest index first, and at one index the adds go in as a block in op order before the removes — so a remove at an index where something was just added removes the added element. `UpdateWorkflow` sent the flow's removes plus its middles in reverse at index 1 in one batch (and index-less adds for event sub-processes/handlers, which come out reversed); `ReplaceActivity` sent remove @k plus adds at k, k+1; `InsertAfterActivity` sent incrementing indices", "file": "`mdl/backend/mcp/workflow.go` (`UpdateWorkflow`, `InsertAfterActivity`, `ReplaceActivity`, `addAtOp`); simulator `pedListSim` in `mdl/backend/mcp/workflow_listops_test.go`", "insight": "**The code comment claimed the reverse-at-index-1 trick worked, and no fake PED modelled batch semantics, so the unit tests asserted the ops sent rather than the list stored.** Fix tests by simulating the server's list semantics and asserting the resulting ORDER, and keep a table test that replays each raw-PED measurement through the simulator — that is what makes the simulator trustworthy and each control meaningful. The first guess at the rule (\"adds first, then removes\") fit two measurements and failed the third; fit the model to every data point before building on it. Also: a live update-path probe needs a workflow the executor can see — either on disk, or created earlier in the same exec (the backend's session list)", "fix": "Never add to and remove from the same list in one batch: add the statement's elements at a single index in their own order (flow middles @1, event sub-processes and handlers @0, replacement activities @k+1), then remove the stored/replaced ones in a second update. Adding first leaves duplicates, not a gutted workflow, if the second update fails"} {"area":"mdl/backend","date":"2026-09-15","symptom":"Wiring FindCustomWidgetType from modelsdk/mpr.Reader onto the codec Backend by straight delegation made `mxcli extract-templates` extract 0 of 6 templates, reporting for each widget: '[SKIP] Combo box: widget type is bson.D, want bson.D'. The type assertion in the caller names the same type on both sides of 'want'.","cause":"modelsdk/mpr builds RawType/RawObject with the v2 BSON driver (go.mongodb.org/mongo-driver/v2/bson) while sdk/mpr and every caller use v1 (go.mongodb.org/mongo-driver/bson). They are unrelated Go types that both print as 'bson.D', so the mismatch is invisible in the error text. types.RawCustomWidgetType declares the fields as `any` to avoid a BSON dependency, which removes the compiler's ability to catch it too.","file":"mdl/backend/modelsdk/widget_custom_find.go","fix":"Convert at the backend boundary with the package's existing v2ToV1BSON helper, so RawType/RawObject always hold v1 bson.D — the currency sdk/mpr established and callers assert. Verified by extracting all 6 templates byte-for-byte identically to the pre-change binary (1.2MB datagrid.json included); reverting the conversion fails the new test with 'RawType is bson.D, want v1 bson.D'.","insight":"An `any` field crossing an engine boundary can carry the RIGHT type name and the WRONG package, and the error message will look like a tautology. When a type assertion fails with identical type names on both sides, the question is which import path each came from, not what the type is — the two BSON drivers coexist in this repo on purpose (modelsdk is v2, sdk/mpr and the CLI are v1) and widget_pluggable_write.go's v2ToV1BSON already existed for the write direction. A cast written to silence that compile/assert error panics at runtime instead. Two process notes from the same change. (1) GREP FOR AN EXISTING IMPLEMENTATION BEFORE WRITING ONE: the walker had been in modelsdk/mpr all along (FindAllCustomWidgetTypes + collectCustomWidgets, and it populates UnitName/WidgetName which a fresh implementation would omit); only the backend wiring was missing, which is exactly what 'this should be unreachable' in the unimplemented error meant. (2) unimplemented_gen.go still emits the stub after a method is implemented — the generator writes a complete fallback set and Backend's own method shadows it — so the thing to update is the unreachableUnimplemented map in unimplemented_reachability_test.go, which fails loudly if a listed method becomes implemented."} {"area":"mdl/backend","date":"2026-09-15","symptom":"Phase 4a took sdk/mpr from 27 importers to 0, but nothing stopped the count from creeping back — there was no build or test guard, only the plan document and a habit.","cause":"The invariant lived in prose. A single new `import \"github.com/mendixlabs/mxcli/sdk/mpr\"` compiles, passes every test, and reintroduces exactly the blind spot Phase 4a existed to close: the unimplemented-method census in mdl/backend/modelsdk lists methods with NO implementation, so a caller reaching one through a concrete *sdk/mpr.Reader never appears in it. That is what hid project_tree.go's 36 semantic reads (#477) and cmd_extract_templates.go's FindCustomWidgetType (#484) until each was found by hand.","file":"mdl/backend/sdkmpr_import_guard_test.go","fix":"TestNothingImportsTheLegacyEngine parses every .go file's imports (go/parser, ImportsOnly) and fails naming any file that imports sdk/mpr, with the remedy in the message. Controlled by dropping a one-line file importing sdk/mpr into examples/ — it fails and names the file.","insight":"A zero-count invariant needs TWO positive controls or it passes vacuously forever, and the failure mode is silent by construction: a walk rooted at the wrong directory, a skipped-dir rule that is too broad, or an import-parsing mistake all report '0 importers' and read as success. So assert (1) a plausible number of files was actually scanned (here >500; it sees 2551) and (2) the detector can see imports AT ALL, by counting a package the repo definitely does import (mdl/backend, 120 files). Only then does 0 mean zero. This is scripts/check-tunnel-deps.sh's pattern — it asserts chisel IS in the linux graph before asserting it is absent from windows/darwin — and the same reasoning as a bug-fix control: a test that only ever passes has not been shown to detect anything. Practical note: skip sdk/mpr's own directory by comparing the path to the repo root rather than by basename, or a directory named mpr elsewhere is skipped too."} +{"area":"mdl/backend","date":"2026-09-16","symptom":"With sdk/mpr at zero importers, `rm -rf sdk/mpr` still would not have been safe: it had two live dependencies that an import-based check cannot see. sdk/mpr/version has six importers (two of them shipping code, cmd/mxcli/docker/build.go and patch.go), and cmd/mxcli/docker/update_widgets_test.go reads sdk/mpr/testdata/v1-project by FILESYSTEM PATH.","cause":"The zero-importer guard matched the exact string \"github.com/mendixlabs/mxcli/sdk/mpr\". A SUBPACKAGE is a different import path, and a testdata directory is not an import at all — it is an os.DirFS string. Neither shape appears in a check written against the parent package's path.","file":"sdk/mpr","fix":"Repointed the six version importers at mdl/types (sdk/mpr/version.ProjectVersion is `type ProjectVersion = types.ProjectVersion`, an ALIAS, so it is the same type rather than a compatible one — modelsdk/mpr/version declares a duplicate struct and would NOT have been), moved the v1-project fixture to modelsdk/mpr/testdata/, verified both with the package still present, and only then deleted. 163 files, 41,674 lines.","insight":"Before deleting a package, search for THREE things, not one: the package's own import path, its subpackages' import paths (`.../pkg/`), and its directory as a literal string (testdata read through os.DirFS, go:embed, scripts). The last two are invisible to any importer census. Repoint everything FIRST and prove the build and tests green while the package still exists — that separates 'the repoint was wrong' from 'the deletion was wrong', which a single combined commit cannot distinguish. Two measurements worth keeping: the shipped binary is byte-identical in SIZE before and after, confirming the linker had already dropped the package, so this deletion removes source weight and not runtime behaviour; and `sdk/widgets` dropped to zero importers as a side effect but must NOT be deleted, because modelsdk/widgets/dirty_template_test.go reads sdk/widgets/templates/mendix-11.6 by path — the same path-not-import trap, found by grepping for the directory name rather than the import."} diff --git a/cmd/mxcli/docker/build.go b/cmd/mxcli/docker/build.go index 2597e1f91e..fcb27c1a80 100644 --- a/cmd/mxcli/docker/build.go +++ b/cmd/mxcli/docker/build.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // BuildOptions configures the docker build command. @@ -779,7 +779,7 @@ func ensureDemoUsers(projectPath string, w io.Writer) error { } // DescribePatches returns the list of patches that would be applied for a given version. -func DescribePatches(pv *version.ProjectVersion) []string { +func DescribePatches(pv *mxversion.ProjectVersion) []string { var patches []string is116x := pv.MajorVersion == 11 && pv.MinorVersion == 6 patches = append(patches, "Set bin/start execute permission") diff --git a/cmd/mxcli/docker/build_integration_test.go b/cmd/mxcli/docker/build_integration_test.go index 1de5f1602d..0963de303b 100644 --- a/cmd/mxcli/docker/build_integration_test.go +++ b/cmd/mxcli/docker/build_integration_test.go @@ -12,7 +12,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // TestBuild_PreservesMPRv2StorageFormat is the end-to-end guard for @@ -96,7 +96,7 @@ func TestBuild_PreservesMPRv2StorageFormat(t *testing.T) { } // mprProductVersion opens the .mpr and returns its Mendix product version. -func mprProductVersion(t *testing.T, mprPath string) *version.ProjectVersion { +func mprProductVersion(t *testing.T, mprPath string) *mxversion.ProjectVersion { t.Helper() reader, err := openReadOnly(mprPath) if err != nil { diff --git a/cmd/mxcli/docker/build_test.go b/cmd/mxcli/docker/build_test.go index da4b1480f4..365a1f8875 100644 --- a/cmd/mxcli/docker/build_test.go +++ b/cmd/mxcli/docker/build_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // newTestZipWriter wraps zip.NewWriter for test helpers. @@ -310,7 +310,7 @@ CMD ["./bin/start.sh", "etc/Default"] os.MkdirAll(etcDir, 0755) os.WriteFile(filepath.Join(etcDir, "Default"), []byte("# config\n"), 0644) - pv := &version.ProjectVersion{ + pv := &mxversion.ProjectVersion{ ProductVersion: "11.6.1", MajorVersion: 11, MinorVersion: 6, @@ -353,7 +353,7 @@ CMD ["./bin/start", "etc/Default"] os.MkdirAll(etcDir, 0755) os.WriteFile(filepath.Join(etcDir, "Default"), []byte("# config\n"), 0644) - pv := &version.ProjectVersion{ + pv := &mxversion.ProjectVersion{ ProductVersion: "12.0.0", MajorVersion: 12, MinorVersion: 0, @@ -737,7 +737,7 @@ func TestFlattenPADDir_OverwritesOldContents(t *testing.T) { } func TestDescribePatches_116x(t *testing.T) { - pv := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 6, PatchVersion: 1} + pv := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 6, PatchVersion: 1} patches := DescribePatches(pv) if len(patches) != 7 { t.Errorf("expected 7 patches for 11.6.x, got %d", len(patches)) @@ -745,7 +745,7 @@ func TestDescribePatches_116x(t *testing.T) { } func TestDescribePatches_12x(t *testing.T) { - pv := &version.ProjectVersion{MajorVersion: 12, MinorVersion: 0, PatchVersion: 0} + pv := &mxversion.ProjectVersion{MajorVersion: 12, MinorVersion: 0, PatchVersion: 0} patches := DescribePatches(pv) if len(patches) != 6 { t.Errorf("expected 6 patches for 12.x, got %d", len(patches)) diff --git a/cmd/mxcli/docker/patch.go b/cmd/mxcli/docker/patch.go index 906031e7b4..bc89a9bfab 100644 --- a/cmd/mxcli/docker/patch.go +++ b/cmd/mxcli/docker/patch.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // PatchResult describes the outcome of applying a single patch. @@ -21,7 +21,7 @@ type PatchResult struct { // ApplyPatches applies version-aware patches to the PAD output directory. // Returns results for each patch attempted. -func ApplyPatches(padDir string, pv *version.ProjectVersion) []PatchResult { +func ApplyPatches(padDir string, pv *mxversion.ProjectVersion) []PatchResult { var results []PatchResult is116x := pv.MajorVersion == 11 && pv.MinorVersion == 6 diff --git a/cmd/mxcli/docker/update_widgets_test.go b/cmd/mxcli/docker/update_widgets_test.go index 8c73131aac..42ac99966f 100644 --- a/cmd/mxcli/docker/update_widgets_test.go +++ b/cmd/mxcli/docker/update_widgets_test.go @@ -53,7 +53,7 @@ func v2Fixture(t *testing.T) string { func v1Fixture(t *testing.T) string { t.Helper() dst := t.TempDir() - if err := os.CopyFS(dst, os.DirFS("../../../sdk/mpr/testdata/v1-project")); err != nil { + if err := os.CopyFS(dst, os.DirFS("../../../modelsdk/mpr/testdata/v1-project")); err != nil { t.Fatalf("copy v1 fixture: %v", err) } p := filepath.Join(dst, "App.mpr") diff --git a/docs/plans/2026-09-14-retire-legacy-engine.md b/docs/plans/2026-09-14-retire-legacy-engine.md index 4df3b430d3..22c2c6f4c1 100644 --- a/docs/plans/2026-09-14-retire-legacy-engine.md +++ b/docs/plans/2026-09-14-retire-legacy-engine.md @@ -640,3 +640,35 @@ existed to close — a caller holding a concrete reader is invisible to the cens **Phase 4a is complete.** `sdk/mpr` has no importers outside itself; deleting it is now a scheduling decision rather than a risk assessment. + +### The package is gone (2026-09-16) + +`sdk/mpr` deleted: **163 files, 41,674 lines**. The plan is finished. + +**Zero importers was not the same as safe to `rm -rf`.** Two live dependencies survived, and +neither is visible to a check written against the parent package's import path: + +- **`sdk/mpr/version` had six importers**, two of them shipping code (`cmd/mxcli/docker/build.go`, + `patch.go`). A subpackage is a *different* import path. +- **`cmd/mxcli/docker/update_widgets_test.go` read `sdk/mpr/testdata/v1-project` by filesystem + path** — an `os.DirFS` string, not an import at all. + +> Before deleting a package, search for **three** things: its own import path, its subpackages' +> paths, and its directory as a literal string (testdata, `go:embed`, scripts). The last two are +> invisible to any importer census. + +The six went to **`mdl/types`**, not to `modelsdk/mpr/version`: `sdk/mpr/version.ProjectVersion` is +`type ProjectVersion = types.ProjectVersion`, an **alias**, so `types.ProjectVersion` is the same +type — while `modelsdk/mpr/version` declares a *duplicate struct* that would have been a different +one. §7.9's trap, avoided by reading the declaration instead of the name. Everything was repointed +and proven green **with the package still present**, which is what separates "the repoint was +wrong" from "the deletion was wrong". + +Two measurements worth keeping. The shipped binary is **identical in size** before and after, so the +linker had already dropped the package — this removes source weight, not runtime behaviour. And +`sdk/widgets` fell to zero importers as a side effect but is **deliberately kept**: +`modelsdk/widgets/dirty_template_test.go` reads `sdk/widgets/templates/mendix-11.6` by path. Same +trap, caught by grepping the directory name rather than the import. + +The import guard from the previous slice is **removed**: with the package gone, an import is a +compile error, which is strictly stronger than a test asserting the same thing. diff --git a/mdl/backend/sdkmpr_import_guard_test.go b/mdl/backend/sdkmpr_import_guard_test.go deleted file mode 100644 index 1f2f03ddcb..0000000000 --- a/mdl/backend/sdkmpr_import_guard_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package backend_test - -// Phase 4a of docs/plans/2026-09-14-retire-legacy-engine.md took sdk/mpr from 27 -// importers to 0. This keeps it there. -// -// Without a guard the count creeps back one import at a time, and the reason it -// matters is not tidiness: sdk/mpr is the legacy engine, and a caller holding a -// concrete *sdk/mpr.Reader is INVISIBLE to the unimplemented-method census in -// mdl/backend/modelsdk (that census lists methods with no implementation; a -// caller reaching one through a concrete reader never appears). That blind spot -// is what hid project_tree.go's 36 semantic reads and cmd_extract_templates.go's -// FindCustomWidgetType until each was found by hand. -// -// Modelled on scripts/check-tunnel-deps.sh: assert a positive control first, so -// a scan that silently examined nothing cannot pass. - -import ( - "go/parser" - "go/token" - "os" - "path/filepath" - "strings" - "testing" -) - -const ( - legacyEngine = `"github.com/mendixlabs/mxcli/sdk/mpr"` - // The backend abstraction — something the repo definitely imports widely. - // Used only as the detector's positive control. - backendPkg = `"github.com/mendixlabs/mxcli/mdl/backend"` -) - -func TestNothingImportsTheLegacyEngine(t *testing.T) { - root := repoRoot(t) - - var offenders []string - var scanned, sawControl int - - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - switch info.Name() { - case ".git", "node_modules", "reference", "vendor": - return filepath.SkipDir - } - // sdk/mpr's own files are the package itself, not importers of it. - if filepath.ToSlash(strings.TrimPrefix(path, root)) == "/sdk/mpr" { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(path, ".go") { - return nil - } - f, perr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) - if perr != nil { - // Generated parser sources can be enormous but still parse; a file that - // does not is not something to fail the guard on, so it is skipped - // loudly rather than silently. - t.Logf("skipping unparseable %s: %v", path, perr) - return nil - } - scanned++ - rel := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, root)), "/") - for _, imp := range f.Imports { - switch imp.Path.Value { - case legacyEngine: - offenders = append(offenders, rel) - case backendPkg: - sawControl++ - } - } - return nil - }) - if err != nil { - t.Fatalf("walk: %v", err) - } - - // Positive controls. Without these a broken walk, a wrong root or an - // import-parsing mistake reports "0 importers" and reads as success. - if scanned < 500 { - t.Fatalf("scanned only %d Go files — the walk is not covering the repo, "+ - "so a clean result here would mean nothing", scanned) - } - if sawControl == 0 { - t.Fatalf("scanned %d files and saw no import of %s — the detector cannot "+ - "see imports at all, so it cannot see sdk/mpr either", scanned, backendPkg) - } - - if len(offenders) > 0 { - t.Errorf("%d file(s) import the legacy engine sdk/mpr, which Phase 4a "+ - "emptied:\n %s\n\nUse mdl/backend (backend.FullBackend) instead. If a "+ - "method you need is missing there, implement it on the codec backend "+ - "rather than reaching past the abstraction — a concrete reader is "+ - "invisible to the unimplemented-method census.", - len(offenders), strings.Join(offenders, "\n ")) - } - t.Logf("scanned %d Go files, %d import mdl/backend, 0 import sdk/mpr", scanned, sawControl) -} - -// repoRoot walks up from the test's directory to the module root. -func repoRoot(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatal("no go.mod found above the test directory") - } - dir = parent - } -} diff --git a/mdl/executor/doctype_version_gating_test.go b/mdl/executor/doctype_version_gating_test.go index 1ccbbda18a..e6f03a8d76 100644 --- a/mdl/executor/doctype_version_gating_test.go +++ b/mdl/executor/doctype_version_gating_test.go @@ -10,15 +10,15 @@ import ( "strings" "testing" + mxversion "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/mdl/visitor" - "github.com/mendixlabs/mxcli/sdk/mpr/version" ) // nightlyMatrix is the Mendix version set .github/workflows/nightly.yml runs the // doctype scripts against. A script that only parses on the newest of them is a // nightly failure on the others, reported hours later against whatever landed in // between — which is how the DecimalScale gating below was found. -var nightlyMatrix = []*version.ProjectVersion{ +var nightlyMatrix = []*mxversion.ProjectVersion{ {MajorVersion: 10, MinorVersion: 24, ProductVersion: "10.24.24.119349"}, {MajorVersion: 11, MinorVersion: 6, ProductVersion: "11.6.8"}, {MajorVersion: 11, MinorVersion: 12, ProductVersion: "11.12.2"}, diff --git a/mdl/executor/version_filter_test.go b/mdl/executor/version_filter_test.go index 6afeeb6609..e68c952f77 100644 --- a/mdl/executor/version_filter_test.go +++ b/mdl/executor/version_filter_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) func TestParseVersionDirective(t *testing.T) { @@ -48,13 +48,13 @@ func TestParseVersionDirective(t *testing.T) { } func TestVersionConstraintMatches(t *testing.T) { - mx1024 := &version.ProjectVersion{MajorVersion: 10, MinorVersion: 24} - mx110 := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 0} - mx116 := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 6} + mx1024 := &mxversion.ProjectVersion{MajorVersion: 10, MinorVersion: 24} + mx110 := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 0} + mx116 := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 6} tests := []struct { constraint string - pv *version.ProjectVersion + pv *mxversion.ProjectVersion want bool }{ // min only: 11.0+ @@ -95,8 +95,8 @@ create view entity Test.MyView (...); create entity Test.Universal (...); ` - mx1024 := &version.ProjectVersion{MajorVersion: 10, MinorVersion: 24, ProductVersion: "10.24.0"} - mx116 := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 6, ProductVersion: "11.6.0"} + mx1024 := &mxversion.ProjectVersion{MajorVersion: 10, MinorVersion: 24, ProductVersion: "10.24.0"} + mx116 := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 6, ProductVersion: "11.6.0"} // On 10.24: VIEW ENTITY line should be stripped filtered1024, skipped1024 := filterByVersion(content, mx1024) diff --git a/sdk/mpr/testdata/v1-project/App.mpr b/modelsdk/mpr/testdata/v1-project/App.mpr similarity index 100% rename from sdk/mpr/testdata/v1-project/App.mpr rename to modelsdk/mpr/testdata/v1-project/App.mpr diff --git a/sdk/mpr/asyncapi.go b/sdk/mpr/asyncapi.go deleted file mode 100644 index 2bedd79071..0000000000 --- a/sdk/mpr/asyncapi.go +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" -) - -// Type aliases — all AsyncAPI types now live in mdl/types. -type AsyncAPIDocument = types.AsyncAPIDocument -type AsyncAPIChannel = types.AsyncAPIChannel -type AsyncAPIMessage = types.AsyncAPIMessage -type AsyncAPIProperty = types.AsyncAPIProperty - -// ParseAsyncAPI delegates to types.ParseAsyncAPI. -func ParseAsyncAPI(yamlStr string) (*AsyncAPIDocument, error) { - return types.ParseAsyncAPI(yamlStr) -} diff --git a/sdk/mpr/asyncapi_test.go b/sdk/mpr/asyncapi_test.go deleted file mode 100644 index aa03aa601e..0000000000 --- a/sdk/mpr/asyncapi_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -const testAsyncAPIYAML = `asyncapi: 2.2.0 -info: - title: "ShopEventsSvc" - version: "1.0.0" - description: "Shop events for order processing" -channels: - c79d2901578f4ddab69688bde6eaf98c: - subscribe: - operationId: receiveOrderChangedEventEvents - message: - $ref: '#/components/messages/OrderChangedEvent' - abc123: - publish: - operationId: sendProductUpdatedEvents - message: - $ref: '#/components/messages/ProductUpdated' -components: - messages: - OrderChangedEvent: - name: OrderChangedEvent - title: OrderChangedEvent event - description: "Fired when an order changes" - contentType: application/json - payload: - $ref: '#/components/schemas/OrderChangedEventPayload' - ProductUpdated: - name: ProductUpdated - title: Product Updated - description: "" - contentType: application/json - payload: - $ref: '#/components/schemas/ProductUpdatedPayload' - schemas: - OrderChangedEventPayload: - type: object - properties: - OrderId: - type: integer - format: int64 - CustomerId: - type: integer - format: int64 - ProductUpdatedPayload: - type: object - properties: - ProductName: - type: string - Price: - type: number - format: double - InStock: - type: boolean -defaultContentType: application/json -` - -func TestParseAsyncAPI(t *testing.T) { - doc, err := ParseAsyncAPI(testAsyncAPIYAML) - if err != nil { - t.Fatalf("ParseAsyncAPI failed: %v", err) - } - - if doc.Version != "2.2.0" { - t.Errorf("expected version 2.2.0, got %s", doc.Version) - } - if doc.Title != "ShopEventsSvc" { - t.Errorf("expected title ShopEventsSvc, got %s", doc.Title) - } - if doc.Description != "Shop events for order processing" { - t.Errorf("expected description, got %q", doc.Description) - } - - // Check channels - if len(doc.Channels) != 2 { - t.Fatalf("expected 2 channels, got %d", len(doc.Channels)) - } - - var subChannel, pubChannel *AsyncAPIChannel - for _, ch := range doc.Channels { - if ch.OperationType == "subscribe" { - subChannel = ch - } else if ch.OperationType == "publish" { - pubChannel = ch - } - } - - if subChannel == nil { - t.Fatal("subscribe channel not found") - } - if subChannel.MessageRef != "OrderChangedEvent" { - t.Errorf("expected message ref OrderChangedEvent, got %s", subChannel.MessageRef) - } - if subChannel.OperationID != "receiveOrderChangedEventEvents" { - t.Errorf("expected operationId receiveOrderChangedEventEvents, got %s", subChannel.OperationID) - } - - if pubChannel == nil { - t.Fatal("publish channel not found") - } - if pubChannel.MessageRef != "ProductUpdated" { - t.Errorf("expected message ref ProductUpdated, got %s", pubChannel.MessageRef) - } - - // Check messages - if len(doc.Messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(doc.Messages)) - } - - orderMsg := doc.FindMessage("OrderChangedEvent") - if orderMsg == nil { - t.Fatal("OrderChangedEvent message not found") - } - if orderMsg.Description != "Fired when an order changes" { - t.Errorf("expected description, got %q", orderMsg.Description) - } - if len(orderMsg.Properties) != 2 { - t.Fatalf("expected 2 properties, got %d", len(orderMsg.Properties)) - } - - // Check property resolution - var orderIdProp *AsyncAPIProperty - for _, p := range orderMsg.Properties { - if p.Name == "OrderId" { - orderIdProp = p - break - } - } - if orderIdProp == nil { - t.Fatal("OrderId property not found") - } - if orderIdProp.Type != "integer" { - t.Errorf("expected type integer, got %s", orderIdProp.Type) - } - if orderIdProp.Format != "int64" { - t.Errorf("expected format int64, got %s", orderIdProp.Format) - } - - // Check ProductUpdated message - prodMsg := doc.FindMessage("ProductUpdated") - if prodMsg == nil { - t.Fatal("ProductUpdated message not found") - } - if len(prodMsg.Properties) != 3 { - t.Fatalf("expected 3 properties, got %d", len(prodMsg.Properties)) - } -} - -func TestParseAsyncAPIEmpty(t *testing.T) { - _, err := ParseAsyncAPI("") - if err == nil { - t.Error("expected error for empty document") - } -} diff --git a/sdk/mpr/bson_testutil_test.go b/sdk/mpr/bson_testutil_test.go deleted file mode 100644 index a3d932c8a6..0000000000 --- a/sdk/mpr/bson_testutil_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "go.mongodb.org/mongo-driver/bson" - -// dToMap recursively converts an ordered bson.D (and any nested bson.D/bson.A) -// into the unordered bson.M / map form that several serializer tests assert -// against by key. The writer now emits ordered bson.D values (so that "$ID" is -// the first property, required by Mendix 11.12+); these tests only care about -// field presence and values, not order, so converting to a map keeps them valid. -// -// Order-sensitivity itself ("$ID" first) is covered separately in -// writer_id_order_test.go. -func dToMap(v any) any { - switch t := v.(type) { - case bson.D: - m := bson.M{} - for _, e := range t { - m[e.Key] = dToMap(e.Value) - } - return m - case bson.A: - out := make(bson.A, len(t)) - for i, e := range t { - out[i] = dToMap(e) - } - return out - default: - return v - } -} - -// dToM is a convenience wrapper that converts a bson.D storage object to bson.M. -func dToM(d bson.D) bson.M { - return dToMap(d).(bson.M) -} diff --git a/sdk/mpr/domainmodel_annotation_test.go b/sdk/mpr/domainmodel_annotation_test.go deleted file mode 100644 index 06788bcbfa..0000000000 --- a/sdk/mpr/domainmodel_annotation_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// A domain model can hold annotations — the note boxes Studio Pro draws on the -// canvas to explain the diagram. Every blank Mendix app ships with one, and -// modellers add more to group and label a large model. -// -// The legacy writer hardcoded `Annotations` to an empty typed array, so ANY -// rewrite of a domain model deleted every note in it: adding one entity to the -// blank app's MyFirstModule took its annotation count from 1 to 0 and the -// caption disappeared from the project. `mx check` reports 0 errors, because an -// annotation is decorative — nothing below Studio Pro can see the loss. -// -// The parser had a second, quieter defect: it read `Location` only as a BSON -// sub-document, while Studio Pro stores it as the string "x;y" (measured on a -// stock 11.13.0 app). So even before the write threw them away, the read had -// already lost every position, and Width was never read at all. -// -// This is the same guard-don't-drop rule as ADR-0005: what MDL cannot express, a -// rewrite carries. - -// storedAnnotation is the shape Studio Pro writes, taken from the annotation in -// a blank 11.13.0 app's MyFirstModule. -func storedAnnotation() map[string]any { - return map[string]any{ - "$Type": "DomainModels$Annotation", - "Caption": "This Domain model defines the data structure of this module.\r\n\r\nMore info: https://docs.mendix.com/refguide/domain-model", - "ExportLevel": "Hidden", - "Location": "60;240", - "Width": int32(440), - } -} - -// The position is a string, not a sub-document. Reading only the sub-document -// form silently returned (0,0) for every real annotation. -func TestParseAnnotationReadsStringLocationAndWidth(t *testing.T) { - got := parseAnnotation(storedAnnotation()) - - if got.Caption == "" { - t.Fatal("Caption is empty") - } - if got.Location.X != 60 || got.Location.Y != 240 { - t.Errorf("Location = (%d,%d), want (60,240) — Studio Pro stores it as the string \"x;y\"", - got.Location.X, got.Location.Y) - } - if got.Width != 440 { - t.Errorf("Width = %d, want 440", got.Width) - } -} - -// The sub-document form is accepted too, exactly as the entity parser does: a -// document written by an older mxcli must still read. -func TestParseAnnotationStillReadsMapLocation(t *testing.T) { - raw := storedAnnotation() - raw["Location"] = map[string]any{"x": int32(12), "y": int32(34)} - - got := parseAnnotation(raw) - if got.Location.X != 12 || got.Location.Y != 34 { - t.Errorf("Location = (%d,%d), want (12,34)", got.Location.X, got.Location.Y) - } -} - -// The write must carry what the read produced. Before this, a domain model -// rewrite emitted `Annotations: [3]` — the empty typed array — regardless. -func TestSerializeAnnotationRoundTrips(t *testing.T) { - annot := &domainmodel.Annotation{ - Caption: "Orders live here", - Location: model.Point{X: 60, Y: 240}, - Width: 440, - } - annot.ID = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" - - got := parseAnnotation(dToM(serializeDomainModelAnnotation(annot))) - - if got.Caption != annot.Caption { - t.Errorf("Caption = %q, want %q", got.Caption, annot.Caption) - } - if got.Location != annot.Location { - t.Errorf("Location = %+v, want %+v", got.Location, annot.Location) - } - if got.Width != annot.Width { - t.Errorf("Width = %d, want %d", got.Width, annot.Width) - } - if got.ID != annot.ID { - t.Errorf("ID = %q, want the stored %q (a real UUID: idToBsonBinary cannot round-trip a made-up string) — a fresh one makes an unchanged model differ (ADR-0008)", - got.ID, annot.ID) - } -} - -// The serialized shape must match what Studio Pro writes, key for key: the -// position as the string "x;y", and ExportLevel present. A sub-document position -// is what the parser used to expect and is NOT what Mendix stores. -func TestSerializeAnnotationMatchesStudioProShape(t *testing.T) { - annot := &domainmodel.Annotation{Caption: "x", Location: model.Point{X: 60, Y: 240}, Width: 440} - annot.ID = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" - - got := dToM(serializeDomainModelAnnotation(annot)) - - if got["$Type"] != "DomainModels$Annotation" { - t.Errorf("$Type = %v", got["$Type"]) - } - if loc, ok := got["Location"].(string); !ok || loc != "60;240" { - t.Errorf("Location = %#v, want the string \"60;240\"", got["Location"]) - } - if got["ExportLevel"] != "Hidden" { - t.Errorf("ExportLevel = %v, want Hidden — every Studio Pro annotation carries it", got["ExportLevel"]) - } -} diff --git a/sdk/mpr/download_file_test.go b/sdk/mpr/download_file_test.go deleted file mode 100644 index e18937af9f..0000000000 --- a/sdk/mpr/download_file_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestDownloadFileAction_Roundtrip(t *testing.T) { - action := µflows.DownloadFileAction{ - BaseElement: model.BaseElement{ID: "download-action-id"}, - ErrorHandlingType: microflows.ErrorHandlingTypeContinue, - FileDocument: "GeneratedReport", - ShowInBrowser: true, - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - parsed := parseDownloadFileAction(raw) - if parsed.ErrorHandlingType != microflows.ErrorHandlingTypeContinue { - t.Fatalf("ErrorHandlingType = %q, want Continue", parsed.ErrorHandlingType) - } - if parsed.FileDocument != "GeneratedReport" { - t.Fatalf("FileDocument = %q, want GeneratedReport", parsed.FileDocument) - } - if !parsed.ShowInBrowser { - t.Fatal("ShowInBrowser = false, want true") - } -} - -func TestParseDownloadFileAction_DefaultsErrorHandlingToRollback(t *testing.T) { - action := parseDownloadFileAction(map[string]any{ - "$ID": "download-action-id", - "FileDocumentVariableName": "GeneratedReport", - "ShowInBrowser": false, - }) - - if action.ErrorHandlingType != microflows.ErrorHandlingTypeRollback { - t.Fatalf("ErrorHandlingType = %q, want Rollback", action.ErrorHandlingType) - } -} diff --git a/sdk/mpr/edmx.go b/sdk/mpr/edmx.go deleted file mode 100644 index d407b08b72..0000000000 --- a/sdk/mpr/edmx.go +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" -) - -// Type aliases — all EDMX types now live in mdl/types. -type EdmxDocument = types.EdmxDocument -type EdmSchema = types.EdmSchema -type EdmEntityType = types.EdmEntityType -type EdmProperty = types.EdmProperty -type EdmNavigationProperty = types.EdmNavigationProperty -type EdmEntitySet = types.EdmEntitySet -type EdmAction = types.EdmAction -type EdmActionParameter = types.EdmActionParameter -type EdmEnumType = types.EdmEnumType -type EdmEnumMember = types.EdmEnumMember - -// ParseEdmx delegates to types.ParseEdmx. -func ParseEdmx(metadataXML string) (*EdmxDocument, error) { - return types.ParseEdmx(metadataXML) -} - -// resolveNavType delegates to types.ResolveNavType (kept for test compatibility). -func resolveNavType(t string) (string, bool) { - return types.ResolveNavType(t) -} diff --git a/sdk/mpr/edmx_test.go b/sdk/mpr/edmx_test.go deleted file mode 100644 index 3be5d4abfd..0000000000 --- a/sdk/mpr/edmx_test.go +++ /dev/null @@ -1,409 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -const testOData3Metadata = ` - - - - - - SAP Purchase Order - Provides access to Purchase Order information from SAP - - - - - - - - - - - - - - - - - - - - - - - - - - - -` - -const testOData4Metadata = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -` - -func TestParseEdmxOData3(t *testing.T) { - doc, err := ParseEdmx(testOData3Metadata) - if err != nil { - t.Fatalf("ParseEdmx failed: %v", err) - } - - if doc.Version != "1.0" { - t.Errorf("expected version 1.0, got %s", doc.Version) - } - - if len(doc.Schemas) != 1 { - t.Fatalf("expected 1 schema, got %d", len(doc.Schemas)) - } - - schema := doc.Schemas[0] - if schema.Namespace != "DefaultNamespace" { - t.Errorf("expected namespace DefaultNamespace, got %s", schema.Namespace) - } - - if len(schema.EntityTypes) != 2 { - t.Fatalf("expected 2 entity types, got %d", len(schema.EntityTypes)) - } - - // Check PurchaseOrder - po := schema.EntityTypes[0] - if po.Name != "PurchaseOrder" { - t.Errorf("expected PurchaseOrder, got %s", po.Name) - } - if po.Summary != "SAP Purchase Order" { - t.Errorf("expected summary 'SAP Purchase Order', got '%s'", po.Summary) - } - if len(po.KeyProperties) != 1 || po.KeyProperties[0] != "ID" { - t.Errorf("expected key [ID], got %v", po.KeyProperties) - } - if len(po.Properties) != 6 { - t.Errorf("expected 6 properties, got %d", len(po.Properties)) - } - if len(po.NavigationProperties) != 2 { - t.Errorf("expected 2 nav properties, got %d", len(po.NavigationProperties)) - } - - // Check SupplierName property - var supplierProp *EdmProperty - for _, p := range po.Properties { - if p.Name == "SupplierName" { - supplierProp = p - break - } - } - if supplierProp == nil { - t.Fatal("SupplierName property not found") - } - if supplierProp.Type != "Edm.String" { - t.Errorf("expected Edm.String, got %s", supplierProp.Type) - } - if supplierProp.MaxLength != "200" { - t.Errorf("expected MaxLength 200, got %s", supplierProp.MaxLength) - } - - // Check entity sets - if len(doc.EntitySets) != 2 { - t.Fatalf("expected 2 entity sets, got %d", len(doc.EntitySets)) - } - if doc.EntitySets[0].Name != "PurchaseOrders" { - t.Errorf("expected PurchaseOrders, got %s", doc.EntitySets[0].Name) - } - - // Check FindEntityType - found := doc.FindEntityType("DefaultNamespace.Customer") - if found == nil { - t.Error("FindEntityType('DefaultNamespace.Customer') returned nil") - } - if found != nil && found.Name != "Customer" { - t.Errorf("expected Customer, got %s", found.Name) - } -} - -func TestParseEdmxOData4(t *testing.T) { - doc, err := ParseEdmx(testOData4Metadata) - if err != nil { - t.Fatalf("ParseEdmx failed: %v", err) - } - - if doc.Version != "4.0" { - t.Errorf("expected version 4.0, got %s", doc.Version) - } - - schema := doc.Schemas[0] - - // Check Product entity - product := schema.EntityTypes[0] - if product.Name != "Product" { - t.Errorf("expected Product, got %s", product.Name) - } - if product.Summary != "Product Inventory" { - t.Errorf("expected summary 'Product Inventory', got '%s'", product.Summary) - } - - // Check navigation property with Collection type - if len(product.NavigationProperties) != 1 { - t.Fatalf("expected 1 nav property, got %d", len(product.NavigationProperties)) - } - nav := product.NavigationProperties[0] - if nav.Name != "Parts" { - t.Errorf("expected Parts, got %s", nav.Name) - } - if nav.TargetType != "Part" { - t.Errorf("expected target type Part, got %s", nav.TargetType) - } - if !nav.IsMany { - t.Error("expected IsMany=true for Collection type") - } - - // Check Part navigation property (single) - part := schema.EntityTypes[1] - partNav := part.NavigationProperties[0] - if partNav.TargetType != "Product" { - t.Errorf("expected target type Product, got %s", partNav.TargetType) - } - if partNav.IsMany { - t.Error("expected IsMany=false for single type") - } - - // Check actions - if len(doc.Actions) != 2 { - t.Fatalf("expected 2 actions, got %d", len(doc.Actions)) - } - createOrder := doc.Actions[0] - if createOrder.Name != "CreateOrder" { - t.Errorf("expected CreateOrder, got %s", createOrder.Name) - } - if len(createOrder.Parameters) != 1 { - t.Errorf("expected 1 parameter, got %d", len(createOrder.Parameters)) - } - if createOrder.ReturnType != "DefaultNamespace.OrderResult" { - t.Errorf("expected return type DefaultNamespace.OrderResult, got %s", createOrder.ReturnType) - } - - // Check function - getTop := doc.Actions[1] - if getTop.Name != "GetTopProducts" { - t.Errorf("expected GetTopProducts, got %s", getTop.Name) - } - if getTop.ReturnType != "Collection(DefaultNamespace.Product)" { - t.Errorf("expected return type Collection(DefaultNamespace.Product), got %s", getTop.ReturnType) - } - - // Check entity sets - if len(doc.EntitySets) != 2 { - t.Fatalf("expected 2 entity sets, got %d", len(doc.EntitySets)) - } -} - -func TestParseEdmxEmpty(t *testing.T) { - _, err := ParseEdmx("") - if err == nil { - t.Error("expected error for empty metadata") - } -} - -const testCapabilitiesMetadata = ` - - - - - - - - - - - - - - - - - - - - - - - - - OrderId - - - - - Lines - - - - - - - - - - OrderId - OrderNumber - - - - - - - - - - - - -` - -func TestParseEdmxCapabilityAnnotations(t *testing.T) { - doc, err := ParseEdmx(testCapabilitiesMetadata) - if err != nil { - t.Fatalf("ParseEdmx failed: %v", err) - } - - // Find the Orders entity set. - var orders *EdmEntitySet - for _, es := range doc.EntitySets { - if es.Name == "Orders" { - orders = es - } - } - if orders == nil { - t.Fatal("Orders entity set not found") - } - - if orders.Insertable == nil || !*orders.Insertable { - t.Errorf("Orders.Insertable = %v, want true", orders.Insertable) - } - if orders.Updatable == nil || !*orders.Updatable { - t.Errorf("Orders.Updatable = %v, want true", orders.Updatable) - } - if orders.Deletable == nil || !*orders.Deletable { - t.Errorf("Orders.Deletable = %v, want true", orders.Deletable) - } - - wantNonIns := []string{"OrderId"} - if !stringSliceEqual(orders.NonInsertableProperties, wantNonIns) { - t.Errorf("NonInsertableProperties = %v, want %v", orders.NonInsertableProperties, wantNonIns) - } - wantNonUpd := []string{"OrderId", "OrderNumber"} - if !stringSliceEqual(orders.NonUpdatableProperties, wantNonUpd) { - t.Errorf("NonUpdatableProperties = %v, want %v", orders.NonUpdatableProperties, wantNonUpd) - } - wantNonInsNav := []string{"Lines"} - if !stringSliceEqual(orders.NonInsertableNavigationProperties, wantNonInsNav) { - t.Errorf("NonInsertableNavigationProperties = %v, want %v", orders.NonInsertableNavigationProperties, wantNonInsNav) - } - - // OrderLines has no annotations → all flags unset. - var lines *EdmEntitySet - for _, es := range doc.EntitySets { - if es.Name == "OrderLines" { - lines = es - } - } - if lines == nil { - t.Fatal("OrderLines entity set not found") - } - if lines.Insertable != nil || lines.Updatable != nil || lines.Deletable != nil { - t.Errorf("OrderLines should have nil capability flags, got Insertable=%v Updatable=%v Deletable=%v", - lines.Insertable, lines.Updatable, lines.Deletable) - } - - // Per-property Computed/Immutable annotations. - order := doc.FindEntityType("DefaultNamespace.Order") - if order == nil { - t.Fatal("Order entity type not found") - } - propByName := map[string]*EdmProperty{} - for _, p := range order.Properties { - propByName[p.Name] = p - } - if !propByName["OrderId"].Computed { - t.Errorf("OrderId.Computed = false, want true") - } - if !propByName["OrderNumber"].Immutable { - t.Errorf("OrderNumber.Immutable = false, want true") - } - if propByName["CustomerName"].Computed || propByName["CustomerName"].Immutable { - t.Errorf("CustomerName should have no capability flags") - } -} - -func stringSliceEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func TestResolveNavType(t *testing.T) { - tests := []struct { - input string - wantType string - wantMany bool - }{ - {"DefaultNamespace.Product", "Product", false}, - {"Collection(DefaultNamespace.Part)", "Part", true}, - {"Edm.String", "String", false}, - {"Product", "Product", false}, - } - - for _, tt := range tests { - typeName, isMany := resolveNavType(tt.input) - if typeName != tt.wantType { - t.Errorf("resolveNavType(%q): got type %q, want %q", tt.input, typeName, tt.wantType) - } - if isMany != tt.wantMany { - t.Errorf("resolveNavType(%q): got isMany=%v, want %v", tt.input, isMany, tt.wantMany) - } - } -} diff --git a/sdk/mpr/get_raw_unit_v1_test.go b/sdk/mpr/get_raw_unit_v1_test.go deleted file mode 100644 index 4e83d45576..0000000000 --- a/sdk/mpr/get_raw_unit_v1_test.go +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" -) - -// TestGetRawUnit_V1 verifies that GetRawUnit works on v1 MPR files (Mendix < 10.18) -// where UnitID is stored as a 16-byte GUID blob in SQLite. -// Regression test for https://github.com/mendixlabs/mxcli/issues/705 -func TestGetRawUnit_V1(t *testing.T) { - mprPath := "testdata/v1-project/App.mpr" - - reader, err := Open(mprPath) - if err != nil { - t.Fatalf("failed to open v1 MPR: %v", err) - } - defer reader.Close() - - if reader.Version() != MPRVersionV1 { - t.Fatalf("expected MPR v1, got v%d", reader.Version()) - } - - // ListAllUnitIDs works correctly (uses blobToUUID internally) - ids, err := reader.ListAllUnitIDs() - if err != nil { - t.Fatalf("ListAllUnitIDs: %v", err) - } - if len(ids) == 0 { - t.Fatal("expected at least one unit ID") - } - - // GetRawUnit must be able to retrieve any unit by the ID that ListAllUnitIDs returns. - // Before the fix, this always returned "no rows in result set" on v1 MPRs. - for _, id := range ids { - raw, err := reader.GetRawUnit(model.ID(id)) - if err != nil { - t.Errorf("GetRawUnit(%s): %v", id, err) - continue - } - if raw == nil { - t.Errorf("GetRawUnit(%s): returned nil map", id) - continue - } - if _, ok := raw["$Type"]; !ok { - t.Errorf("GetRawUnit(%s): BSON missing $Type field", id) - } - } -} diff --git a/sdk/mpr/inheritance_roundtrip_test.go b/sdk/mpr/inheritance_roundtrip_test.go deleted file mode 100644 index b339c94b18..0000000000 --- a/sdk/mpr/inheritance_roundtrip_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestBuildSequenceFlowCase_InheritanceCase(t *testing.T) { - doc := buildSequenceFlowCase(µflows.InheritanceCase{ - BaseElement: model.BaseElement{ID: "case-1"}, - EntityQualifiedName: "Sample.SpecializedInput", - }) - - if got := bsonGetKey(doc, "$Type"); got != "Microflows$InheritanceCase" { - t.Fatalf("$Type = %v, want Microflows$InheritanceCase", got) - } - if got := bsonGetKey(doc, "Value"); got != "Sample.SpecializedInput" { - t.Fatalf("Value = %v, want Sample.SpecializedInput", got) - } -} - -func TestSerializeMicroflowObject_InheritanceSplit(t *testing.T) { - doc := serializeMicroflowObject(µflows.InheritanceSplit{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "split-1"}, - Position: model.Point{X: 100, Y: 200}, - Size: model.Size{Width: 120, Height: 60}, - }, - VariableName: "Input", - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - }) - - if got := bsonGetKey(doc, "$Type"); got != "Microflows$InheritanceSplit" { - t.Fatalf("$Type = %v, want Microflows$InheritanceSplit", got) - } - if got := bsonGetKey(doc, "SplitVariableName"); got != "Input" { - t.Fatalf("SplitVariableName = %v, want Input", got) - } -} - -func TestCastAction_RoundtripVariableName(t *testing.T) { - action := µflows.CastAction{ - BaseElement: model.BaseElement{ID: "cast-1"}, - OutputVariable: "SpecificInput", - } - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal cast action: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal cast action: %v", err) - } - - parsed := parseCastAction(raw) - if parsed.OutputVariable != "SpecificInput" { - t.Fatalf("OutputVariable = %q, want SpecificInput", parsed.OutputVariable) - } -} - -// TestSerializeCastAction_UsesVariableNameFieldKey pins the BSON field key -// Studio Pro emits for Microflows$CastAction. Empirical evidence (BSON -// dump of the Control Centre app on Mendix 9.24): Studio Pro stores the -// output variable under "VariableName", not "OutputVariableName". The -// parser falls back to "VariableName" when "OutputVariableName" is -// absent so projects authored by Studio Pro still parse cleanly; the -// writer must match Studio Pro's authored shape so projects we produce -// open without surprises. -func TestSerializeCastAction_UsesVariableNameFieldKey(t *testing.T) { - action := µflows.CastAction{ - BaseElement: model.BaseElement{ID: "cast-1"}, - OutputVariable: "SpecificInput", - } - doc := serializeMicroflowAction(action) - if got := bsonGetKey(doc, "VariableName"); got != "SpecificInput" { - t.Fatalf("VariableName = %v, want SpecificInput", got) - } - if got := bsonGetKey(doc, "OutputVariableName"); got != nil { - t.Fatalf("OutputVariableName = %v, want absent (Studio Pro uses VariableName)", got) - } -} - -// TestBuildSequenceFlowCase_InheritanceCase_UsesValueFieldKey pins the -// BSON field key for Microflows$InheritanceCase. Empirical evidence -// (BSON dump of the Control Centre app, Mendix 9.24): the entity -// reference is stored under "Value" as a qualified-name string -// (e.g. "Administration.Account"), not "Entity". The parser falls back -// to "Entity" for forward compatibility; the writer must emit "Value" -// so output matches Studio Pro's authored shape. -func TestBuildSequenceFlowCase_InheritanceCase_UsesValueFieldKey(t *testing.T) { - doc := buildSequenceFlowCase(µflows.InheritanceCase{ - BaseElement: model.BaseElement{ID: "case-1"}, - EntityQualifiedName: "Sample.SpecializedInput", - }) - if got := bsonGetKey(doc, "Value"); got != "Sample.SpecializedInput" { - t.Fatalf("Value = %v, want Sample.SpecializedInput", got) - } - if got := bsonGetKey(doc, "Entity"); got != nil { - t.Fatalf("Entity = %v, want absent (Studio Pro uses Value)", got) - } -} diff --git a/sdk/mpr/javaactions_microflowactioninfo_656_test.go b/sdk/mpr/javaactions_microflowactioninfo_656_test.go deleted file mode 100644 index f0f139c1ea..0000000000 --- a/sdk/mpr/javaactions_microflowactioninfo_656_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func maiField(d bson.D, key string) (any, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// TestMicroflowActionInfoBSON_HealthyShape asserts the writer emits the current -// metamodel shape — CodeActions$ type, all four icon/image bitmaps present as -// non-null binaries, and no obsolete Icon key — even when every bitmap is empty. -// A null or absent ImageData crashes Studio Pro's UnitWriter (issue #656). -func TestMicroflowActionInfoBSON_HealthyShape(t *testing.T) { - d := microflowActionInfoBSON(&javaactions.MicroflowActionInfo{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Caption: "My Action", - Category: "My Category", - }) - - if v, _ := maiField(d, "$Type"); v != "CodeActions$MicroflowActionInfo" { - t.Errorf("$Type = %v, want CodeActions$MicroflowActionInfo", v) - } - if _, ok := maiField(d, "Icon"); ok { - t.Error("obsolete Icon key must not be emitted") - } - for _, key := range []string{"IconData", "IconDataDark", "ImageData", "ImageDataDark"} { - v, ok := maiField(d, key) - if !ok { - t.Errorf("%s missing — must always be present", key) - continue - } - bin, isBin := v.(primitive.Binary) - if !isBin { - t.Errorf("%s = %T, want primitive.Binary (never null/string)", key, v) - continue - } - if bin.Data == nil { - t.Errorf("%s.Data is nil, want empty (non-null) binary", key) - } - } -} - -// TestParseMicroflowActionInfo_ToleratesLegacyShape asserts the parser reads the -// broken legacy shape (JavaActions$ type, Icon string, null ImageData) without -// error, and that re-serializing the result yields the healthy shape — i.e. a -// corrupted unit loads and self-repairs on rewrite (issue #656). -func TestParseMicroflowActionInfo_ToleratesLegacyShape(t *testing.T) { - legacy := map[string]any{ - "$ID": primitive.Binary{Subtype: 0, Data: make([]byte, 16)}, - "$Type": "JavaActions$MicroflowActionInfo", - "Caption": "Old Action", - "Category": "Old Category", - "Icon": "", // obsolete string key - "ImageData": nil, // the crash-triggering null - } - - mai := parseMicroflowActionInfo(legacy) - if mai.Caption != "Old Action" || mai.Category != "Old Category" { - t.Fatalf("parsed Caption/Category wrong: %+v", mai) - } - if mai.ImageData != nil { - t.Errorf("null ImageData should parse to nil, got %v", mai.ImageData) - } - - // Rewriting must produce the healthy CodeActions$ shape with non-null binaries. - d := microflowActionInfoBSON(mai) - if v, _ := maiField(d, "$Type"); v != "CodeActions$MicroflowActionInfo" { - t.Errorf("rewrite $Type = %v, want CodeActions$MicroflowActionInfo", v) - } - if v, ok := maiField(d, "ImageData"); !ok { - t.Error("rewrite missing ImageData") - } else if bin, isBin := v.(primitive.Binary); !isBin || bin.Data == nil { - t.Errorf("rewrite ImageData = %v, want non-null binary", v) - } -} - -// TestParseMicroflowActionInfo_RoundTripsBinaries asserts real icon/image -// bitmaps survive a parse→write round-trip (no longer silently stripped). -func TestParseMicroflowActionInfo_RoundTripsBinaries(t *testing.T) { - icon := []byte{0xDE, 0xAD, 0xBE, 0xEF} - raw := map[string]any{ - "$ID": primitive.Binary{Subtype: 0, Data: make([]byte, 16)}, - "$Type": "CodeActions$MicroflowActionInfo", - "Caption": "Has Icon", - "Category": "Cat", - "IconData": primitive.Binary{Subtype: 0, Data: icon}, - } - mai := parseMicroflowActionInfo(raw) - if string(mai.IconData) != string(icon) { - t.Fatalf("IconData not preserved: got %v", mai.IconData) - } - d := microflowActionInfoBSON(mai) - v, _ := maiField(d, "IconData") - bin, _ := v.(primitive.Binary) - if string(bin.Data) != string(icon) { - t.Errorf("IconData round-trip lost data: got %v", bin.Data) - } -} diff --git a/sdk/mpr/jsonstructure_folder_lookup_test.go b/sdk/mpr/jsonstructure_folder_lookup_test.go deleted file mode 100644 index 41e4c7d721..0000000000 --- a/sdk/mpr/jsonstructure_folder_lookup_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - _ "modernc.org/sqlite" -) - -// JSON structures live inside a module but may be nested in subfolders. -// GetJsonStructureByQualifiedName must resolve container IDs through the -// folder hierarchy up to the owning module; otherwise addRestCallAction -// silently defaults SingleObject=false and produces invalid REST-call -// roundtrips on projects that organise their JSON structures in -// folders. -func TestGetJsonStructureByQualifiedName_ResolvesThroughFolders(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.mpr") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `); err != nil { - t.Fatalf("create Unit: %v", err) - } - - reader := &Reader{db: db, version: MPRVersionV1} - - moduleID := "11111111-1111-1111-1111-111111111111" - folderID := "22222222-2222-2222-2222-222222222222" - jsID := "33333333-3333-3333-3333-333333333333" - otherModuleID := "44444444-4444-4444-4444-444444444444" - - // Module: SBOMModule - modBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "$ID", Value: idToBsonBinary(moduleID)}, - {Key: "Name", Value: "SBOMModule"}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Module', ?)`, - uuidToBlob(moduleID), nil, modBSON); err != nil { - t.Fatalf("insert module: %v", err) - } - - // Other module (for the negative case) - otherModBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "$ID", Value: idToBsonBinary(otherModuleID)}, - {Key: "Name", Value: "OtherModule"}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Module', ?)`, - uuidToBlob(otherModuleID), nil, otherModBSON); err != nil { - t.Fatalf("insert other module: %v", err) - } - - // Folder inside SBOMModule - folderBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "Projects$Folder"}, - {Key: "$ID", Value: idToBsonBinary(folderID)}, - {Key: "Name", Value: "Payloads"}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Folder', ?)`, - uuidToBlob(folderID), uuidToBlob(moduleID), folderBSON); err != nil { - t.Fatalf("insert folder: %v", err) - } - - // JSON structure nested inside the folder (not the module directly). - jsBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "JsonStructures$JsonStructure"}, - {Key: "$ID", Value: idToBsonBinary(jsID)}, - {Key: "Name", Value: "OrderPayload"}, - {Key: "Elements", Value: bson.A{ - int32(2), - bson.D{ - {Key: "$Type", Value: "JsonStructures$JsonElement"}, - {Key: "ExposedName", Value: "Root"}, - {Key: "ElementType", Value: "Object"}, - }, - }}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Document', ?)`, - uuidToBlob(jsID), uuidToBlob(folderID), jsBSON); err != nil { - t.Fatalf("insert json structure: %v", err) - } - - // Lookup by owning module name should resolve through the folder. - js, err := reader.GetJsonStructureByQualifiedName("SBOMModule", "OrderPayload") - if err != nil { - t.Fatalf("GetJsonStructureByQualifiedName (folder-nested): %v", err) - } - if js == nil { - t.Fatal("expected non-nil JsonStructure") - } - if js.Name != "OrderPayload" { - t.Errorf("Name = %q, want OrderPayload", js.Name) - } - if len(js.Elements) != 1 || js.Elements[0].ElementType != "Object" { - t.Errorf("Elements = %+v, want one Object element", js.Elements) - } - if js.ContainerID != model.ID(folderID) { - t.Errorf("ContainerID = %q, want folder ID %q", js.ContainerID, folderID) - } - - // Cross-module lookup must fail (the structure belongs to SBOMModule, not OtherModule). - if _, err := reader.GetJsonStructureByQualifiedName("OtherModule", "OrderPayload"); err == nil { - t.Error("expected error for wrong-module lookup, got nil") - } -} diff --git a/sdk/mpr/microflow_call_writer_test.go b/sdk/mpr/microflow_call_writer_test.go deleted file mode 100644 index 4df11fdaf2..0000000000 --- a/sdk/mpr/microflow_call_writer_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "reflect" - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestMicroflowCallAction_WritesStableFieldOrder(t *testing.T) { - action := µflows.MicroflowCallAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - MicroflowCall: µflows.MicroflowCall{ - BaseElement: model.BaseElement{ID: "call-id"}, - Microflow: "Demo.UpdateRecord", - ParameterMappings: []*microflows.MicroflowCallParameterMapping{ - { - BaseElement: model.BaseElement{ID: "mapping-id"}, - Argument: "$Record/Name", - Parameter: "Demo.UpdateRecord.Name", - }, - }, - }, - UseReturnVariable: true, - } - - doc := serializeMicroflowAction(action) - assertBSONKeys(t, doc, []string{ - "$ID", - "$Type", - "ErrorHandlingType", - "MicroflowCall", - "ResultVariableName", - "UseReturnVariable", - }) - - callDoc, ok := bsonValue(doc, "MicroflowCall").(bson.D) - if !ok { - t.Fatalf("MicroflowCall type = %T, want bson.D", bsonValue(doc, "MicroflowCall")) - } - assertBSONKeys(t, callDoc, []string{ - "$ID", - "$Type", - "Microflow", - "ParameterMappings", - "QueueSettings", - }) - - mappings, ok := bsonValue(callDoc, "ParameterMappings").(bson.A) - if !ok || len(mappings) != 2 { - t.Fatalf("ParameterMappings = %#v, want marker plus one mapping", bsonValue(callDoc, "ParameterMappings")) - } - mappingDoc, ok := mappings[1].(bson.D) - if !ok { - t.Fatalf("mapping type = %T, want bson.D", mappings[1]) - } - assertBSONKeys(t, mappingDoc, []string{ - "$ID", - "$Type", - "Argument", - "Parameter", - }) -} - -func assertBSONKeys(t *testing.T, doc bson.D, want []string) { - t.Helper() - - var got []string - for _, elem := range doc { - got = append(got, elem.Key) - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("BSON keys = %#v, want %#v", got, want) - } -} - -func bsonValue(doc bson.D, key string) any { - for _, elem := range doc { - if elem.Key == key { - return elem.Value - } - } - return nil -} diff --git a/sdk/mpr/microflow_parameter_position_test.go b/sdk/mpr/microflow_parameter_position_test.go deleted file mode 100644 index ea7a0731b7..0000000000 --- a/sdk/mpr/microflow_parameter_position_test.go +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -func rmp(t *testing.T, doc bson.D) string { - t.Helper() - v, _ := doc.Map()["RelativeMiddlePoint"].(string) - return v -} - -// #993: a hand-placed parameter must be written where it was placed. Before the -// fix the legacy serializer computed the position from the index and ignored -// anything stored, so a describe → exec of mxcli's own output moved a real -// parameter from -77;0 to 200;53. -func TestSerializeMicroflowParameterKeepsAuthoredPosition(t *testing.T) { - authored := µflows.MicroflowParameter{ - Name: "Feedback", - Position: &model.Point{X: -77, Y: 0}, - } - if got := rmp(t, serializeMicroflowParameter(authored, 0, 11)); got != "-77;0" { - t.Errorf("authored position = %q, want -77;0", got) - } - - // Control: with no authored position the parameter goes where the layout - // puts it — the behaviour every unannotated flow still relies on. Without - // this the test would pass against a writer that had simply stopped - // deriving. - derived := µflows.MicroflowParameter{Name: "Feedback"} - if got := rmp(t, serializeMicroflowParameter(derived, 0, 11)); got != "200;53" { - t.Errorf("derived position at index 0 = %q, want 200;53", got) - } - if got := rmp(t, serializeMicroflowParameter(derived, 2, 11)); got != "400;53" { - t.Errorf("derived position at index 2 = %q, want 400;53", got) - } -} - -// The reader is where the derived/authored arbitration happens, so that -// everything downstream can treat a non-nil Position as intent. A parameter -// stored on the derived grid must come back unset — carrying it over would pin -// it, and inserting a parameter would then strand the others (#951's shape). -func TestParseMicroflowParameterNormalizesDerivedPosition(t *testing.T) { - raw := func(pos string) map[string]any { - return map[string]any{"Name": "A", "RelativeMiddlePoint": pos} - } - if p := parseMicroflowParameter(raw("200;53"), 0); p.Position != nil { - t.Errorf("derived position came back as %v, want nil", *p.Position) - } - if p := parseMicroflowParameter(raw("300;53"), 1); p.Position != nil { - t.Errorf("derived position at index 1 came back as %v, want nil", *p.Position) - } - p := parseMicroflowParameter(raw("-77;0"), 0) - if p.Position == nil { - t.Fatal("authored position was dropped — this is the #993 read-side loss") - } - if *p.Position != (model.Point{X: -77, Y: 0}) { - t.Errorf("position = %v, want -77;0", *p.Position) - } -} diff --git a/sdk/mpr/parser.go b/sdk/mpr/parser.go deleted file mode 100644 index 2c0725e7cd..0000000000 --- a/sdk/mpr/parser.go +++ /dev/null @@ -1,253 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "encoding/base64" - "strings" - - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// extractBsonID extracts an ID string from various BSON ID representations. -// Mendix stores IDs as Binary with Subtype/Data or as primitive.Binary. -func extractBsonID(v any) string { - if v == nil { - return "" - } - - switch val := v.(type) { - case string: - return val - case []byte: - return blobToUUID(val) - case primitive.Binary: - return blobToUUID(val.Data) - case map[string]any: - // Binary UUID stored as {Subtype: 0, Data: "base64..."} - if data, ok := val["Data"].(string); ok { - decoded, err := base64.StdEncoding.DecodeString(data) - if err == nil { - return blobToUUID(decoded) - } - } - // Also try $ID field - if id, ok := val["$ID"]; ok { - return extractBsonID(id) - } - } - - return "" -} - -// extractInt extracts an integer from various BSON number types. -func extractInt(v any) int { - if v == nil { - return 0 - } - switch val := v.(type) { - case int32: - return int(val) - case int64: - return int(val) - case int: - return val - case float64: - return int(val) - } - return 0 -} - -// extractString extracts a string from various BSON representations. -func extractString(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return s - } - return "" -} - -// extractBool extracts a boolean from BSON, with default value. -func extractBool(v any, defaultVal bool) bool { - if v == nil { - return defaultVal - } - if b, ok := v.(bool); ok { - return b - } - return defaultVal -} - -// extractBsonArray extracts items from a Mendix BSON array. -// Mendix arrays start with a type indicator (2 or 3 for storageListType), followed by items. -func extractBsonArray(v any) []any { - if v == nil { - return nil - } - - arr, ok := v.(primitive.A) - if !ok { - // Try regular slice - if slice, ok := v.([]any); ok { - // Check if first element is the array type indicator - if len(slice) > 0 { - if typeIndicator, ok := slice[0].(int32); ok && (typeIndicator == 2 || typeIndicator == 3) { - // Skip the type indicator - return slice[1:] - } - } - return slice - } - return nil - } - - // primitive.A is []interface{} underneath - slice := []any(arr) - - // Check if first element is the array type indicator (2 or 3) - if len(slice) > 0 { - if typeIndicator, ok := slice[0].(int32); ok && (typeIndicator == 2 || typeIndicator == 3) { - // Skip the type indicator - return slice[1:] - } - } - - return slice -} - -// extractBsonMap coerces a BSON value to map[string]interface{}. -// Handles map[string]interface{}, primitive.D, and primitive.M. -func extractBsonMap(v any) map[string]any { - if v == nil { - return nil - } - switch val := v.(type) { - case map[string]any: - return val - case primitive.D: - return val.Map() - case primitive.M: - return map[string]any(val) - } - return nil -} - -// extractBsonSlice coerces a BSON value to []interface{}. -// Handles []interface{} and primitive.A. Unlike extractBsonArray, -// this does NOT strip Mendix type-indicator prefixes. -func extractBsonSlice(v any) []any { - if v == nil { - return nil - } - switch val := v.(type) { - case []any: - return val - case primitive.A: - return []any(val) - } - return nil -} - -// BsonArrayInfo holds the extracted items and the marker from a Mendix BSON array. -type BsonArrayInfo struct { - Marker int32 - Items []any -} - -// extractBsonArrayWithMarker extracts items from a Mendix BSON array, preserving the marker. -// Returns the marker (2 or 3) and the items after the marker. -func extractBsonArrayWithMarker(v any) BsonArrayInfo { - if v == nil { - return BsonArrayInfo{} - } - - var slice []any - switch val := v.(type) { - case primitive.A: - slice = []any(val) - case []any: - slice = val - default: - return BsonArrayInfo{} - } - - if len(slice) > 0 { - if marker, ok := slice[0].(int32); ok && (marker == 1 || marker == 2 || marker == 3) { - return BsonArrayInfo{Marker: marker, Items: slice[1:]} - } - } - return BsonArrayInfo{Items: slice} -} - -// inferPropertyKind determines the Mendix property kind of a BSON field from its key -// and value shape. Returns one of: "id", "type-discriminator", "by-name-reference", -// "primitive", "part", "collection:by-name" (marker=1), "collection:part-secondary" -// (marker=2), "collection:part-primary" (marker=3), "collection". -// Used by UnknownElement to surface diagnostic info when an unimplemented $Type is encountered. -func inferPropertyKind(key string, v any) string { - if v == nil { - return "primitive" - } - - // Key-based shortcuts take priority over value shape. - switch key { - case "$ID", "$ContainerID": - return "id" - case "$Type": - return "type-discriminator" - } - - switch val := v.(type) { - case map[string]any: - if _, hasType := val["$Type"]; hasType { - return "part" - } - if _, hasID := val["$ID"]; hasID { - return "part" - } - return "primitive" - - case primitive.D: - m := val.Map() - if _, hasType := m["$Type"]; hasType { - return "part" - } - if _, hasID := m["$ID"]; hasID { - return "part" - } - return "primitive" - - case primitive.M: - if _, hasType := val["$Type"]; hasType { - return "part" - } - if _, hasID := val["$ID"]; hasID { - return "part" - } - return "primitive" - - case primitive.A, []any: - info := extractBsonArrayWithMarker(v) - switch info.Marker { - case 1: - return "collection:by-name" - case 2: - return "collection:part-secondary" - case 3: - return "collection:part-primary" - } - return "collection" - - case string: - // Heuristic: qualified names like "Module.Entity" are likely by-name references. - if strings.Contains(val, ".") && !strings.Contains(val, " ") && !strings.Contains(val, "/") { - return "by-name-reference" - } - return "primitive" - - default: - return "primitive" - } -} diff --git a/sdk/mpr/parser_businessevents.go b/sdk/mpr/parser_businessevents.go deleted file mode 100644 index 2f6b6ac89f..0000000000 --- a/sdk/mpr/parser_businessevents.go +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseBusinessEventService parses a BusinessEvents$BusinessEventService from BSON. -func (r *Reader) parseBusinessEventService(unitID, containerID string, contents []byte) (*model.BusinessEventService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.BusinessEventService{} - svc.ID = model.ID(unitID) - svc.TypeName = "BusinessEvents$BusinessEventService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Excluded = extractBool(raw["Excluded"], false) - svc.ExportLevel = extractString(raw["ExportLevel"]) - svc.Document = extractString(raw["Document"]) - - // Parse Definition (non-nil for service definitions) - if defRaw, ok := raw["Definition"]; ok && defRaw != nil { - if defMap := extractBsonMap(defRaw); defMap != nil { - svc.Definition = parseBusinessEventDefinition(defMap) - } - } - - // Parse OperationImplementations - opImpls := extractBsonArray(raw["OperationImplementations"]) - for _, oi := range opImpls { - if oiMap := extractBsonMap(oi); oiMap != nil { - svc.OperationImplementations = append(svc.OperationImplementations, parseServiceOperation(oiMap)) - } - } - - return svc, nil -} - -// parseBusinessEventDefinition parses a BusinessEvents$BusinessEventDefinition from a BSON map. -func parseBusinessEventDefinition(raw map[string]any) *model.BusinessEventDefinition { - def := &model.BusinessEventDefinition{} - def.ID = model.ID(extractBsonID(raw["$ID"])) - def.TypeName = extractString(raw["$Type"]) - def.ServiceName = extractString(raw["ServiceName"]) - def.EventNamePrefix = extractString(raw["EventNamePrefix"]) - def.Description = extractString(raw["Description"]) - def.Summary = extractString(raw["Summary"]) - - // Parse Channels - channels := extractBsonArray(raw["Channels"]) - for _, ch := range channels { - if chMap := extractBsonMap(ch); chMap != nil { - def.Channels = append(def.Channels, parseBusinessEventChannel(chMap)) - } - } - - return def -} - -// parseBusinessEventChannel parses a BusinessEvents$Channel from a BSON map. -func parseBusinessEventChannel(raw map[string]any) *model.BusinessEventChannel { - ch := &model.BusinessEventChannel{} - ch.ID = model.ID(extractBsonID(raw["$ID"])) - ch.TypeName = extractString(raw["$Type"]) - ch.ChannelName = extractString(raw["ChannelName"]) - ch.Description = extractString(raw["Description"]) - - // Parse Messages - messages := extractBsonArray(raw["Messages"]) - for _, msg := range messages { - if msgMap := extractBsonMap(msg); msgMap != nil { - ch.Messages = append(ch.Messages, parseBusinessEventMessage(msgMap)) - } - } - - return ch -} - -// parseBusinessEventMessage parses a BusinessEvents$Message from a BSON map. -func parseBusinessEventMessage(raw map[string]any) *model.BusinessEventMessage { - msg := &model.BusinessEventMessage{} - msg.ID = model.ID(extractBsonID(raw["$ID"])) - msg.TypeName = extractString(raw["$Type"]) - msg.MessageName = extractString(raw["MessageName"]) - msg.Description = extractString(raw["Description"]) - msg.CanPublish = extractBool(raw["CanPublish"], false) - msg.CanSubscribe = extractBool(raw["CanSubscribe"], false) - - // Parse Attributes - attrs := extractBsonArray(raw["Attributes"]) - for _, a := range attrs { - if aMap := extractBsonMap(a); aMap != nil { - msg.Attributes = append(msg.Attributes, parseBusinessEventAttribute(aMap)) - } - } - - return msg -} - -// parseBusinessEventAttribute parses a BusinessEvents$MessageAttribute from a BSON map. -func parseBusinessEventAttribute(raw map[string]any) *model.BusinessEventAttribute { - attr := &model.BusinessEventAttribute{} - attr.ID = model.ID(extractBsonID(raw["$ID"])) - attr.TypeName = extractString(raw["$Type"]) - attr.AttributeName = extractString(raw["AttributeName"]) - attr.Description = extractString(raw["Description"]) - - // Parse AttributeType — extract kind from the nested $Type field - // e.g., "DomainModels$LongAttributeType" → "Long" - if atRaw := extractBsonMap(raw["AttributeType"]); atRaw != nil { - typeName := extractString(atRaw["$Type"]) - attr.AttributeType = attributeTypeFromBsonType(typeName) - } - - return attr -} - -// attributeTypeFromBsonType converts a BSON $Type like "DomainModels$LongAttributeType" to "Long". -func attributeTypeFromBsonType(bsonType string) string { - switch bsonType { - case "DomainModels$LongAttributeType": - return "Long" - case "DomainModels$StringAttributeType": - return "String" - case "DomainModels$IntegerAttributeType": - return "Integer" - case "DomainModels$BooleanAttributeType": - return "Boolean" - case "DomainModels$DateTimeAttributeType": - return "DateTime" - case "DomainModels$DecimalAttributeType": - return "Decimal" - case "DomainModels$AutoNumberAttributeType": - return "AutoNumber" - case "DomainModels$BinaryAttributeType": - return "Binary" - default: - return bsonType - } -} - -// parseServiceOperation parses a BusinessEvents$ServiceOperation from a BSON map. -func parseServiceOperation(raw map[string]any) *model.ServiceOperation { - op := &model.ServiceOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.TypeName = extractString(raw["$Type"]) - op.MessageName = extractString(raw["MessageName"]) - op.Operation = extractString(raw["Operation"]) - op.Entity = extractString(raw["Entity"]) - op.Microflow = extractString(raw["Microflow"]) - return op -} diff --git a/sdk/mpr/parser_customblob.go b/sdk/mpr/parser_customblob.go deleted file mode 100644 index 30b46e238a..0000000000 --- a/sdk/mpr/parser_customblob.go +++ /dev/null @@ -1,295 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Parsing of CustomBlobDocuments$CustomBlobDocument units. -// -// The agent-editor Studio Pro extension (Mendix 11.9+) stores all of its -// documents — Agent, Model, Knowledge Base, Consumed MCP Service — as -// generic CustomBlobDocument units. They share the same BSON wrapper and -// are discriminated by the CustomDocumentType field. The actual document -// payload lives in a JSON string in the Contents field. -// -// This file provides the generic wrapper decode plus type-specific -// decoders for each inner JSON schema. -package mpr - -import ( - "encoding/json" - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" - - "go.mongodb.org/mongo-driver/bson" -) - -// customBlobDocType is the BSON $Type of the wrapper. -const customBlobDocType = "CustomBlobDocuments$CustomBlobDocument" - -// rawCustomBlobDoc is the decoded BSON wrapper (fields we care about). -type rawCustomBlobDoc struct { - Name string - Documentation string - Excluded bool - ExportLevel string - CustomDocumentType string - Contents string // JSON payload -} - -// parseCustomBlobWrapper decodes the outer CustomBlobDocument BSON wrapper. -// Returns a rawCustomBlobDoc or an error. -func parseCustomBlobWrapper(contents []byte) (*rawCustomBlobDoc, error) { - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal CustomBlobDocument BSON: %w", err) - } - - out := &rawCustomBlobDoc{} - if v, ok := raw["Name"].(string); ok { - out.Name = v - } - if v, ok := raw["Documentation"].(string); ok { - out.Documentation = v - } - if v, ok := raw["Excluded"].(bool); ok { - out.Excluded = v - } - if v, ok := raw["ExportLevel"].(string); ok { - out.ExportLevel = v - } - if v, ok := raw["CustomDocumentType"].(string); ok { - out.CustomDocumentType = v - } - if v, ok := raw["Contents"].(string); ok { - out.Contents = v - } - return out, nil -} - -// parseAgentEditorModel parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.model" into an agenteditor.Model. -func (r *Reader) parseAgentEditorModel(unitID, containerID string, contents []byte) (*agenteditor.Model, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeModel { - return nil, fmt.Errorf("unit %s is not an agent-editor model (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - m := &agenteditor.Model{} - m.ID = model.ID(unitID) - m.TypeName = customBlobDocType - m.ContainerID = model.ID(containerID) - m.Name = wrap.Name - m.Documentation = wrap.Documentation - m.Excluded = wrap.Excluded - m.ExportLevel = wrap.ExportLevel - - // Decode the Contents JSON payload. - if wrap.Contents != "" { - var payload struct { - Type string `json:"type"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Provider string `json:"provider"` - ProviderFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ResourceName string `json:"resourceName"` - Key *agenteditor.ConstantRef `json:"key"` - } `json:"providerFields"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor Model Contents JSON: %w", err) - } - - m.Type = payload.Type - m.InnerName = payload.Name - m.DisplayName = payload.DisplayName - m.Provider = payload.Provider - m.Environment = payload.ProviderFields.Environment - m.DeepLinkURL = payload.ProviderFields.DeepLinkURL - m.KeyID = payload.ProviderFields.KeyID - m.KeyName = payload.ProviderFields.KeyName - m.ResourceName = payload.ProviderFields.ResourceName - m.Key = payload.ProviderFields.Key - } - - return m, nil -} - -// parseAgentEditorKnowledgeBase parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.knowledgebase" into an -// agenteditor.KnowledgeBase. -func (r *Reader) parseAgentEditorKnowledgeBase(unitID, containerID string, contents []byte) (*agenteditor.KnowledgeBase, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeKnowledgeBase { - return nil, fmt.Errorf("unit %s is not an agent-editor knowledge base (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - k := &agenteditor.KnowledgeBase{} - k.ID = model.ID(unitID) - k.TypeName = customBlobDocType - k.ContainerID = model.ID(containerID) - k.Name = wrap.Name - k.Documentation = wrap.Documentation - k.Excluded = wrap.Excluded - k.ExportLevel = wrap.ExportLevel - - if wrap.Contents != "" { - var payload struct { - Name string `json:"name"` - Provider string `json:"provider"` - ProviderFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ModelDisplayName string `json:"modelDisplayName"` - ModelName string `json:"modelName"` - Key *agenteditor.ConstantRef `json:"key"` - } `json:"providerFields"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor KnowledgeBase Contents JSON: %w", err) - } - k.Provider = payload.Provider - k.Environment = payload.ProviderFields.Environment - k.DeepLinkURL = payload.ProviderFields.DeepLinkURL - k.KeyID = payload.ProviderFields.KeyID - k.KeyName = payload.ProviderFields.KeyName - k.ModelDisplayName = payload.ProviderFields.ModelDisplayName - k.ModelName = payload.ProviderFields.ModelName - k.Key = payload.ProviderFields.Key - } - - return k, nil -} - -// parseAgentEditorConsumedMCPService parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.consumedMCPService" into an -// agenteditor.ConsumedMCPService. -func (r *Reader) parseAgentEditorConsumedMCPService(unitID, containerID string, contents []byte) (*agenteditor.ConsumedMCPService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeConsumedMCPService { - return nil, fmt.Errorf("unit %s is not an agent-editor consumed MCP service (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - c := &agenteditor.ConsumedMCPService{} - c.ID = model.ID(unitID) - c.TypeName = customBlobDocType - c.ContainerID = model.ID(containerID) - c.Name = wrap.Name - c.Documentation = wrap.Documentation - c.Excluded = wrap.Excluded - c.ExportLevel = wrap.ExportLevel - - if wrap.Contents != "" { - var payload struct { - ProtocolVersion string `json:"protocolVersion"` - Documentation string `json:"documentation"` - Version string `json:"version"` - ConnectionTimeoutSeconds int `json:"connectionTimeoutSeconds"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor ConsumedMCPService Contents JSON: %w", err) - } - c.ProtocolVersion = payload.ProtocolVersion - c.InnerDocumentation = payload.Documentation - c.Version = payload.Version - c.ConnectionTimeoutSeconds = payload.ConnectionTimeoutSeconds - } - - return c, nil -} - -// parseAgentEditorAgent parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.agent" into an agenteditor.Agent. -func (r *Reader) parseAgentEditorAgent(unitID, containerID string, contents []byte) (*agenteditor.Agent, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeAgent { - return nil, fmt.Errorf("unit %s is not an agent-editor agent (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - a := &agenteditor.Agent{} - a.ID = model.ID(unitID) - a.TypeName = customBlobDocType - a.ContainerID = model.ID(containerID) - a.Name = wrap.Name - a.Documentation = wrap.Documentation - a.Excluded = wrap.Excluded - a.ExportLevel = wrap.ExportLevel - - if wrap.Contents != "" { - // Decode the fields we know about; unknown fields are ignored so - // the parser stays forward-compatible with editor updates. - var payload struct { - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - UserPrompt string `json:"userPrompt"` - UsageType string `json:"usageType"` - Variables []agenteditor.AgentVar `json:"variables"` - Tools []agenteditor.AgentTool `json:"tools"` - KnowledgebaseTools []agenteditor.AgentKBTool `json:"knowledgebaseTools"` - Model *agenteditor.DocRef `json:"model"` - Entity *agenteditor.DocRef `json:"entity"` - MaxTokens *int `json:"maxTokens"` - ToolChoice string `json:"toolChoice"` - Temperature *float64 `json:"temperature"` - TopP *float64 `json:"topP"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor Agent Contents JSON: %w", err) - } - a.Description = payload.Description - a.SystemPrompt = payload.SystemPrompt - a.UserPrompt = payload.UserPrompt - a.UsageType = payload.UsageType - a.Variables = payload.Variables - a.Tools = payload.Tools - a.KBTools = payload.KnowledgebaseTools - a.Model = payload.Model - a.Entity = payload.Entity - a.MaxTokens = payload.MaxTokens - a.ToolChoice = payload.ToolChoice - a.Temperature = payload.Temperature - a.TopP = payload.TopP - } - - return a, nil -} diff --git a/sdk/mpr/parser_datatransformer.go b/sdk/mpr/parser_datatransformer.go deleted file mode 100644 index fbad192dc1..0000000000 --- a/sdk/mpr/parser_datatransformer.go +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseDataTransformer parses a DataTransformers$DataTransformer from BSON. -func (r *Reader) parseDataTransformer(unitID, containerID string, contents []byte) (*model.DataTransformer, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - dt := &model.DataTransformer{} - dt.ID = model.ID(unitID) - dt.TypeName = "DataTransformers$DataTransformer" - dt.ContainerID = model.ID(containerID) - dt.Name = extractString(raw["Name"]) - dt.Excluded = extractBool(raw["Excluded"], false) - - // Parse Source - if srcMap := extractBsonMap(raw["Source"]); srcMap != nil { - srcType := extractString(srcMap["$Type"]) - switch srcType { - case "DataTransformers$JsonSource": - dt.SourceType = "JSON" - dt.SourceJSON = extractString(srcMap["Content"]) - case "DataTransformers$XmlSource": - dt.SourceType = "XML" - dt.SourceJSON = extractString(srcMap["Content"]) - } - } - - // Parse Steps - steps := extractBsonArray(raw["Steps"]) - for _, step := range steps { - stepMap, ok := step.(map[string]any) - if !ok { - continue - } - if extractString(stepMap["$Type"]) != "DataTransformers$Step" { - continue - } - actionMap := extractBsonMap(stepMap["Action"]) - if actionMap == nil { - continue - } - actionType := extractString(actionMap["$Type"]) - s := &model.DataTransformerStep{} - switch actionType { - case "DataTransformers$JsltAction": - s.Technology = "JSLT" - s.Expression = extractString(actionMap["Jslt"]) - case "DataTransformers$XsltAction": - s.Technology = "XSLT" - s.Expression = extractString(actionMap["Xslt"]) - default: - s.Technology = actionType - } - dt.Steps = append(dt.Steps, s) - } - - return dt, nil -} diff --git a/sdk/mpr/parser_dbconnection.go b/sdk/mpr/parser_dbconnection.go deleted file mode 100644 index 1b3192e12e..0000000000 --- a/sdk/mpr/parser_dbconnection.go +++ /dev/null @@ -1,136 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "github.com/mendixlabs/mxcli/mdl/dbconnector" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// parseDBConnection parses a DatabaseConnector$DatabaseConnection from BSON. -func (r *Reader) parseDBConnection(unitID, containerID string, contents []byte) (*model.DatabaseConnection, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - conn := &model.DatabaseConnection{} - conn.ID = model.ID(unitID) - conn.TypeName = "DatabaseConnector$DatabaseConnection" - conn.ContainerID = model.ID(containerID) - - conn.Name = extractString(raw["Name"]) - conn.DatabaseType = extractString(raw["DatabaseType"]) - conn.ConnectionString = extractString(raw["ConnectionString"]) - conn.UserName = extractString(raw["UserName"]) - conn.Password = extractString(raw["Password"]) - conn.Documentation = extractString(raw["Documentation"]) - conn.Excluded = extractBool(raw["Excluded"], false) - conn.ExportLevel = extractString(raw["ExportLevel"]) - - // Parse ConnectionInput.Value (actual JDBC URL for Studio Pro) - if ci := extractBsonMap(raw["ConnectionInput"]); ci != nil { - conn.ConnectionInputValue = extractString(ci["Value"]) - } - - // Parse Queries - queries := extractBsonArray(raw["Queries"]) - for _, q := range queries { - if qMap := extractBsonMap(q); qMap != nil { - conn.Queries = append(conn.Queries, parseDBQuery(qMap)) - } - } - - return conn, nil -} - -func parseDBQuery(raw map[string]any) *model.DatabaseQuery { - q := &model.DatabaseQuery{} - q.ID = model.ID(extractBsonID(raw["$ID"])) - q.TypeName = extractString(raw["$Type"]) - q.Name = extractString(raw["Name"]) - q.SQL = extractString(raw["Query"]) - // Mendix 11.13 replaced the integer QueryType with the `Type` string enum; - // read whichever key this project stores so a round-trip preserves it. - q.QueryTypeName = extractString(raw[dbconnector.TypeKey]) - if q.QueryTypeName != "" { - q.QueryType = dbconnector.LegacyQueryTypeFor(q.QueryTypeName) - } else { - q.QueryType = extractInt(raw[dbconnector.QueryTypeKey]) - } - - // Parse TableMappings - mappings := extractBsonArray(raw["TableMappings"]) - for _, m := range mappings { - if mMap := extractBsonMap(m); mMap != nil { - q.TableMappings = append(q.TableMappings, parseDBTableMapping(mMap)) - } - } - - // Parse Parameters - params := extractBsonArray(raw["Parameters"]) - for _, p := range params { - if pMap := extractBsonMap(p); pMap != nil { - q.Parameters = append(q.Parameters, parseDBQueryParameter(pMap)) - } - } - - return q -} - -func parseDBQueryParameter(raw map[string]any) *model.DatabaseQueryParameter { - p := &model.DatabaseQueryParameter{} - p.ID = model.ID(extractBsonID(raw["$ID"])) - p.TypeName = extractString(raw["$Type"]) - p.ParameterName = extractString(raw["ParameterName"]) - p.DefaultValue = extractString(raw["DefaultValue"]) - p.EmptyValueBecomesNull = extractBool(raw["EmptyValueBecomesNull"], false) - - // DataType is a nested object like {"$Type": "DataTypes$IntegerType", "$ID": "..."} - if dt := extractBsonMap(raw["DataType"]); dt != nil { - p.DataType = extractString(dt["$Type"]) - } - - return p -} - -func parseDBTableMapping(raw map[string]any) *model.DatabaseTableMapping { - m := &model.DatabaseTableMapping{} - m.ID = model.ID(extractBsonID(raw["$ID"])) - m.TypeName = extractString(raw["$Type"]) - m.Entity = extractString(raw["Entity"]) - m.TableName = extractString(raw["TableName"]) - - // Parse Columns - columns := extractBsonArray(raw["Columns"]) - for _, c := range columns { - if cMap := extractBsonMap(c); cMap != nil { - m.Columns = append(m.Columns, parseDBColumnMapping(cMap)) - } - } - - return m -} - -func parseDBColumnMapping(raw map[string]any) *model.DatabaseColumnMapping { - c := &model.DatabaseColumnMapping{} - c.ID = model.ID(extractBsonID(raw["$ID"])) - c.TypeName = extractString(raw["$Type"]) - c.Attribute = extractString(raw["Attribute"]) - c.ColumnName = extractString(raw["ColumnName"]) - - // SqlDataType is polymorphic: SimpleSqlDataType or LimitedLengthSqlDataType - if dt := extractBsonMap(raw["SqlDataType"]); dt != nil { - c.SqlDataType = extractString(dt["$Type"]) - } - - return c -} diff --git a/sdk/mpr/parser_domainmodel.go b/sdk/mpr/parser_domainmodel.go deleted file mode 100644 index 26a22df457..0000000000 --- a/sdk/mpr/parser_domainmodel.go +++ /dev/null @@ -1,831 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseDomainModel(unitID, containerID string, contents []byte) (*domainmodel.DomainModel, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - dm := &domainmodel.DomainModel{} - dm.ID = model.ID(unitID) - dm.TypeName = "DomainModels$DomainModel" - dm.ContainerID = model.ID(containerID) - - // Parse entities - use extractBsonArray to handle Mendix array format - entities := extractBsonArray(raw["Entities"]) - for _, e := range entities { - if entityMap, ok := e.(map[string]any); ok { - entity := parseEntity(entityMap) - dm.Entities = append(dm.Entities, entity) - } - } - - // Parse associations - associations := extractBsonArray(raw["Associations"]) - for _, a := range associations { - if assocMap, ok := a.(map[string]any); ok { - assoc := parseAssociation(assocMap) - dm.Associations = append(dm.Associations, assoc) - } - } - - // Parse cross-module associations - crossAssocs := extractBsonArray(raw["CrossAssociations"]) - for _, ca := range crossAssocs { - if caMap, ok := ca.(map[string]any); ok { - crossAssoc := parseCrossAssociation(caMap) - dm.CrossAssociations = append(dm.CrossAssociations, crossAssoc) - } - } - - // Parse annotations - annotations := extractBsonArray(raw["Annotations"]) - for _, a := range annotations { - if annotMap, ok := a.(map[string]any); ok { - annot := parseAnnotation(annotMap) - dm.Annotations = append(dm.Annotations, annot) - } - } - - return dm, nil -} - -func parseEntity(raw map[string]any) *domainmodel.Entity { - entity := &domainmodel.Entity{} - - // Use extractBsonID to handle various ID formats (string, binary, base64) - entity.ID = model.ID(extractBsonID(raw["$ID"])) - if typeName, ok := raw["$Type"].(string); ok { - entity.TypeName = typeName - } - if name, ok := raw["Name"].(string); ok { - entity.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - entity.Documentation = doc - } - - // Parse location - handle string "x;y" format or map format - if locStr, ok := raw["Location"].(string); ok { - // Parse "x;y" format - parts := strings.Split(locStr, ";") - if len(parts) == 2 { - fmt.Sscanf(parts[0], "%d", &entity.Location.X) - fmt.Sscanf(parts[1], "%d", &entity.Location.Y) - } - } else if loc, ok := raw["Location"].(map[string]any); ok { - entity.Location.X = extractInt(loc["x"]) - entity.Location.Y = extractInt(loc["y"]) - } - - // Parse persistable - default to true if not specified - entity.Persistable = true - if persistable, ok := raw["Persistable"].(bool); ok { - entity.Persistable = persistable - } - - // Parse source (for view/external entities) - if source, ok := raw["Source"].(map[string]any); ok { - if sourceType, ok := source["$Type"].(string); ok { - entity.Source = sourceType - } - // Preserve the Source object's $ID to avoid CE-6770 on updates - if sourceID := extractID(source["$ID"]); sourceID != "" { - entity.SourceObjectID = model.ID(sourceID) - } - // For view entities, extract the OQL query directly - if oqlQuery, ok := source["OqlQuery"].(string); ok { - entity.OqlQuery = oqlQuery - } - // For view entities, extract the source document reference - if sourceDocRef, ok := source["SourceDocument"].(string); ok { - entity.SourceDocumentRef = sourceDocRef - } - // External entity sources (three flavors) - switch entity.Source { - case "Rest$ODataRemoteEntitySource": - entity.RemoteServiceName = extractString(source["SourceDocument"]) - entity.RemoteEntitySet = extractString(source["EntitySet"]) - entity.RemoteEntityName = extractString(source["RemoteName"]) - entity.Countable = extractBool(source["Countable"], false) - entity.Creatable = extractBool(source["Creatable"], false) - entity.Deletable = extractBool(source["Deletable"], false) - entity.Updatable = extractBool(source["Updatable"], false) - entity.SkipSupported = extractBool(source["SkipSupported"], false) - entity.TopSupported = extractBool(source["TopSupported"], false) - entity.CreateChangeLocally = extractBool(source["CreateChangeLocally"], false) - parseRemoteKey(source, entity) - case "Rest$ODataEntityTypeSource": - entity.RemoteServiceName = extractString(source["SourceDocument"]) - entity.RemoteEntityName = extractString(source["EntityTypeName"]) - entity.IsOpen = extractBool(source["IsOpen"], false) - parseRemoteKey(source, entity) - case "Rest$ODataPrimitiveCollectionEntitySource": - entity.RemoteServiceName = extractString(source["SourceDocument"]) - } - } - - // Parse generalization (parent entity) - field is MaybeGeneralization in newer formats - genField := raw["Generalization"] - if genField == nil { - genField = raw["MaybeGeneralization"] - } - if genField != nil { - if genMap, ok := genField.(map[string]any); ok { - if genID := extractBsonID(genMap["$ID"]); genID != "" { - entity.GeneralizationID = model.ID(genID) - } - // Handle qualified name reference (e.g., "System.User") - if genRef, ok := genMap["Generalization"].(string); ok { - entity.GeneralizationRef = genRef - } - // For NoGeneralization, system flags are stored inside the generalization object. - // Mendix < 11.9 uses HasOwner/HasChangedBy/HasChangedDate/HasCreatedDate. - // Mendix >= 11.9 uses HasOwnerAttr/HasChangedByAttr/HasChangedDateAttr/HasCreatedDateAttr. - if genType, ok := genMap["$Type"].(string); ok && genType == "DomainModels$NoGeneralization" { - if persistable, ok := genMap["Persistable"].(bool); ok { - entity.Persistable = persistable - } - entity.HasOwner = extractBool(genMap["HasOwner"], false) || extractBool(genMap["HasOwnerAttr"], false) - entity.HasChangedBy = extractBool(genMap["HasChangedBy"], false) || extractBool(genMap["HasChangedByAttr"], false) - entity.HasChangedDate = extractBool(genMap["HasChangedDate"], false) || extractBool(genMap["HasChangedDateAttr"], false) - entity.HasCreatedDate = extractBool(genMap["HasCreatedDate"], false) || extractBool(genMap["HasCreatedDateAttr"], false) - } - } - } - - // Fallback: check both old and new field names at entity level - if extractBool(raw["HasOwner"], false) || extractBool(raw["HasOwnerAttr"], false) { - entity.HasOwner = true - } - if extractBool(raw["HasChangedBy"], false) || extractBool(raw["HasChangedByAttr"], false) { - entity.HasChangedBy = true - } - if extractBool(raw["HasChangedDate"], false) || extractBool(raw["HasChangedDateAttr"], false) { - entity.HasChangedDate = true - } - if extractBool(raw["HasCreatedDate"], false) || extractBool(raw["HasCreatedDateAttr"], false) { - entity.HasCreatedDate = true - } - - // Parse attributes using extractBsonArray - attrs := extractBsonArray(raw["Attributes"]) - for _, a := range attrs { - if attrMap, ok := a.(map[string]any); ok { - attr := parseAttribute(attrMap) - entity.Attributes = append(entity.Attributes, attr) - } - } - - // Parse indexes - indexes := extractBsonArray(raw["Indexes"]) - for _, i := range indexes { - if indexMap, ok := i.(map[string]any); ok { - index := parseIndex(indexMap) - entity.Indexes = append(entity.Indexes, index) - } - } - - // Parse access rules - rules := extractBsonArray(raw["AccessRules"]) - for _, r := range rules { - if ruleMap, ok := r.(map[string]any); ok { - rule := parseAccessRule(ruleMap) - entity.AccessRules = append(entity.AccessRules, rule) - } - } - - // Parse validation rules - validations := extractBsonArray(raw["ValidationRules"]) - for _, v := range validations { - if validMap, ok := v.(map[string]any); ok { - validation := parseValidationRule(validMap) - entity.ValidationRules = append(entity.ValidationRules, validation) - } - } - - // Parse event handlers — field is "Events" in BSON (not "EventHandlers") - handlers := extractBsonArray(raw["Events"]) - if len(handlers) == 0 { - handlers = extractBsonArray(raw["EventHandlers"]) // fallback for older format - } - for _, h := range handlers { - if handlerMap, ok := h.(map[string]any); ok { - handler := parseEventHandler(handlerMap) - entity.EventHandlers = append(entity.EventHandlers, handler) - } - } - - return entity -} - -// parseRemoteKey reads the Rest$ODataKey block from a Source map and populates -// entity.RemoteKeyParts. -func parseRemoteKey(source map[string]any, entity *domainmodel.Entity) { - keyMap, ok := source["Key"].(map[string]any) - if !ok { - return - } - partsArr := extractBsonArray(keyMap["Parts"]) - for _, p := range partsArr { - pMap, ok := p.(map[string]any) - if !ok { - continue - } - kp := &domainmodel.RemoteKeyPart{ - Name: extractString(pMap["EntityKeyPartName"]), - RemoteName: extractString(pMap["Name"]), - RemoteType: extractString(pMap["RemoteType"]), - } - if typeMap, ok := pMap["Type"].(map[string]any); ok { - kp.Type = parseAttributeType(typeMap) - } - entity.RemoteKeyParts = append(entity.RemoteKeyParts, kp) - } -} - -func parseAttribute(raw map[string]any) *domainmodel.Attribute { - attr := &domainmodel.Attribute{} - - attr.ID = model.ID(extractBsonID(raw["$ID"])) - attr.TypeName = extractString(raw["$Type"]) - attr.Name = extractString(raw["Name"]) - attr.Documentation = extractString(raw["Documentation"]) - - // Parse attribute type - Mendix uses "NewType" field - if attrType, ok := raw["NewType"].(map[string]any); ok { - attr.Type = parseAttributeType(attrType) - } else if attrType, ok := raw["Type"].(map[string]any); ok { - // Fallback to "Type" for older format - attr.Type = parseAttributeType(attrType) - } - - // Parse default value - if val, ok := raw["Value"].(map[string]any); ok { - attr.Value = parseAttributeValue(val) - - // For external entities, the Value is a Rest$ODataMappedValue that - // carries the OData property name, type, and capability flags. - switch extractString(val["$Type"]) { - case "Rest$ODataMappedValue": - attr.RemoteName = extractString(val["RemoteName"]) - attr.RemoteType = extractString(val["RemoteType"]) - attr.Filterable = extractBool(val["Filterable"], false) - attr.Sortable = extractBool(val["Sortable"], false) - attr.Creatable = extractBool(val["Creatable"], false) - attr.Updatable = extractBool(val["Updatable"], false) - case "Rest$ODataMappedPrimitiveCollectionValue": - attr.RemoteName = extractString(val["RemoteName"]) - attr.RemoteType = extractString(val["RemoteType"]) - attr.IsPrimitiveCollection = true - } - } - - return attr -} - -func parseAttributeValue(raw map[string]any) *domainmodel.AttributeValue { - typeName := extractString(raw["$Type"]) - defaultValue := extractString(raw["DefaultValue"]) - valueID := model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "DomainModels$StoredValue": - val := &domainmodel.AttributeValue{ - Type: "StoredValue", - DefaultValue: defaultValue, - } - val.ID = valueID - return val - case "DomainModels$CalculatedValue": - val := &domainmodel.AttributeValue{ - Type: "CalculatedValue", - MicroflowID: model.ID(extractBsonID(raw["Microflow"])), - MicroflowName: extractString(raw["Microflow"]), - } - val.ID = valueID - return val - case "DomainModels$OqlViewValue": - val := &domainmodel.AttributeValue{ - Type: "OqlViewValue", - ViewReference: extractString(raw["Reference"]), - } - val.ID = valueID - return val - case "Rest$ODataMappedValue": - val := &domainmodel.AttributeValue{ - Type: "ODataMappedValue", - DefaultValue: extractString(raw["DefaultValueDesignTime"]), - } - val.ID = valueID - return val - case "Rest$ODataMappedPrimitiveCollectionValue": - val := &domainmodel.AttributeValue{ - Type: "ODataMappedPrimitiveCollectionValue", - DefaultValue: extractString(raw["DefaultValueDesignTime"]), - } - val.ID = valueID - return val - default: - val := &domainmodel.AttributeValue{ - DefaultValue: defaultValue, - } - val.ID = valueID - return val - } -} - -func parseAttributeType(raw map[string]any) domainmodel.AttributeType { - typeName, _ := raw["$Type"].(string) - typeID := model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "DomainModels$StringAttributeType": - t := &domainmodel.StringAttributeType{} - t.ID = typeID - // Issue #583: Studio Pro stores Length as BSON int64; mxcli's writer - // emits int32. extractInt accepts both (plus int and float64). - t.Length = extractInt(raw["Length"]) - return t - case "DomainModels$IntegerAttributeType": - t := &domainmodel.IntegerAttributeType{} - t.ID = typeID - return t - case "DomainModels$LongAttributeType": - t := &domainmodel.LongAttributeType{} - t.ID = typeID - return t - case "DomainModels$DecimalAttributeType": - t := &domainmodel.DecimalAttributeType{} - t.ID = typeID - return t - case "DomainModels$BooleanAttributeType": - t := &domainmodel.BooleanAttributeType{} - t.ID = typeID - return t - case "DomainModels$DateTimeAttributeType": - localize, ok := raw["LocalizeDate"].(bool) - if !ok || localize { - // Default to DateTime when LocalizeDate is absent or true - t := &domainmodel.DateTimeAttributeType{LocalizeDate: true} - t.ID = typeID - return t - } - // LocalizeDate explicitly false means date-only type - dt := &domainmodel.DateAttributeType{} - dt.ID = typeID - return dt - case "DomainModels$EnumerationAttributeType": - t := &domainmodel.EnumerationAttributeType{} - t.ID = typeID - // Enumeration is stored as qualified name string (BY_NAME_REFERENCE) - if enumRef, ok := raw["Enumeration"].(string); ok { - t.EnumerationRef = enumRef - // Also store in EnumerationID for backward compatibility - t.EnumerationID = model.ID(enumRef) - } - return t - case "DomainModels$AutoNumberAttributeType": - t := &domainmodel.AutoNumberAttributeType{} - t.ID = typeID - return t - case "DomainModels$BinaryAttributeType": - t := &domainmodel.BinaryAttributeType{} - t.ID = typeID - return t - case "DomainModels$HashedStringAttributeType": - t := &domainmodel.HashedStringAttributeType{} - t.ID = typeID - return t - default: - t := &domainmodel.StringAttributeType{} // Default fallback - t.ID = typeID - return t - } -} - -func parseAssociation(raw map[string]any) *domainmodel.Association { - assoc := &domainmodel.Association{} - - assoc.ID = model.ID(extractBsonID(raw["$ID"])) - assoc.TypeName = extractString(raw["$Type"]) - assoc.Name = extractString(raw["Name"]) - assoc.Documentation = extractString(raw["Documentation"]) - assoc.ParentID = model.ID(extractBsonID(raw["ParentPointer"])) - assoc.ChildID = model.ID(extractBsonID(raw["ChildPointer"])) - assoc.Type = domainmodel.AssociationType(extractString(raw["Type"])) - assoc.Owner = domainmodel.AssociationOwner(extractString(raw["Owner"])) - if sf := extractString(raw["StorageFormat"]); sf != "" { - assoc.StorageFormat = domainmodel.AssociationStorageFormat(sf) - } else { - assoc.StorageFormat = domainmodel.StorageFormatTable - } - // The line anchors are read so the writer can put them back unchanged; every - // association write rebuilds the whole element, so a field not read here is - // a field destroyed on the next `alter association`. (issue #872) - assoc.ParentConnection = domainmodel.ParseConnectionPoint(extractString(raw["ParentConnection"])) - assoc.ChildConnection = domainmodel.ParseConnectionPoint(extractString(raw["ChildConnection"])) - - // Parse delete behavior - if deleteBehaviorRaw, ok := raw["DeleteBehavior"].(map[string]any); ok { - if parentType := extractString(deleteBehaviorRaw["ParentDeleteBehavior"]); parentType != "" { - assoc.ParentDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(parentType), - } - } - if childType := extractString(deleteBehaviorRaw["ChildDeleteBehavior"]); childType != "" { - assoc.ChildDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(childType), - // Read the refusal message back, or DESCRIBE cannot emit it and a - // describe -> exec round trip rebuilds an association whose runtime - // will not start (CapTrackV2 §1). - ErrorMessage: deleteBehaviorErrorMessage(deleteBehaviorRaw["ChildErrorMessage"]), - } - } - } - - // Parse OData remote association source - if sourceMap, ok := raw["Source"].(map[string]any); ok { - switch extractString(sourceMap["$Type"]) { - case "Rest$ODataRemoteAssociationSource": - assoc.Source = "Rest$ODataRemoteAssociationSource" - assoc.RemoteParentNavigationProperty = extractString(sourceMap["RemoteParentNavigationProperty"]) - assoc.RemoteChildNavigationProperty = extractString(sourceMap["RemoteChildNavigationProperty"]) - assoc.CreatableFromParent = extractBool(sourceMap["CreatableFromParent"], false) - assoc.CreatableFromChild = extractBool(sourceMap["CreatableFromChild"], false) - assoc.UpdatableFromParent = extractBool(sourceMap["UpdatableFromParent"], false) - assoc.UpdatableFromChild = extractBool(sourceMap["UpdatableFromChild"], false) - assoc.Navigability2 = extractString(sourceMap["Navigability2"]) - case "Rest$ODataPrimitiveCollectionAssociationSource": - assoc.Source = "Rest$ODataPrimitiveCollectionAssociationSource" - case "DomainModels$OqlViewAssociationSource": - // A view entity's association to a persistent entity. Reading it is - // not a convenience: an unread Source is written back as null on the - // next rewrite of this domain model, which turns a working project - // into CE6771 + CE6770 with no statement having asked for that. - assoc.Source = "DomainModels$OqlViewAssociationSource" - assoc.ViewSourceReference = extractString(sourceMap["Reference"]) - } - } - - return assoc -} - -func parseCrossAssociation(raw map[string]any) *domainmodel.CrossModuleAssociation { - ca := &domainmodel.CrossModuleAssociation{} - - ca.ID = model.ID(extractBsonID(raw["$ID"])) - ca.TypeName = extractString(raw["$Type"]) - ca.Name = extractString(raw["Name"]) - ca.Documentation = extractString(raw["Documentation"]) - ca.ParentID = model.ID(extractBsonID(raw["ParentPointer"])) - ca.ChildRef = extractString(raw["Child"]) - ca.Type = domainmodel.AssociationType(extractString(raw["Type"])) - ca.Owner = domainmodel.AssociationOwner(extractString(raw["Owner"])) - if sf := extractString(raw["StorageFormat"]); sf != "" { - ca.StorageFormat = domainmodel.AssociationStorageFormat(sf) - } else { - ca.StorageFormat = domainmodel.StorageFormatTable - } - - // Parse delete behavior - if deleteBehaviorRaw, ok := raw["DeleteBehavior"].(map[string]any); ok { - if parentType := extractString(deleteBehaviorRaw["ParentDeleteBehavior"]); parentType != "" { - ca.ParentDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(parentType), - } - } - if childType := extractString(deleteBehaviorRaw["ChildDeleteBehavior"]); childType != "" { - ca.ChildDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(childType), - ErrorMessage: deleteBehaviorErrorMessage(deleteBehaviorRaw["ChildErrorMessage"]), - } - } - } - - // A view entity pointing at an entity in ANOTHER module lands here rather - // than in parseAssociation, so the Source has to be read in both places. - if sourceMap, ok := raw["Source"].(map[string]any); ok { - if extractString(sourceMap["$Type"]) == "DomainModels$OqlViewAssociationSource" { - ca.Source = "DomainModels$OqlViewAssociationSource" - ca.ViewSourceReference = extractString(sourceMap["Reference"]) - } - } - - return ca -} - -func parseAnnotation(raw map[string]any) *domainmodel.Annotation { - annot := &domainmodel.Annotation{} - - annot.ID = model.ID(extractBsonID(raw["$ID"])) - annot.TypeName = extractString(raw["$Type"]) - annot.Caption = extractString(raw["Caption"]) - annot.Width = extractInt(raw["Width"]) - - // Studio Pro stores the position as the string "x;y", the same shape an - // entity's Location uses. Reading only the sub-document form returned (0,0) - // for every real annotation, so a rewrite piled them all in one corner. - if locStr, ok := raw["Location"].(string); ok { - parts := strings.Split(locStr, ";") - if len(parts) == 2 { - fmt.Sscanf(parts[0], "%d", &annot.Location.X) - fmt.Sscanf(parts[1], "%d", &annot.Location.Y) - } - } else if loc, ok := raw["Location"].(map[string]any); ok { - annot.Location.X = extractInt(loc["x"]) - annot.Location.Y = extractInt(loc["y"]) - } - - return annot -} - -func parseIndex(raw map[string]any) *domainmodel.Index { - index := &domainmodel.Index{} - - index.ID = model.ID(extractBsonID(raw["$ID"])) - index.Name = extractString(raw["Name"]) - - // Parse index attributes - attrs := extractBsonArray(raw["Attributes"]) - for _, a := range attrs { - if attrMap, ok := a.(map[string]any); ok { - // Try "AttributePointer" first (Mendix format), then "Attribute" - attrID := extractBsonID(attrMap["AttributePointer"]) - if attrID == "" { - attrID = extractBsonID(attrMap["Attribute"]) - } - if attrID != "" { - // Populate both AttributeIDs and Attributes for compatibility - index.AttributeIDs = append(index.AttributeIDs, model.ID(attrID)) - - // Parse as IndexAttribute with ascending/descending info - // Default to ascending (true) unless explicitly set to false - ascending := true - if asc, ok := attrMap["Ascending"].(bool); ok { - ascending = asc - } else if sortOrder := extractString(attrMap["SortOrder"]); sortOrder == "Descending" { - ascending = false - } - - indexAttr := &domainmodel.IndexAttribute{ - AttributeID: model.ID(attrID), - Ascending: ascending, - } - index.Attributes = append(index.Attributes, indexAttr) - } - } - } - - return index -} - -func parseAccessRule(raw map[string]any) *domainmodel.AccessRule { - rule := &domainmodel.AccessRule{} - - rule.ID = model.ID(extractBsonID(raw["$ID"])) - rule.AllowCreate = extractBool(raw["AllowCreate"], false) - rule.AllowRead = extractBool(raw["AllowRead"], false) - rule.AllowWrite = extractBool(raw["AllowWrite"], false) - rule.AllowDelete = extractBool(raw["AllowDelete"], false) - rule.XPathConstraint = extractString(raw["XPathConstraint"]) - - // Parse default member access rights - if dmr := extractString(raw["DefaultMemberAccessRights"]); dmr != "" { - rule.DefaultMemberAccessRights = domainmodel.MemberAccessRights(dmr) - } - - // Parse module roles - try both field names (AllowedModuleRoles for newer, ModuleRoles for older) - rolesField := raw["AllowedModuleRoles"] - if rolesField == nil { - rolesField = raw["ModuleRoles"] - } - roles := extractBsonArray(rolesField) - for _, r := range roles { - // Module roles can be BY_NAME (string) or BY_ID (binary) - if name, ok := r.(string); ok { - rule.ModuleRoleNames = append(rule.ModuleRoleNames, name) - rule.ModuleRoles = append(rule.ModuleRoles, model.ID(name)) - } else { - roleID := extractBsonID(r) - if roleID != "" { - rule.ModuleRoles = append(rule.ModuleRoles, model.ID(roleID)) - } - } - } - - // Parse member accesses - memberAccesses := extractBsonArray(raw["MemberAccesses"]) - for _, ma := range memberAccesses { - maMap := toMap(ma) - if maMap == nil { - continue - } - access := parseMemberAccess(maMap) - rule.MemberAccesses = append(rule.MemberAccesses, access) - } - - return rule -} - -func parseMemberAccess(raw map[string]any) *domainmodel.MemberAccess { - ma := &domainmodel.MemberAccess{} - ma.ID = model.ID(extractBsonID(raw["$ID"])) - - // Access rights - if ar := extractString(raw["AccessRights"]); ar != "" { - ma.AccessRights = domainmodel.MemberAccessRights(ar) - } - - // Attribute - BY_NAME reference (e.g., "Shop.Customer.FirstName") - if attr := extractString(raw["Attribute"]); attr != "" { - ma.AttributeName = attr - ma.AttributeID = model.ID(attr) - } - - // Association - BY_NAME reference (e.g., "Shop.Order_Customer") - if assoc := extractString(raw["Association"]); assoc != "" { - ma.AssociationName = assoc - ma.AssociationID = model.ID(assoc) - } - - return ma -} - -func parseValidationRule(raw map[string]any) *domainmodel.ValidationRule { - rule := &domainmodel.ValidationRule{} - - rule.ID = model.ID(extractBsonID(raw["$ID"])) - - // Attribute can be a qualified name like "DmTest.Cars.CarId" or an ID - attrRef := raw["Attribute"] - if attrID := extractBsonID(attrRef); attrID != "" { - rule.AttributeID = model.ID(attrID) - } else if attrName, ok := attrRef.(string); ok { - // Store qualified name as ID - will need to resolve later - rule.AttributeID = model.ID(attrName) - } - - // Get rule type from RuleInfo.$Type field - // e.g., "DomainModels$RequiredRuleInfo" -> "Required" - if ruleInfo, ok := raw["RuleInfo"].(map[string]any); ok { - ruleType := extractString(ruleInfo["$Type"]) - rule.Type = normalizeValidationRuleType(ruleType) - rule.Rule = parseValidationRuleInfo(rule.Type, ruleInfo) - } - - // Parse error message from "Message" field (not "ErrorMessage") - if errMsg, ok := raw["Message"].(map[string]any); ok { - rule.ErrorMessage = parseText(errMsg) - } else if errMsg, ok := raw["ErrorMessage"].(map[string]any); ok { - // Fallback for older format - rule.ErrorMessage = parseText(errMsg) - } - - return rule -} - -// parseValidationRuleInfo carries the rule's payload onto the model so a -// read-modify-write can rebuild it. -// -// Without this the reader reported the right TYPE and dropped everything that -// made the rule mean something, which is half of the silent RegEx→Required -// downgrade (the writer's fallback was the other half). A type with no case -// here yields a nil payload, which the writer treats as a refusal — the safe -// direction. -// -// The BSON keys are the STORAGE names, which differ from the SDK names for the -// regex reference: Studio Pro stores "RegExIdentifier", not "RegularExpression" -// (see CLAUDE.md, "modelsdk/gen Binds Some Properties Under the Wrong BSON Key"). -func parseValidationRuleInfo(ruleType string, raw map[string]any) domainmodel.ValidationRuleInfo { - switch ruleType { - case "RegEx": - info := &domainmodel.RegexValidationRuleInfo{ - RegularExpressionQualifiedName: extractString(raw["RegExIdentifier"]), - } - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - case "Range": - info := &domainmodel.RangeValidationRuleInfo{ - UseMinValue: extractBool(raw["UseMinValue"], false), - UseMaxValue: extractBool(raw["UseMaxValue"], false), - MinAttributeQualifiedName: extractString(raw["MinAttribute"]), - MaxAttributeQualifiedName: extractString(raw["MaxAttribute"]), - } - if v := extractString(raw["MinValue"]); v != "" { - info.MinValue = &v - } - if v := extractString(raw["MaxValue"]); v != "" { - info.MaxValue = &v - } - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - case "Required", "": - info := &domainmodel.RequiredValidationRuleInfo{} - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - case "Unique": - info := &domainmodel.UniqueValidationRuleInfo{} - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - default: - // MaxLength, EqualsTo — no model payload type, so the writer refuses to - // rewrite an entity carrying one rather than downgrading it. - return nil - } -} - -// normalizeValidationRuleType converts BSON type names to simple rule types. -// e.g., "DomainModels$RequiredRuleInfo" -> "Required" -func normalizeValidationRuleType(fullType string) string { - // Strip prefix "DomainModels$" - if idx := strings.Index(fullType, "$"); idx >= 0 { - fullType = fullType[idx+1:] - } - // Strip suffix "RuleInfo" - if strings.HasSuffix(fullType, "RuleInfo") { - fullType = fullType[:len(fullType)-8] - } - // Strip suffix "Rule" (for backward compatibility) - if strings.HasSuffix(fullType, "Rule") { - fullType = fullType[:len(fullType)-4] - } - return fullType -} - -func parseEventHandler(raw map[string]any) *domainmodel.EventHandler { - handler := &domainmodel.EventHandler{} - - handler.ID = model.ID(extractBsonID(raw["$ID"])) - handler.Moment = domainmodel.EventMoment(extractString(raw["Moment"])) - // BSON field is "Type" (e.g., "Commit", "Create", "Delete", "RollBack") - handler.Event = domainmodel.EventType(extractString(raw["Type"])) - if handler.Event == "" { - handler.Event = domainmodel.EventType(extractString(raw["Event"])) // fallback - } - // Microflow can be either a binary ID (BY_ID_REFERENCE) or a string (BY_NAME_REFERENCE) - if mfStr, ok := raw["Microflow"].(string); ok { - handler.MicroflowName = mfStr - } else { - handler.MicroflowID = model.ID(extractBsonID(raw["Microflow"])) - } - handler.RaiseErrorOnFalse = extractBool(raw["RaiseErrorOnFalse"], false) - // BSON field is "SendInputParameter" (not "PassEventObject") - handler.PassEventObject = extractBool(raw["SendInputParameter"], true) - if _, ok := raw["PassEventObject"]; ok { - handler.PassEventObject = extractBool(raw["PassEventObject"], true) // fallback - } - - return handler -} - -// parseMicroflow parses microflow contents from BSON. - -// deleteBehaviorErrorMessage reads the en_US text out of a delete behaviour's -// error message. The message is an ordinary Texts$Text; MDL carries one string. -func deleteBehaviorErrorMessage(raw any) string { - m, ok := raw.(map[string]any) - if !ok { - return "" - } - items, ok := m["Items"].([]any) - if !ok { - return "" - } - first := "" - for _, it := range items { - tr, ok := it.(map[string]any) - if !ok { - continue - } - text := extractString(tr["Text"]) - if extractString(tr["LanguageCode"]) == "en_US" { - return text - } - if first == "" { - first = text - } - } - return first -} diff --git a/sdk/mpr/parser_domainmodel_test.go b/sdk/mpr/parser_domainmodel_test.go deleted file mode 100644 index f021bbe33c..0000000000 --- a/sdk/mpr/parser_domainmodel_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// Issue #583: parseAttributeType silently dropped the Length value for -// StringAttributeType when Mendix Studio Pro stored it as BSON int64. -// The previous code only handled int32, so every String attribute in a -// Studio Pro-written MPR was reported as String(unlimited) / Length: 0. -// -// Studio Pro and mxcli can both store integers as int32 or int64 depending -// on encoder choice; the parser must handle every BSON numeric width. -func TestParseAttributeType_StringLength_BsonNumericWidths(t *testing.T) { - cases := []struct { - name string - length any - want int - }{ - {"int32 (mxcli writer)", int32(40), 40}, - {"int64 (Studio Pro writer)", int64(40), 40}, - {"int", int(40), 40}, - {"float64 (extended JSON)", float64(40), 40}, - {"missing field = unlimited", nil, 0}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - raw := map[string]any{ - "$Type": "DomainModels$StringAttributeType", - } - if tc.length != nil { - raw["Length"] = tc.length - } - at := parseAttributeType(raw) - st, ok := at.(*domainmodel.StringAttributeType) - if !ok { - t.Fatalf("parseAttributeType returned %T, want *StringAttributeType", at) - } - if st.Length != tc.want { - t.Errorf("Length = %d, want %d (input %T(%v))", st.Length, tc.want, tc.length, tc.length) - } - }) - } -} diff --git a/sdk/mpr/parser_enumeration.go b/sdk/mpr/parser_enumeration.go deleted file mode 100644 index 75a74dffb1..0000000000 --- a/sdk/mpr/parser_enumeration.go +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/scheduledevents" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseEnumeration(unitID, containerID string, contents []byte) (*model.Enumeration, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - enum := &model.Enumeration{} - enum.ID = model.ID(unitID) - enum.TypeName = "Enumerations$Enumeration" - enum.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - enum.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - enum.Documentation = doc - } - // Excluded must survive a read→rebuild→write cycle; defaulting it to false - // un-excludes the document on the next CREATE OR MODIFY (#914). - if excl, ok := raw["Excluded"].(bool); ok { - enum.Excluded = excl - } - - // Parse values - array may start with a version number, skip non-map elements - if values, ok := raw["Values"].(bson.A); ok { - for _, v := range values { - if valMap, ok := v.(map[string]any); ok { - value := parseEnumerationValue(valMap) - enum.Values = append(enum.Values, value) - } - } - } - - return enum, nil -} - -func parseEnumerationValue(raw map[string]any) model.EnumerationValue { - value := model.EnumerationValue{} - - if name, ok := raw["Name"].(string); ok { - value.Name = name - } - if caption, ok := raw["Caption"].(map[string]any); ok { - value.Caption = parseTextMap(caption) - } - - return value -} - -// parseTextMap parses a Text from map[string]interface{} -func parseTextMap(raw map[string]any) *model.Text { - text := &model.Text{ - Translations: make(map[string]string), - } - - if items, ok := raw["Items"].(bson.A); ok { - for _, item := range items { - if transMap, ok := item.(map[string]any); ok { - langCode, _ := transMap["LanguageCode"].(string) - textVal, _ := transMap["Text"].(string) - if langCode != "" { - text.Translations[langCode] = textVal - } - } - } - } - - return text -} - -// parseConstant parses constant contents from BSON. -func (r *Reader) parseConstant(unitID, containerID string, contents []byte) (*model.Constant, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - constant := &model.Constant{} - constant.ID = model.ID(unitID) - constant.TypeName = "Constants$Constant" - constant.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - constant.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - constant.Documentation = doc - } - // Parse Type as a nested BSON object containing $Type field - if typeObj, ok := raw["Type"].(map[string]any); ok { - constant.Type = parseConstantDataType(typeObj) - } - if defaultValue, ok := raw["DefaultValue"].(string); ok { - constant.DefaultValue = defaultValue - } - if exposed, ok := raw["ExposedToClient"].(bool); ok { - constant.ExposedToClient = exposed - } - if excluded, ok := raw["Excluded"].(bool); ok { - constant.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - constant.ExportLevel = exportLevel - } - - return constant, nil -} - -// parseConstantDataType extracts the data type from a constant's Type field. -func parseConstantDataType(typeObj map[string]any) model.ConstantDataType { - dt := model.ConstantDataType{} - typeName, _ := typeObj["$Type"].(string) - - switch typeName { - case "DataTypes$StringType": - dt.Kind = "String" - case "DataTypes$IntegerType": - dt.Kind = "Integer" - case "DataTypes$LongType": - dt.Kind = "Long" - case "DataTypes$DecimalType": - dt.Kind = "Decimal" - case "DataTypes$BooleanType": - dt.Kind = "Boolean" - case "DataTypes$DateTimeType": - dt.Kind = "DateTime" - case "DataTypes$BinaryType": - dt.Kind = "Binary" - case "DataTypes$FloatType": - dt.Kind = "Float" - case "DataTypes$EnumerationType": - dt.Kind = "Enumeration" - // Enumeration reference can be string (qualified name) or binary ID - if enumRef, ok := typeObj["Enumeration"].(string); ok { - dt.EnumRef = enumRef - } - case "DataTypes$ObjectType": - dt.Kind = "Object" - if entityRef, ok := typeObj["Entity"].(string); ok { - dt.EntityRef = entityRef - } - case "DataTypes$ListType": - dt.Kind = "List" - if entityRef, ok := typeObj["Entity"].(string); ok { - dt.EntityRef = entityRef - } - default: - dt.Kind = "Unknown" - } - - return dt -} - -// parseScheduledEvent parses scheduled event contents from BSON. -func (r *Reader) parseScheduledEvent(unitID, containerID string, contents []byte) (*model.ScheduledEvent, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw bson.M - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - // Shared with the modelsdk engine so both read the same keys the shared - // writer produces — including the polymorphic Schedule child, which this - // parser used to drop, and StartDateTime, which Studio Pro stores as a BSON - // datetime. (Interval is int64 in Studio Pro documents; the codec accepts - // every numeric width — issue #585.) - return scheduledevents.Parse(raw, model.ID(unitID), model.ID(containerID)), nil -} - -// resolveContents handles MPR v2 external file references. diff --git a/sdk/mpr/parser_export_mapping.go b/sdk/mpr/parser_export_mapping.go deleted file mode 100644 index 62b64b26e7..0000000000 --- a/sdk/mpr/parser_export_mapping.go +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseExportMapping parses an ExportMappings$ExportMapping unit from BSON. -func (r *Reader) parseExportMapping(unitID, containerID string, contents []byte) (*model.ExportMapping, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - em := &model.ExportMapping{} - em.ID = model.ID(unitID) - em.TypeName = "ExportMappings$ExportMapping" - em.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - em.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - em.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - em.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - em.ExportLevel = exportLevel - } - if v, ok := raw["JsonStructure"].(string); ok { - em.JsonStructure = v - } - if v, ok := raw["XmlSchema"].(string); ok { - em.XmlSchema = v - } - // MessageDefinition2 is version-introduced (11.10+) and carried, not derived: - // nil means the stored document does not have the key (ako/mxcli#279). - if v, ok := raw["MessageDefinition2"].(string); ok { - em.MessageDefinition2 = &v - } - if v, ok := raw["MessageDefinition"].(string); ok { - em.MessageDefinition = v - } - if v, ok := raw["NullValueOption"].(string); ok { - em.NullValueOption = v - } - em.WebServiceSource = parseWebServiceSource(raw) - - // Parse top-level mapping elements (array with int32 version prefix) - if elements, ok := raw["Elements"].(bson.A); ok { - for _, e := range elements { - if elemMap, ok := e.(map[string]any); ok { - elem := parseExportMappingElement(elemMap) - if elem != nil { - em.Elements = append(em.Elements, elem) - } - } - } - } - - return em, nil -} - -// parseExportMappingElement dispatches to the correct parser based on $Type. -func parseExportMappingElement(raw map[string]any) *model.ExportMappingElement { - typeName, _ := raw["$Type"].(string) - switch typeName { - case "ExportMappings$ObjectMappingElement": - return parseExportObjectMappingElement(raw) - case "ExportMappings$ValueMappingElement": - return parseExportValueMappingElement(raw) - default: - return nil - } -} - -func parseExportObjectMappingElement(raw map[string]any) *model.ExportMappingElement { - elem := &model.ExportMappingElement{Kind: "Object"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ExportMappings$ObjectMappingElement" - - if v, ok := raw["Entity"].(string); ok { - elem.Entity = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["Association"].(string); ok { - elem.Association = v - } - - // Parse children recursively (mix of object and value elements) - if children, ok := raw["Children"].(bson.A); ok { - for _, c := range children { - if childMap, ok := c.(map[string]any); ok { - child := parseExportMappingElement(childMap) - if child != nil { - elem.Children = append(elem.Children, child) - } - } - } - } - - return elem -} - -func parseExportValueMappingElement(raw map[string]any) *model.ExportMappingElement { - elem := &model.ExportMappingElement{Kind: "Value"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ExportMappings$ValueMappingElement" - - if v, ok := raw["Attribute"].(string); ok { - elem.Attribute = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["OriginalValue"].(string); ok { - elem.OriginalValue = v - } - - // Extract the primitive type from the nested Type object - if typeObj, ok := raw["Type"].(map[string]any); ok { - elem.DataType = extractPrimitiveTypeName(typeObj) - } - - return elem -} diff --git a/sdk/mpr/parser_import_mapping.go b/sdk/mpr/parser_import_mapping.go deleted file mode 100644 index ceacdd1aeb..0000000000 --- a/sdk/mpr/parser_import_mapping.go +++ /dev/null @@ -1,244 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseImportMapping parses an ImportMappings$ImportMapping unit from BSON. -func (r *Reader) parseImportMapping(unitID, containerID string, contents []byte) (*model.ImportMapping, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - im := &model.ImportMapping{} - im.ID = model.ID(unitID) - im.TypeName = "ImportMappings$ImportMapping" - im.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - im.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - im.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - im.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - im.ExportLevel = exportLevel - } - if v, ok := raw["JsonStructure"].(string); ok { - im.JsonStructure = v - } - if v, ok := raw["XmlSchema"].(string); ok { - im.XmlSchema = v - } - if v, ok := raw["MessageDefinition"].(string); ok { - im.MessageDefinition = v - } - im.WebServiceSource = parseWebServiceSource(raw) - // MessageDefinition2 is version-introduced (11.10+) and carried, not derived: - // nil means the stored document does not have the key (ako/mxcli#279). - if v, ok := raw["MessageDefinition2"].(string); ok { - im.MessageDefinition2 = &v - } - // The mapping's input object (#265). Only DataTypes$ObjectType carries an - // entity — the DataTypes$UnknownType marker an unparameterised mapping - // stores means "none". - if pt, ok := raw["ParameterType"].(map[string]any); ok { - if t, _ := pt["$Type"].(string); t == "DataTypes$ObjectType" { - if e, _ := pt["Entity"].(string); e != "" { - im.ParameterEntity = e - } - } - } - - // Parse top-level mapping elements (may start with int32 version prefix) - if elements, ok := raw["Elements"].(bson.A); ok { - for _, e := range elements { - if elemMap, ok := e.(map[string]any); ok { - elem := parseImportMappingElement(elemMap) - if elem != nil { - im.Elements = append(im.Elements, elem) - } - } - } - } - - return im, nil -} - -// parseImportMappingElement dispatches to the correct parser based on $Type. -func parseImportMappingElement(raw map[string]any) *model.ImportMappingElement { - typeName, _ := raw["$Type"].(string) - switch typeName { - case "ImportMappings$ObjectMappingElement": - return parseImportObjectMappingElement(raw) - case "ImportMappings$ValueMappingElement": - return parseImportValueMappingElement(raw) - default: - return nil - } -} - -func parseImportObjectMappingElement(raw map[string]any) *model.ImportMappingElement { - elem := &model.ImportMappingElement{Kind: "Object"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ImportMappings$ObjectMappingElement" - - if v, ok := raw["Entity"].(string); ok { - elem.Entity = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["ObjectHandling"].(string); ok { - elem.ObjectHandling = v - if v == "Find" { - if backup, ok := raw["ObjectHandlingBackup"].(string); ok && backup == "Create" { - elem.ObjectHandling = "FindOrCreate" - } - } - } - // The backup is what the element does when the object is NOT found, and it - // is carried in its own right now that MDL can say `or ignore` / `or error` - // (#261). FindOrCreate above stays as the shorthand for Find + Create. - if v, ok := raw["ObjectHandlingBackup"].(string); ok { - elem.ObjectHandlingBackup = v - } - if v, ok := raw["ObjectHandlingBackupAllowOverride"].(bool); ok { - elem.BackupAllowOverride = v - } - 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 { - for _, c := range children { - if childMap, ok := c.(map[string]any); ok { - child := parseImportMappingElement(childMap) - if child != nil { - elem.Children = append(elem.Children, child) - } - } - } - } - - return elem -} - -func parseImportValueMappingElement(raw map[string]any) *model.ImportMappingElement { - elem := &model.ImportMappingElement{Kind: "Value"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ImportMappings$ValueMappingElement" - - if v, ok := raw["Attribute"].(string); ok { - elem.Attribute = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - 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 { - elem.DataType = extractPrimitiveTypeName(typeObj) - } - - return elem -} - -// extractPrimitiveTypeName converts a DataTypes$* BSON type object to a simple type string. -func extractPrimitiveTypeName(typeObj map[string]any) string { - typeName, _ := typeObj["$Type"].(string) - switch typeName { - case "DataTypes$StringType": - return "String" - case "DataTypes$IntegerType": - return "Integer" - case "DataTypes$LongType": - return "Long" - case "DataTypes$DecimalType": - return "Decimal" - case "DataTypes$BooleanType": - return "Boolean" - case "DataTypes$DateTimeType": - return "DateTime" - case "DataTypes$BinaryType": - return "Binary" - default: - return "String" - } -} - -// parseWebServiceSource reads a mapping's SOAP binding. -// -// Read-only, and read for one reason: a rewrite that dropped these keys turned a -// working integration into CE6896 + CE0270. ImportedWebService is stored under -// `wsdlFile`'s SDK name — the BSON key is ImportedWebService — and the root -// element under RootElementName (`xsdRootElementName` in the SDK). -func parseWebServiceSource(raw map[string]any) model.WebServiceMappingSource { - var w model.WebServiceMappingSource - if v, ok := raw["ImportedWebService"].(string); ok { - w.ImportedWebService = v - } - if v, ok := raw["ServiceName"].(string); ok { - w.ServiceName = v - } - if v, ok := raw["OperationName"].(string); ok { - w.OperationName = v - } - if v, ok := raw["RootElementName"].(string); ok { - w.RootElementName = v - } - if v, ok := raw["ParameterName"].(string); ok { - w.ParameterName = v - } - if v, ok := raw["IsHeader"].(bool); ok { - w.IsHeader = v - } - return w -} diff --git a/sdk/mpr/parser_import_mapping_test.go b/sdk/mpr/parser_import_mapping_test.go deleted file mode 100644 index 9a054a5b91..0000000000 --- a/sdk/mpr/parser_import_mapping_test.go +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -func TestParseImportObjectMappingElement_FindWithCreateBackupBecomesFindOrCreate(t *testing.T) { - elem := parseImportObjectMappingElement(map[string]any{ - "$ID": "ignored", - "$Type": "ImportMappings$ObjectMappingElement", - "Entity": "MyModule.Pet", - "ObjectHandling": "Find", - "ObjectHandlingBackup": "Create", - }) - - if elem == nil { - t.Fatal("expected element, got nil") - } - if elem.ObjectHandling != "FindOrCreate" { - t.Fatalf("ObjectHandling = %q, want %q", elem.ObjectHandling, "FindOrCreate") - } -} diff --git a/sdk/mpr/parser_javaactions.go b/sdk/mpr/parser_javaactions.go deleted file mode 100644 index 2ee31900b1..0000000000 --- a/sdk/mpr/parser_javaactions.go +++ /dev/null @@ -1,578 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Java action parsing. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// ReadJavaAction reads a Java action by its ID. -func (r *Reader) ReadJavaAction(id model.ID) (*javaactions.JavaAction, error) { - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - for _, u := range units { - if u.ID == string(id) { - return r.parseJavaActionFull(u.ID, u.ContainerID, u.Contents) - } - } - - return nil, fmt.Errorf("java action not found: %s", id) -} - -// ReadJavaActionByName reads a Java action by its qualified name (Module.ActionName). -func (r *Reader) ReadJavaActionByName(qualifiedName string) (*javaactions.JavaAction, error) { - // First, list all Java actions - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - // Build module and folder hierarchy - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - // Get all folders for hierarchy resolution - folders, err := r.ListFolders() - if err != nil { - return nil, err - } - folderContainers := make(map[model.ID]model.ID) - for _, f := range folders { - folderContainers[f.ID] = f.ContainerID - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - name := extractString(raw["Name"]) - - // Find module name by walking up the container hierarchy - modName := "" - containerID := model.ID(u.ContainerID) - for range 20 { // Max depth to prevent infinite loops - if mn, ok := moduleNames[containerID]; ok { - modName = mn - break - } - // Check if container is a folder and get its parent - if parent, ok := folderContainers[containerID]; ok { - containerID = parent - } else { - break - } - } - - fullName := modName + "." + name - if fullName == qualifiedName { - return r.parseJavaActionFull(u.ID, u.ContainerID, contents) - } - } - - return nil, fmt.Errorf("java action not found: %s", qualifiedName) -} - -// parseJavaActionFull parses a Java action with full details. -func (r *Reader) parseJavaActionFull(unitID, containerID string, contents []byte) (*javaactions.JavaAction, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ja := &javaactions.JavaAction{} - ja.ID = model.ID(unitID) - ja.TypeName = "JavaActions$JavaAction" - ja.ContainerID = model.ID(containerID) - - // Basic fields - ja.Name = extractString(raw["Name"]) - ja.Documentation = extractString(raw["Documentation"]) - ja.Excluded = extractBool(raw["Excluded"], false) - ja.ExportLevel = extractString(raw["ExportLevel"]) - ja.ActionDefaultReturnName = extractString(raw["ActionDefaultReturnName"]) - - // Parse return type - handle both map and primitive.D - switch rt := raw["JavaReturnType"].(type) { - case map[string]any: - ja.ReturnType = parseCodeActionReturnType(rt) - case primitive.D: - ja.ReturnType = parseCodeActionReturnType(primitiveToMap(rt)) - } - - // Parse parameters - handle both map and primitive.D and primitive.A - switch params := raw["Parameters"].(type) { - case []any: - for _, p := range params { - pMap := toMap(p) - if pMap != nil { - param := parseJavaActionParameter(pMap) - if param != nil { - ja.Parameters = append(ja.Parameters, param) - } - } - } - case primitive.A: - for _, p := range params { - pMap := toMap(p) - if pMap != nil { - param := parseJavaActionParameter(pMap) - if param != nil { - ja.Parameters = append(ja.Parameters, param) - } - } - } - } - - // Parse type parameters (generics) - preserve IDs for BY_ID references - switch typeParams := raw["TypeParameters"].(type) { - case []any: - for _, tp := range typeParams { - tpMap := toMap(tp) - if tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - tpDef := &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - } - ja.TypeParameters = append(ja.TypeParameters, tpDef) - } - } - } - case primitive.A: - for _, tp := range typeParams { - tpMap := toMap(tp) - if tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - tpDef := &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - } - ja.TypeParameters = append(ja.TypeParameters, tpDef) - } - } - } - } - - // Parse MicroflowActionInfo - if mai := toMap(raw["MicroflowActionInfo"]); mai != nil { - ja.MicroflowActionInfo = parseMicroflowActionInfo(mai) - } - - // Resolve type parameter names for EntityTypeParameterType and ParameterizedEntityType parameters - for _, param := range ja.Parameters { - switch pt := param.ParameterType.(type) { - case *javaactions.EntityTypeParameterType: - pt.TypeParameterName = ja.FindTypeParameterName(pt.TypeParameterID) - case *javaactions.TypeParameter: - if pt.TypeParameterID != "" && pt.TypeParameter == "" { - pt.TypeParameter = ja.FindTypeParameterName(pt.TypeParameterID) - } - } - } - - // Resolve type parameter name for return type if it's a ParameterizedEntityType - if tp, ok := ja.ReturnType.(*javaactions.TypeParameter); ok { - if tp.TypeParameterID != "" && tp.TypeParameter == "" { - tp.TypeParameter = ja.FindTypeParameterName(tp.TypeParameterID) - } - } - - return ja, nil -} - -// parseCodeActionReturnType parses a Java action return type. -func parseCodeActionReturnType(raw map[string]any) javaactions.CodeActionReturnType { - if raw == nil { - return nil - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "CodeActions$VoidType": - return &javaactions.VoidType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$BooleanType": - return &javaactions.BooleanType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$IntegerType": - return &javaactions.IntegerType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$LongType": - return &javaactions.LongType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DecimalType": - return &javaactions.DecimalType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$StringType": - return &javaactions.StringType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DateTimeType": - return &javaactions.DateTimeType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EntityType", "CodeActions$ConcreteEntityType": - et := &javaactions.EntityType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Entity = extractString(raw["Entity"]) - return et - case "CodeActions$ListType": - lt := &javaactions.ListType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ListType can have Entity directly or Parameter containing ConcreteEntityType - if entity := extractString(raw["Entity"]); entity != "" { - lt.Entity = entity - } else if param := toMap(raw["Parameter"]); param != nil { - lt.Entity = extractString(param["Entity"]) - } - return lt - case "CodeActions$FileDocumentType": - return &javaactions.FileDocumentType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EnumerationType": - et := &javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Enumeration = extractString(raw["Enumeration"]) - return et - case "CodeActions$TypeParameter": - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - tp.TypeParameter = extractString(raw["TypeParameter"]) - return tp - case "CodeActions$ParameterizedEntityType": - // Return type referencing a type parameter (e.g., returns the entity passed as type param) - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ParameterizedEntityType stores the type parameter as a binary ID pointer - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - tp.TypeParameterID = model.ID(id) - return tp - } - - // Unknown type - return nil - return nil -} - -// parseJavaActionParameter parses a Java action parameter. -func parseJavaActionParameter(raw map[string]any) *javaactions.JavaActionParameter { - if raw == nil { - return nil - } - - // Skip array markers (items without $ID) - if raw["$ID"] == nil { - return nil - } - - param := &javaactions.JavaActionParameter{} - param.ID = model.ID(extractBsonID(raw["$ID"])) - param.TypeName = extractString(raw["$Type"]) - param.Name = extractString(raw["Name"]) - param.Description = extractString(raw["Description"]) - param.Category = extractString(raw["Category"]) - param.IsRequired = extractBool(raw["IsRequired"], false) - - // Parse parameter type - handle both map and primitive.D - switch pt := raw["ParameterType"].(type) { - case map[string]any: - param.ParameterType = parseCodeActionParameterType(pt) - case primitive.D: - param.ParameterType = parseCodeActionParameterType(primitiveToMap(pt)) - } - - return param -} - -// parseCodeActionParameterType parses a Java action parameter type. -func parseCodeActionParameterType(raw map[string]any) javaactions.CodeActionParameterType { - if raw == nil { - return nil - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "CodeActions$BasicParameterType": - // BasicParameterType wraps the actual type in a "Type" property - innerType := toMap(raw["Type"]) - if innerType != nil { - return parseInnerParameterType(innerType) - } - return nil - case "CodeActions$BooleanType": - return &javaactions.BooleanType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$IntegerType": - return &javaactions.IntegerType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$LongType": - return &javaactions.LongType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DecimalType": - return &javaactions.DecimalType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$StringType": - return &javaactions.StringType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DateTimeType": - return &javaactions.DateTimeType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EntityType", "CodeActions$ConcreteEntityType": - et := &javaactions.EntityType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Entity = extractString(raw["Entity"]) - return et - case "CodeActions$ListType": - lt := &javaactions.ListType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ListType can have Entity directly or Parameter containing ConcreteEntityType - if entity := extractString(raw["Entity"]); entity != "" { - lt.Entity = entity - } else if param := toMap(raw["Parameter"]); param != nil { - lt.Entity = extractString(param["Entity"]) - } - return lt - case "CodeActions$StringTemplateParameterType": - st := &javaactions.StringTemplateParameterType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - st.Grammar = extractString(raw["Grammar"]) - return st - case "CodeActions$FileDocumentType": - return &javaactions.FileDocumentType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EnumerationType": - et := &javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Enumeration = extractString(raw["Enumeration"]) - return et - case "CodeActions$MicroflowType", "JavaActions$MicroflowJavaActionParameterType": - return &javaactions.MicroflowType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$TypeParameter": - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - tp.TypeParameter = extractString(raw["TypeParameter"]) - return tp - case "CodeActions$EntityTypeParameterType": - etpt := &javaactions.EntityTypeParameterType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // Studio Pro uses "TypeParameterPointer"; fall back to "TypeParameter" for backward compat - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - etpt.TypeParameterID = model.ID(id) - return etpt - case "JavaScriptActions$NanoflowJavaScriptActionParameterType": - return &javaactions.NanoflowType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - } - - // Unknown type - return nil - return nil -} - -// parseInnerParameterType parses the inner type from BasicParameterType. -func parseInnerParameterType(raw map[string]any) javaactions.CodeActionParameterType { - if raw == nil { - return nil - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "CodeActions$BooleanType": - return &javaactions.BooleanType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$IntegerType": - return &javaactions.IntegerType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DecimalType": - return &javaactions.DecimalType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$StringType": - return &javaactions.StringType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DateTimeType": - return &javaactions.DateTimeType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$MicroflowType", "JavaActions$MicroflowJavaActionParameterType": - return &javaactions.MicroflowType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$ConcreteEntityType", "CodeActions$EntityType": - et := &javaactions.EntityType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Entity = extractString(raw["Entity"]) - return et - case "CodeActions$ListType": - lt := &javaactions.ListType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ListType contains Parameter with ConcreteEntityType - if param := toMap(raw["Parameter"]); param != nil { - lt.Entity = extractString(param["Entity"]) - } else if entity := extractString(raw["Entity"]); entity != "" { - lt.Entity = entity - } - return lt - case "CodeActions$EntityTypeParameterType": - etpt := &javaactions.EntityTypeParameterType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // Studio Pro uses "TypeParameterPointer"; fall back to "TypeParameter" for backward compat - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - etpt.TypeParameterID = model.ID(id) - return etpt - case "CodeActions$ParameterizedEntityType": - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ParameterizedEntityType stores the type parameter as a binary ID pointer - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - tp.TypeParameterID = model.ID(id) - return tp - } - - return nil -} - -// ListJavaActionsFull returns all Java actions with full details, including virtual System module actions. -func (r *Reader) ListJavaActionsFull() ([]*javaactions.JavaAction, error) { - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - var result []*javaactions.JavaAction - for _, u := range units { - ja, err := r.parseJavaActionFull(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse java action %s: %w", u.ID, err) - } - result = append(result, ja) - } - - // Append virtual System module Java actions (not stored in the MPR database) - result = append(result, BuildSystemJavaActionsFull()...) - - return result, nil -} - -// toMap converts various BSON types to map[string]interface{}. -func toMap(v any) map[string]any { - if v == nil { - return nil - } - switch m := v.(type) { - case map[string]any: - return m - case primitive.D: - return primitiveToMap(m) - default: - return nil - } -} - -// primitiveToMap converts primitive.D to map[string]interface{}. -func primitiveToMap(d primitive.D) map[string]any { - result := make(map[string]any) - for _, e := range d { - result[e.Key] = e.Value - } - return result -} - -// extractBinary returns the bytes of a BSON binary value, or nil when the field -// is absent, BSON null, or some other type. This tolerates the legacy -// MicroflowActionInfo shape (null/absent ImageData) on read so already-corrupted -// units can still be loaded and repaired. See issue #656. -func extractBinary(v any) []byte { - if b, ok := v.(primitive.Binary); ok { - return b.Data - } - return nil -} - -// parseMicroflowActionInfo reads a MicroflowActionInfo sub-document. It accepts -// both the current CodeActions$ binary shape and the legacy JavaActions$ shape -// (an `Icon` string and a null/absent `ImageData`), reading the four icon/image -// bitmaps as binaries and silently dropping the obsolete `Icon` key. Shared by -// the Java- and JavaScript-action parsers. See issue #656. -func parseMicroflowActionInfo(mai map[string]any) *javaactions.MicroflowActionInfo { - return &javaactions.MicroflowActionInfo{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mai["$ID"]))}, - Caption: extractString(mai["Caption"]), - Category: extractString(mai["Category"]), - IconData: extractBinary(mai["IconData"]), - IconDataDark: extractBinary(mai["IconDataDark"]), - ImageData: extractBinary(mai["ImageData"]), - ImageDataDark: extractBinary(mai["ImageDataDark"]), - } -} diff --git a/sdk/mpr/parser_javaactions_test.go b/sdk/mpr/parser_javaactions_test.go deleted file mode 100644 index 16456a6c55..0000000000 --- a/sdk/mpr/parser_javaactions_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/javaactions" -) - -func TestBuildSystemJavaActions_VerifyPassword(t *testing.T) { - actions := BuildSystemJavaActions() - - var found bool - for _, a := range actions { - if a.Name == "VerifyPassword" && string(a.ContainerID) == SystemModuleID { - found = true - break - } - } - if !found { - t.Error("BuildSystemJavaActions: System.VerifyPassword not present") - } -} - -func TestBuildSystemJavaActionsFull_VerifyPassword(t *testing.T) { - actions := BuildSystemJavaActionsFull() - - var found bool - for _, a := range actions { - if a.Name != "VerifyPassword" || string(a.ContainerID) != SystemModuleID { - continue - } - found = true - if len(a.Parameters) != 2 { - t.Errorf("VerifyPassword: want 2 parameters, got %d", len(a.Parameters)) - } - if _, ok := a.ReturnType.(*javaactions.BooleanType); !ok { - t.Errorf("VerifyPassword: want BooleanType return, got %T", a.ReturnType) - } - } - if !found { - t.Error("BuildSystemJavaActionsFull: System.VerifyPassword not present") - } -} - -func TestBuildSystemJavaActions_DeterministicIDs(t *testing.T) { - a1 := BuildSystemJavaActions() - a2 := BuildSystemJavaActions() - for i := range a1 { - if a1[i].ID != a2[i].ID { - t.Errorf("non-deterministic ID for %s", a1[i].Name) - } - } -} - -func TestParseCodeActionParameterType_JavaActionMicroflowParameter(t *testing.T) { - value := parseCodeActionParameterType(map[string]any{ - "$ID": "type-1", - "$Type": "JavaActions$MicroflowJavaActionParameterType", - }) - - if _, ok := value.(*javaactions.MicroflowType); !ok { - t.Fatalf("value = %T, want *MicroflowType", value) - } -} diff --git a/sdk/mpr/parser_listoperation_test.go b/sdk/mpr/parser_listoperation_test.go deleted file mode 100644 index 8fd6c0394c..0000000000 --- a/sdk/mpr/parser_listoperation_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -func TestParseListOperation_FindByAttribute(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$Find", - "$ID": nil, - "ListName": "Orders", - "Attribute": "MyModule.Order.Status", - "Expression": "'Active'", - } - op := parseListOperation(raw) - got, ok := op.(*microflows.FindByAttributeOperation) - if !ok { - t.Fatalf("expected *FindByAttributeOperation, got %T", op) - } - if got.ListVariable != "Orders" { - t.Errorf("ListVariable: got %q, want %q", got.ListVariable, "Orders") - } - if got.Attribute != "MyModule.Order.Status" { - t.Errorf("Attribute: got %q, want %q", got.Attribute, "MyModule.Order.Status") - } - if got.Expression != "'Active'" { - t.Errorf("Expression: got %q, want %q", got.Expression, "'Active'") - } -} - -func TestParseListOperation_FindByAssociation(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$Find", - "$ID": nil, - "ListName": "Orders", - "Association": "MyModule.Order_Customer", - "Expression": "$Customer", - } - op := parseListOperation(raw) - got, ok := op.(*microflows.FindByAttributeOperation) - if !ok { - t.Fatalf("expected *FindByAttributeOperation, got %T", op) - } - if got.Association != "MyModule.Order_Customer" { - t.Errorf("Association: got %q, want %q", got.Association, "MyModule.Order_Customer") - } -} - -func TestParseListOperation_FilterByAttribute(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$Filter", - "$ID": nil, - "ListName": "Orders", - "Attribute": "MyModule.Order.IsActive", - "Expression": "true", - } - op := parseListOperation(raw) - got, ok := op.(*microflows.FilterByAttributeOperation) - if !ok { - t.Fatalf("expected *FilterByAttributeOperation, got %T", op) - } - if got.ListVariable != "Orders" { - t.Errorf("ListVariable: got %q, want %q", got.ListVariable, "Orders") - } - if got.Attribute != "MyModule.Order.IsActive" { - t.Errorf("Attribute: got %q, want %q", got.Attribute, "MyModule.Order.IsActive") - } -} - -func TestParseListOperation_Range(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$ListRange", - "$ID": nil, - "ListName": "Orders", - "CustomRange": map[string]any{ - "$Type": "Microflows$CustomRange", - "OffsetExpression": "0", - "LimitExpression": "10", - }, - } - op := parseListOperation(raw) - got, ok := op.(*microflows.ListRangeOperation) - if !ok { - t.Fatalf("expected *ListRangeOperation, got %T", op) - } - if got.ListVariable != "Orders" { - t.Errorf("ListVariable: got %q, want %q", got.ListVariable, "Orders") - } - if got.OffsetExpression != "0" { - t.Errorf("OffsetExpression: got %q, want %q", got.OffsetExpression, "0") - } - if got.LimitExpression != "10" { - t.Errorf("LimitExpression: got %q, want %q", got.LimitExpression, "10") - } -} diff --git a/sdk/mpr/parser_menu_signout_test.go b/sdk/mpr/parser_menu_signout_test.go deleted file mode 100644 index ed85a207dc..0000000000 --- a/sdk/mpr/parser_menu_signout_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// menuItemRaw builds the minimum a Menus$MenuItem needs to parse: a caption with -// a real translation (parseNavMenuItem deliberately returns nil for an item with -// no caption, no page and no children) plus the action under test. -func menuItemRaw(actionType string) map[string]any { - return map[string]any{ - "Caption": map[string]any{ - "$Type": "Texts$Text", - "Items": []any{ - int32(3), - map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": "Sign out"}, - }, - }, - "Action": map[string]any{"$Type": actionType}, - } -} - -// The legacy reader is the other half of reading a sign-out MENU ITEM back. -// Before this case it fell to the raw-type-name default, so the item was -// described as a plain `menu item 'x';` and DESCRIBE -> exec turned ako/TestApp's -// working sign-out entry into a dead one — silently, with mx check clean. -func TestParseNavMenuItem_SignOut(t *testing.T) { - mi := parseNavMenuItem(menuItemRaw("Forms$SignOutClientAction")) - if mi == nil { - t.Fatal("parseNavMenuItem returned nil") - } - if mi.ActionType != "SignOutAction" { - t.Errorf("ActionType = %q, want SignOutAction — the writers and DESCRIBE key on that string", - mi.ActionType) - } -} - -// CONTROL: the action types already read must be unchanged, and an unknown one -// must still fall through to its raw name rather than being absorbed. -func TestParseNavMenuItem_OtherActionsUnchanged(t *testing.T) { - cases := []struct { - typeName string - want string - }{ - {"Forms$FormAction", "PageAction"}, - {"Forms$MicroflowAction", "MicroflowAction"}, - {"Forms$NoAction", "NoAction"}, - {"Forms$SomethingElseAction", "Forms$SomethingElseAction"}, - } - for _, c := range cases { - mi := parseNavMenuItem(menuItemRaw(c.typeName)) - if mi.ActionType != c.want { - t.Errorf("%s -> %q, want %q", c.typeName, mi.ActionType, c.want) - } - } -} diff --git a/sdk/mpr/parser_microflow.go b/sdk/mpr/parser_microflow.go deleted file mode 100644 index 070b572fe2..0000000000 --- a/sdk/mpr/parser_microflow.go +++ /dev/null @@ -1,1246 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strconv" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func (r *Reader) parseMicroflow(unitID, containerID string, contents []byte) (*microflows.Microflow, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - return ParseMicroflowBSON(contents, model.ID(unitID), model.ID(containerID)) -} - -// ParseMicroflowBSON parses raw microflow BSON bytes into a Microflow. -// Unlike (*Reader).parseMicroflow it does not require a Reader, so it can -// parse arbitrary blobs (e.g. historical versions read via `git show`). -func ParseMicroflowBSON(contents []byte, unitID, containerID model.ID) (*microflows.Microflow, error) { - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - return ParseMicroflowFromRaw(raw, unitID, containerID), nil -} - -// ParseMicroflowFromRaw builds a Microflow from an already-unmarshalled BSON map. -// Useful when the caller already has the decoded map (e.g. diff-local). -func ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow { - mf := µflows.Microflow{} - mf.ID = unitID - mf.TypeName = "Microflows$Microflow" - mf.ContainerID = containerID - - if name, ok := raw["Name"].(string); ok { - mf.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - mf.Documentation = doc - } - if concurrent, ok := raw["AllowConcurrentExecution"].(bool); ok { - mf.AllowConcurrentExecution = concurrent - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - mf.MarkAsUsed = markAsUsed - } - if excluded, ok := raw["Excluded"].(bool); ok { - mf.Excluded = excluded - } - // A security setting: without reading it, a rewrite turned "apply entity - // access" OFF, widening what the microflow may read and write with nothing - // reporting it. - if applyEntityAccess, ok := raw["ApplyEntityAccess"].(bool); ok { - mf.ApplyEntityAccess = applyEntityAccess - } - - // Parse allowed module roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedModuleRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - mf.AllowedModuleRoles = append(mf.AllowedModuleRoles, model.ID(name)) - } - } - - // Parse parameters from MicroflowParameterCollection (new format) or MicroflowParameters/Parameters (old format) - var paramsArray any - if mpc, ok := raw["MicroflowParameterCollection"]; ok { - // New format: MicroflowParameterCollection contains Parameters array - if mpcMap := extractBsonMap(mpc); mpcMap != nil { - paramsArray = mpcMap["Parameters"] - } - } else { - // Old format: direct MicroflowParameters or Parameters field - paramKey := "MicroflowParameters" - if _, ok := raw[paramKey]; !ok { - paramKey = "Parameters" - } - paramsArray = raw[paramKey] - } - for _, p := range extractBsonSlice(paramsArray) { - if paramMap := extractBsonMap(p); paramMap != nil { - param := parseMicroflowParameter(paramMap, len(mf.Parameters)) - mf.Parameters = append(mf.Parameters, param) - } - } - - // Parse return type (Mendix uses "MicroflowReturnType") - if rt, ok := raw["MicroflowReturnType"].(map[string]any); ok { - mf.ReturnType = parseMicroflowDataType(rt) - } - - // Parse return variable name - if rvn, ok := raw["ReturnVariableName"].(string); ok { - mf.ReturnVariableName = rvn - } - - // Parse object collection (flow elements) - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - mf.ObjectCollection = parseMicroflowObjectCollection(oc) - } - - // Also extract parameters from ObjectCollection.Objects (modern format) - // Parameters are stored as Microflows$MicroflowParameter in ObjectCollection - if len(mf.Parameters) == 0 { - if ocRaw := extractBsonMap(raw["ObjectCollection"]); ocRaw != nil { - for _, obj := range extractBsonSlice(ocRaw["Objects"]) { - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - param := parseMicroflowParameter(objMap, len(mf.Parameters)) - mf.Parameters = append(mf.Parameters, param) - } - } - } - } - } - - // Parse Flows array (SequenceFlows and AnnotationFlows are at root level, not in ObjectCollection) - if flowsRaw := raw["Flows"]; flowsRaw != nil { - if mf.ObjectCollection == nil { - mf.ObjectCollection = µflows.MicroflowObjectCollection{} - } - for _, f := range extractBsonSlice(flowsRaw) { - if flowMap := extractBsonMap(f); flowMap != nil { - typeName, _ := flowMap["$Type"].(string) - switch typeName { - case "Microflows$AnnotationFlow": - if af := parseAnnotationFlow(flowMap); af != nil { - mf.ObjectCollection.AnnotationFlows = append(mf.ObjectCollection.AnnotationFlows, af) - } - default: - if flow := parseSequenceFlow(flowMap); flow != nil { - mf.ObjectCollection.Flows = append(mf.ObjectCollection.Flows, flow) - } - } - } - } - } - - return mf -} - -// parseSequenceFlow parses a SequenceFlow from raw BSON data. -func parseSequenceFlow(raw map[string]any) *microflows.SequenceFlow { - flow := µflows.SequenceFlow{} - flow.ID = model.ID(extractBsonID(raw["$ID"])) - - // OriginPointer and DestinationPointer are binary IDs - flow.OriginID = model.ID(extractBsonID(raw["OriginPointer"])) - flow.DestinationID = model.ID(extractBsonID(raw["DestinationPointer"])) - - flow.OriginConnectionIndex = extractInt(raw["OriginConnectionIndex"]) - flow.DestinationConnectionIndex = extractInt(raw["DestinationConnectionIndex"]) - if isErr, ok := raw["IsErrorHandler"].(bool); ok { - flow.IsErrorHandler = isErr - } - - // Parse decision branch values. Newer Mendix versions store branch data - // in CaseValues ([marker, case]), while older projects use a single - // inline NewCaseValue document. - if caseVals := raw["CaseValues"]; caseVals != nil { - flow.CaseValue = parseCaseValues(caseVals) - } else if caseVal := raw["NewCaseValue"]; caseVal != nil { - flow.CaseValue = parseCaseValue(caseVal) - } - - // Parse BezierCurve control vectors from Line - if lineMap := extractBsonMap(raw["Line"]); lineMap != nil { - if v, ok := lineMap["OriginControlVector"].(string); ok { - flow.OriginControlVector = v - } - if v, ok := lineMap["DestinationControlVector"].(string); ok { - flow.DestinationControlVector = v - } - } - - return flow -} - -// parseCaseValues parses CaseValues from raw BSON data. -// CaseValues is stored as an array: [count_marker, case_object, ...] -// Usually [2] for empty, or [2, {case}] for a single case value. -func parseCaseValues(raw any) microflows.CaseValue { - arr := extractBsonSlice(raw) - if arr == nil { - return nil - } - - // Skip the count marker (first element), process actual case values - if len(arr) < 2 { - return nil // Empty array or just count marker - } - - // Parse the first case value (element at index 1) - return parseCaseValue(arr[1]) -} - -// parseCaseValue parses a single CaseValue from raw BSON data. -func parseCaseValue(raw any) microflows.CaseValue { - caseMap := extractBsonMap(raw) - if caseMap == nil { - return nil - } - - typeName, _ := caseMap["$Type"].(string) - id := model.ID(extractBsonID(caseMap["$ID"])) - switch typeName { - case "Microflows$NoCase": - return µflows.NoCase{BaseElement: model.BaseElement{ID: id}} - case "Microflows$ExpressionCase": - if expr, ok := caseMap["Expression"].(string); ok { - return µflows.ExpressionCase{ - BaseElement: model.BaseElement{ID: id}, - Expression: expr, - } - } - case "Microflows$EnumerationCase": - if val, ok := caseMap["Value"].(string); ok { - return µflows.EnumerationCase{ - BaseElement: model.BaseElement{ID: id}, - Value: val, - } - } - case "Microflows$InheritanceCase": - entityName := extractString(caseMap["Value"]) - if entityName == "" { - entityName = extractString(caseMap["Entity"]) - } - return µflows.InheritanceCase{ - BaseElement: model.BaseElement{ID: id}, - EntityID: model.ID(extractBsonID(caseMap["Entity"])), - EntityQualifiedName: entityName, - } - } - return nil -} - -// parseMicroflowParameter reads one Microflows$MicroflowParameter. idx is the -// parameter's ordinal in its flow, needed to tell a stored position that says -// something from one that is mxcli's own layout arithmetic handed back — see -// microflows.AuthoredParameterPosition. -func parseMicroflowParameter(raw map[string]any, idx int) *microflows.MicroflowParameter { - param := µflows.MicroflowParameter{} - - // Use extractBsonID to handle binary IDs - param.ID = model.ID(extractBsonID(raw["$ID"])) - if name, ok := raw["Name"].(string); ok { - param.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - param.Documentation = doc - } - // Parse parameter type - Mendix uses "VariableType" in ObjectCollection.Objects format - // and "ParameterType" in older formats - if pt := extractBsonMap(raw["VariableType"]); pt != nil { - param.Type = parseMicroflowDataType(pt) - } else if pt := extractBsonMap(raw["ParameterType"]); pt != nil { - param.Type = parseMicroflowDataType(pt) - } - if rmp, ok := raw["RelativeMiddlePoint"]; ok { - param.Position = microflows.AuthoredParameterPosition(parsePoint(rmp), idx) - } - - return param -} - -func parseMicroflowObjectCollection(raw map[string]any) *microflows.MicroflowObjectCollection { - collection := µflows.MicroflowObjectCollection{} - - // Handle various ID formats (string, binary, etc.) - collection.ID = model.ID(extractBsonID(raw["$ID"])) - - // Parse objects array (int32/int64 version markers are skipped by extractBsonMap returning nil) - for _, obj := range extractBsonSlice(raw["Objects"]) { - // Prefer primitive.D path to preserve field ordering for unknown types - if rawD, ok := obj.(primitive.D); ok { - typeName, _ := rawD.Map()["$Type"].(string) - if typeName == "" || typeName == "Microflows$MicroflowParameter" { - // Parameters are handled separately via mf.Parameters - continue - } - if fn, ok := microflowObjectParsers[typeName]; ok { - if mfObj := fn(rawD.Map()); mfObj != nil { - collection.Objects = append(collection.Objects, mfObj) - } - } else { - collection.Objects = append(collection.Objects, newUnknownObjectFromD(typeName, bson.D(rawD))) - } - continue - } - // Fallback for map[string]any - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - continue // Parameters are handled separately - } - if mfObj := parseMicroflowObject(objMap); mfObj != nil { - collection.Objects = append(collection.Objects, mfObj) - } - } - } - - return collection -} - -// microflowObjectParsers maps Mendix $Type strings to their parser functions. -// Adding support for a new type requires only one new entry here. -// Declared as a nil var and populated in init() so that the map literal can -// reference parseLoopedActivity, which itself calls parseMicroflowObjectCollection, -// keeping the package-level initialization order unambiguous. -var microflowObjectParsers map[string]func(map[string]any) microflows.MicroflowObject - -func init() { - microflowObjectParsers = map[string]func(map[string]any) microflows.MicroflowObject{ - "Microflows$StartEvent": func(r map[string]any) microflows.MicroflowObject { return parseStartEvent(r) }, - "Microflows$EndEvent": func(r map[string]any) microflows.MicroflowObject { return parseEndEvent(r) }, - "Microflows$ErrorEvent": func(r map[string]any) microflows.MicroflowObject { return parseErrorEvent(r) }, - "Microflows$ActionActivity": func(r map[string]any) microflows.MicroflowObject { return parseActionActivity(r) }, - "Microflows$ExclusiveSplit": func(r map[string]any) microflows.MicroflowObject { return parseExclusiveSplit(r) }, - "Microflows$ExclusiveMerge": func(r map[string]any) microflows.MicroflowObject { return parseExclusiveMerge(r) }, - "Microflows$InheritanceSplit": func(r map[string]any) microflows.MicroflowObject { return parseInheritanceSplit(r) }, - "Microflows$LoopedActivity": func(r map[string]any) microflows.MicroflowObject { return parseLoopedActivity(r) }, - "Microflows$BreakEvent": func(r map[string]any) microflows.MicroflowObject { return parseBreakEvent(r) }, - "Microflows$ContinueEvent": func(r map[string]any) microflows.MicroflowObject { return parseContinueEvent(r) }, - "Microflows$Annotation": func(r map[string]any) microflows.MicroflowObject { return parseMicroflowAnnotation(r) }, - } -} - -// parseMicroflowObject parses a single microflow object based on its $Type. -// Returns nil for elements with an empty $Type (corrupt or placeholder records). -func parseMicroflowObject(raw map[string]any) microflows.MicroflowObject { - typeName, _ := raw["$Type"].(string) - if typeName == "" { - return nil - } - if fn, ok := microflowObjectParsers[typeName]; ok { - return fn(raw) - } - return newUnknownObject(typeName, raw) -} - -func parseStartEvent(raw map[string]any) *microflows.StartEvent { - event := µflows.StartEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseEndEvent(raw map[string]any) *microflows.EndEvent { - event := µflows.EndEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - event.ReturnValue = extractString(raw["ReturnValue"]) - return event -} - -func parseErrorEvent(raw map[string]any) *microflows.ErrorEvent { - event := µflows.ErrorEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseBreakEvent(raw map[string]any) *microflows.BreakEvent { - event := µflows.BreakEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseContinueEvent(raw map[string]any) *microflows.ContinueEvent { - event := µflows.ContinueEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseExclusiveSplit(raw map[string]any) *microflows.ExclusiveSplit { - split := µflows.ExclusiveSplit{} - split.ID = model.ID(extractBsonID(raw["$ID"])) - split.Position = parsePoint(raw["RelativeMiddlePoint"]) - split.Size = parseSize(raw["Size"]) - split.Caption = extractString(raw["Caption"]) - split.Documentation = extractString(raw["Documentation"]) - - // Parse split condition - if condition, ok := raw["SplitCondition"].(map[string]any); ok { - split.SplitCondition = parseSplitCondition(condition) - } - - return split -} - -func parseExclusiveMerge(raw map[string]any) *microflows.ExclusiveMerge { - merge := µflows.ExclusiveMerge{} - merge.ID = model.ID(extractBsonID(raw["$ID"])) - merge.Position = parsePoint(raw["RelativeMiddlePoint"]) - merge.Size = parseSize(raw["Size"]) - return merge -} - -func parseInheritanceSplit(raw map[string]any) *microflows.InheritanceSplit { - split := µflows.InheritanceSplit{} - split.ID = model.ID(extractBsonID(raw["$ID"])) - split.Position = parsePoint(raw["RelativeMiddlePoint"]) - split.Size = parseSize(raw["Size"]) - split.Caption = extractString(raw["Caption"]) - split.Documentation = extractString(raw["Documentation"]) - split.VariableName = extractString(raw["SplitVariableName"]) - return split -} - -func parseLoopedActivity(raw map[string]any) *microflows.LoopedActivity { - loop := µflows.LoopedActivity{} - loop.ID = model.ID(extractBsonID(raw["$ID"])) - loop.Position = parsePoint(raw["RelativeMiddlePoint"]) - loop.Size = parseSize(raw["Size"]) - loop.Caption = extractString(raw["Caption"]) - loop.Documentation = extractString(raw["Documentation"]) - - // Parse LoopSource (IterableList or WhileLoopCondition) - if loopSourceMap := extractBsonMap(raw["LoopSource"]); loopSourceMap != nil { - typeName := extractString(loopSourceMap["$Type"]) - switch typeName { - case "Microflows$WhileLoopCondition": - loop.LoopSource = µflows.WhileLoopCondition{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(loopSourceMap["$ID"]))}, - WhileExpression: extractString(loopSourceMap["WhileExpression"]), - } - default: // Microflows$IterableList - loop.LoopSource = µflows.IterableList{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(loopSourceMap["$ID"]))}, - ListVariableName: extractString(loopSourceMap["ListVariableName"]), - VariableName: extractString(loopSourceMap["VariableName"]), - } - } - } - - // Parse nested object collection - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - loop.ObjectCollection = parseMicroflowObjectCollection(oc) - } - - return loop -} - -func parseMicroflowAnnotation(raw map[string]any) *microflows.Annotation { - annot := µflows.Annotation{} - annot.ID = model.ID(extractBsonID(raw["$ID"])) - annot.Position = parsePoint(raw["RelativeMiddlePoint"]) - annot.Size = parseSize(raw["Size"]) - annot.Caption = extractString(raw["Caption"]) - return annot -} - -// parseAnnotationFlow parses an AnnotationFlow from raw BSON data. -func parseAnnotationFlow(raw map[string]any) *microflows.AnnotationFlow { - flow := µflows.AnnotationFlow{} - flow.ID = model.ID(extractBsonID(raw["$ID"])) - flow.OriginID = model.ID(extractBsonID(raw["OriginPointer"])) - flow.DestinationID = model.ID(extractBsonID(raw["DestinationPointer"])) - return flow -} - -func parseSplitCondition(raw map[string]any) microflows.SplitCondition { - typeName, _ := raw["$Type"].(string) - - switch typeName { - case "Microflows$ExpressionSplitCondition": - return µflows.ExpressionSplitCondition{ - Expression: extractString(raw["Expression"]), - } - case "Microflows$RuleSplitCondition": - cond := µflows.RuleSplitCondition{} - // Mendix nests the rule reference under a RuleCall sub-document whose - // "Microflow" field holds the rule's qualified name (rules share the - // microflow namespace). Parameter mappings are scoped inside RuleCall too. - rcSource := raw - if rc := extractBsonMap(raw["RuleCall"]); rc != nil { - cond.RuleQualifiedName = extractString(rc["Microflow"]) - rcSource = rc - } - for _, m := range extractBsonArray(rcSource["ParameterMappings"]) { - mMap := extractBsonMap(m) - if mMap == nil { - continue - } - mapping := µflows.RuleCallParameterMapping{ - ParameterID: model.ID(extractBsonID(mMap["Parameter"])), - ParameterName: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - cond.ParameterMappings = append(cond.ParameterMappings, mapping) - } - return cond - default: - return nil - } -} - -func parseActionActivity(raw map[string]any) *microflows.ActionActivity { - activity := µflows.ActionActivity{} - activity.ID = model.ID(extractBsonID(raw["$ID"])) - activity.Position = parsePoint(raw["RelativeMiddlePoint"]) - activity.Size = parseSize(raw["Size"]) - activity.Caption = extractString(raw["Caption"]) - activity.Documentation = extractString(raw["Documentation"]) - activity.AutoGenerateCaption = extractBool(raw["AutoGenerateCaption"], false) - activity.BackgroundColor = extractString(raw["BackgroundColor"]) - activity.Disabled = extractBool(raw["Disabled"], false) - - if errorHandling, ok := raw["ErrorHandlingType"].(string); ok { - activity.ErrorHandlingType = microflows.ErrorHandlingType(errorHandling) - } - - // Parse the action. - if action := parseMicroflowActionValue(raw["Action"]); action != nil { - activity.Action = action - } - - return activity -} - -// microflowActionParsers maps Mendix $Type strings to their action parser functions. -// Storage names (e.g. CreateChangeAction) and qualified names (e.g. CreateObjectAction) -// both map to the same parser to handle BSON format variations. -var microflowActionParsers = map[string]func(map[string]any) microflows.MicroflowAction{ - // Variable actions - "Microflows$CreateVariableAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateVariableAction(r) }, - "Microflows$ChangeVariableAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeVariableAction(r) }, - - // Object actions (storageName may differ from qualifiedName) - "Microflows$CreateObjectAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateObjectAction(r) }, - "Microflows$CreateChangeAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateObjectAction(r) }, - "Microflows$ChangeObjectAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeObjectAction(r) }, - "Microflows$ChangeAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeObjectAction(r) }, - "Microflows$DeleteAction": func(r map[string]any) microflows.MicroflowAction { return parseDeleteAction(r) }, - "Microflows$CommitAction": func(r map[string]any) microflows.MicroflowAction { return parseCommitAction(r) }, - "Microflows$RollbackAction": func(r map[string]any) microflows.MicroflowAction { return parseRollbackAction(r) }, - - // Retrieve actions - "Microflows$RetrieveAction": func(r map[string]any) microflows.MicroflowAction { return parseRetrieveAction(r) }, - "Microflows$AggregateListAction": func(r map[string]any) microflows.MicroflowAction { return parseAggregateListAction(r) }, - "Microflows$AggregateAction": func(r map[string]any) microflows.MicroflowAction { return parseAggregateListAction(r) }, - - // List actions - "Microflows$CreateListAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateListAction(r) }, - "Microflows$ChangeListAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeListAction(r) }, - "Microflows$ListOperationAction": func(r map[string]any) microflows.MicroflowAction { return parseListOperationAction(r) }, - "Microflows$ListOperationsAction": func(r map[string]any) microflows.MicroflowAction { return parseListOperationAction(r) }, - - // Integration actions - "Microflows$MicroflowCallAction": func(r map[string]any) microflows.MicroflowAction { return parseMicroflowCallAction(r) }, - "Microflows$NanoflowCallAction": func(r map[string]any) microflows.MicroflowAction { return parseNanoflowCallAction(r) }, - "Microflows$JavaActionCallAction": func(r map[string]any) microflows.MicroflowAction { return parseJavaActionCallAction(r) }, - "Microflows$JavaScriptActionCallAction": func(r map[string]any) microflows.MicroflowAction { return parseJavaScriptActionCallAction(r) }, - "Microflows$CallExternalAction": func(r map[string]any) microflows.MicroflowAction { return parseCallExternalAction(r) }, - "Microflows$CallWebServiceAction": func(r map[string]any) microflows.MicroflowAction { return parseWebServiceCallAction(r) }, - - // Client actions (ShowFormAction is storageName for ShowPageAction) - "Microflows$ShowFormAction": func(r map[string]any) microflows.MicroflowAction { return parseShowPageAction(r) }, - "Microflows$ShowPageAction": func(r map[string]any) microflows.MicroflowAction { return parseShowPageAction(r) }, - "Microflows$ShowHomePageAction": func(r map[string]any) microflows.MicroflowAction { return parseShowHomePageAction(r) }, - "Microflows$CloseFormAction": func(r map[string]any) microflows.MicroflowAction { return parseClosePageAction(r) }, - "Microflows$ShowMessageAction": func(r map[string]any) microflows.MicroflowAction { return parseShowMessageAction(r) }, - "Microflows$ValidationFeedbackAction": func(r map[string]any) microflows.MicroflowAction { return parseValidationFeedbackAction(r) }, - "Microflows$DownloadFileAction": func(r map[string]any) microflows.MicroflowAction { return parseDownloadFileAction(r) }, - - // Log action - "Microflows$LogMessageAction": func(r map[string]any) microflows.MicroflowAction { return parseLogMessageAction(r) }, - - // Cast action - "Microflows$CastAction": func(r map[string]any) microflows.MicroflowAction { return parseCastAction(r) }, - - // REST call action (inline HTTP) - "Microflows$RestCallAction": func(r map[string]any) microflows.MicroflowAction { return parseRestCallAction(r) }, - - // REST operation call action (consumed REST service) - "Microflows$RestOperationCallAction": func(r map[string]any) microflows.MicroflowAction { - return parseRestOperationCallAction(r) - }, - - // Import/Export mapping actions - "Microflows$ImportXmlAction": func(r map[string]any) microflows.MicroflowAction { return parseImportXmlAction(r) }, - "Microflows$ExportXmlAction": func(r map[string]any) microflows.MicroflowAction { return parseExportXmlAction(r) }, - - // Data transformer action - "Microflows$TransformJsonAction": func(r map[string]any) microflows.MicroflowAction { return parseTransformJsonAction(r) }, - - // Database Connector action - "DatabaseConnector$ExecuteDatabaseQueryAction": func(r map[string]any) microflows.MicroflowAction { return parseExecuteDatabaseQueryAction(r) }, - - // Workflow actions - "Microflows$WorkflowCallAction": func(r map[string]any) microflows.MicroflowAction { return parseWorkflowCallAction(r) }, - "Microflows$GetWorkflowDataAction": func(r map[string]any) microflows.MicroflowAction { return parseGetWorkflowDataAction(r) }, - "Microflows$GetWorkflowsAction": func(r map[string]any) microflows.MicroflowAction { return parseGetWorkflowsAction(r) }, - "Microflows$GetWorkflowActivityRecordsAction": func(r map[string]any) microflows.MicroflowAction { return parseGetWorkflowActivityRecordsAction(r) }, - "Microflows$WorkflowOperationAction": func(r map[string]any) microflows.MicroflowAction { return parseWorkflowOperationAction(r) }, - "Microflows$SetTaskOutcomeAction": func(r map[string]any) microflows.MicroflowAction { return parseSetTaskOutcomeAction(r) }, - "Microflows$OpenUserTaskAction": func(r map[string]any) microflows.MicroflowAction { return parseOpenUserTaskAction(r) }, - "Microflows$NotifyWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseNotifyWorkflowAction(r) }, - "Microflows$OpenWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseOpenWorkflowAction(r) }, - "Microflows$LockWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseLockWorkflowAction(r) }, - "Microflows$UnlockWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseUnlockWorkflowAction(r) }, -} - -// parseMicroflowAction parses a microflow action based on its $Type. -func parseMicroflowAction(raw map[string]any) microflows.MicroflowAction { - typeName, _ := raw["$Type"].(string) - if fn, ok := microflowActionParsers[typeName]; ok { - return fn(raw) - } - return µflows.UnknownAction{TypeName: typeName} -} - -func parseMicroflowActionValue(raw any) microflows.MicroflowAction { - switch action := raw.(type) { - case primitive.D: - actionMap := action.Map() - typeName, _ := actionMap["$Type"].(string) - if typeName == "Microflows$CallWebServiceAction" { - return parseWebServiceCallActionFromD(action) - } - return parseMicroflowAction(actionMap) - case map[string]any: - return parseMicroflowAction(action) - case primitive.M: - return parseMicroflowAction(map[string]any(action)) - default: - if actionMap := extractBsonMap(raw); actionMap != nil { - return parseMicroflowAction(actionMap) - } - return nil - } -} - -func parseCreateVariableAction(raw map[string]any) *microflows.CreateVariableAction { - action := µflows.CreateVariableAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.VariableName = extractString(raw["VariableName"]) - action.InitialValue = extractString(raw["InitialValue"]) - - if dt, ok := raw["VariableType"].(map[string]any); ok { - action.DataType = parseMicroflowDataType(dt) - } - - return action -} - -func parseChangeVariableAction(raw map[string]any) *microflows.ChangeVariableAction { - action := µflows.ChangeVariableAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.VariableName = extractString(raw["ChangeVariableName"]) - action.Value = extractString(raw["Value"]) - return action -} - -func parseCreateObjectAction(raw map[string]any) *microflows.CreateObjectAction { - action := µflows.CreateObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - // Entity is BY_NAME_REFERENCE - can be string (qualified name) or binary (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - action.EntityQualifiedName = entityStr - } else { - action.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - // OutputVariable has storageName "VariableName" but qualifiedName "OutputVariableName" - action.OutputVariable = extractString(raw["VariableName"]) - if action.OutputVariable == "" { - action.OutputVariable = extractString(raw["OutputVariableName"]) - } - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - - if commit, ok := raw["Commit"].(string); ok { - action.Commit = microflows.CommitType(commit) - } - - // Parse initial member values - for _, item := range extractBsonSlice(raw["Items"]) { - if itemMap := extractBsonMap(item); itemMap != nil { - if change := parseMemberChange(itemMap); change != nil { - action.InitialMembers = append(action.InitialMembers, change) - } - } - } - - return action -} - -func parseChangeObjectAction(raw map[string]any) *microflows.ChangeObjectAction { - action := µflows.ChangeObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ChangeVariable = extractString(raw["ChangeVariableName"]) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - - if commit, ok := raw["Commit"].(string); ok { - action.Commit = microflows.CommitType(commit) - } - - // Parse member changes - for _, item := range extractBsonSlice(raw["Items"]) { - if itemMap := extractBsonMap(item); itemMap != nil { - if change := parseMemberChange(itemMap); change != nil { - action.Changes = append(action.Changes, change) - } - } - } - - return action -} - -func parseMemberChange(raw map[string]any) *microflows.MemberChange { - change := µflows.MemberChange{} - change.ID = model.ID(extractBsonID(raw["$ID"])) - - // Attribute can be BY_NAME_REFERENCE (string) or BY_ID (binary) - if attrStr, ok := raw["Attribute"].(string); ok { - change.AttributeQualifiedName = attrStr - } else { - change.AttributeID = model.ID(extractBsonID(raw["Attribute"])) - } - - // Association can be BY_NAME_REFERENCE (string) or BY_ID (binary) - if assocStr, ok := raw["Association"].(string); ok { - change.AssociationQualifiedName = assocStr - } else { - change.AssociationID = model.ID(extractBsonID(raw["Association"])) - } - - change.Value = extractString(raw["Value"]) - - if changeType, ok := raw["Type"].(string); ok { - change.Type = microflows.MemberChangeType(changeType) - } - - return change -} - -func parseDeleteAction(raw map[string]any) *microflows.DeleteObjectAction { - action := µflows.DeleteObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.DeleteVariable = extractString(raw["DeleteVariableName"]) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - return action -} - -func parseCommitAction(raw map[string]any) *microflows.CommitObjectsAction { - action := µflows.CommitObjectsAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.CommitVariable = extractString(raw["CommitVariableName"]) - action.WithEvents = extractBool(raw["WithEvents"], false) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - if action.ErrorHandlingType == "" { - action.ErrorHandlingType = microflows.ErrorHandlingTypeRollback - } - return action -} - -func parseRollbackAction(raw map[string]any) *microflows.RollbackObjectAction { - action := µflows.RollbackObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.RollbackVariable = extractString(raw["RollbackVariableName"]) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - return action -} - -func parseRetrieveAction(raw map[string]any) *microflows.RetrieveAction { - action := µflows.RetrieveAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - // Writer uses "ResultVariableName" as the storage name - action.OutputVariable = extractString(raw["ResultVariableName"]) - - // Parse retrieve source - if source, ok := raw["RetrieveSource"].(map[string]any); ok { - action.Source = parseRetrieveSource(source) - } - - return action -} - -// parseSortItems parses sort items from a BSON map that wraps a SortItemList. -// It tries multiple field-name conventions (modern and legacy storage names). -func parseSortItems(raw map[string]any) []*microflows.SortItem { - // Try field names: "sortItemList", "NewSortings", "Sortings", "SortItemList" - var listMap map[string]any - for _, key := range []string{"sortItemList", "NewSortings", "Sortings", "SortItemList"} { - if m := extractBsonMap(raw[key]); m != nil { - listMap = m - break - } - } - if listMap == nil { - return nil - } - - // Extract items array — try "items", "Sortings", "Items" - var items []any - for _, key := range []string{"items", "Sortings", "Items"} { - if s := extractBsonSlice(listMap[key]); s != nil { - items = s - break - } - } - - var result []*microflows.SortItem - for _, item := range items { - itemMap := extractBsonMap(item) - if itemMap == nil { - continue - } - sortItem := µflows.SortItem{} - sortItem.ID = model.ID(extractBsonID(itemMap["$ID"])) - - // Try AttributeRef (modern: DomainModels$AttributeRef with BY_NAME_REFERENCE) - if attrRefMap := extractBsonMap(itemMap["AttributeRef"]); attrRefMap != nil { - if attrStr, ok := attrRefMap["Attribute"].(string); ok { - sortItem.AttributeQualifiedName = attrStr - } else { - sortItem.AttributeID = model.ID(extractBsonID(attrRefMap["Attribute"])) - } - sortItem.EntityRefSteps = parseEntityRefSteps(attrRefMap["EntityRef"]) - } - - // Fall back to AttributePath (legacy) - if sortItem.AttributeQualifiedName == "" && sortItem.AttributeID == "" { - if attrStr, ok := itemMap["AttributePath"].(string); ok { - sortItem.AttributeQualifiedName = attrStr - } else { - sortItem.AttributeID = model.ID(extractBsonID(itemMap["AttributePath"])) - } - } - - if dir, ok := itemMap["SortOrder"].(string); ok { - sortItem.Direction = microflows.SortDirection(dir) - } - result = append(result, sortItem) - } - return result -} - -func parseEntityRefSteps(raw any) []microflows.EntityRefStep { - entityRefMap := extractBsonMap(raw) - if entityRefMap == nil { - return nil - } - items := extractBsonSlice(entityRefMap["Steps"]) - if len(items) == 0 { - return nil - } - var steps []microflows.EntityRefStep - for _, item := range items { - itemMap := extractBsonMap(item) - if itemMap == nil { - continue - } - step := microflows.EntityRefStep{ - Association: extractString(itemMap["Association"]), - DestinationEntity: extractString(itemMap["DestinationEntity"]), - } - if step.Association != "" || step.DestinationEntity != "" { - steps = append(steps, step) - } - } - return steps -} - -func parseRetrieveSource(raw map[string]any) microflows.RetrieveSource { - typeName, _ := raw["$Type"].(string) - - switch typeName { - case "Microflows$DatabaseRetrieveSource": - source := µflows.DatabaseRetrieveSource{} - source.ID = model.ID(extractBsonID(raw["$ID"])) - // Entity can be stored as string (BY_NAME_REFERENCE) or binary ID - if entityStr, ok := raw["Entity"].(string); ok { - source.EntityQualifiedName = entityStr - } else { - source.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - // XPath constraint - Studio Pro uses lowercase 'p' (XpathConstraint), but we also support uppercase for backwards compatibility - source.XPathConstraint = extractString(raw["XpathConstraint"]) - if source.XPathConstraint == "" { - source.XPathConstraint = extractString(raw["XPathConstraint"]) - } - - // Parse range - if rangeMap, ok := raw["Range"].(map[string]any); ok { - source.Range = parseRange(rangeMap) - } - - // Parse sorting - source.Sorting = parseSortItems(raw) - - return source - - case "Microflows$AssociationRetrieveSource": - source := µflows.AssociationRetrieveSource{} - source.ID = model.ID(extractBsonID(raw["$ID"])) - source.StartVariable = extractString(raw["StartVariableName"]) - source.AssociationID = model.ID(extractBsonID(raw["Association"])) - // AssociationId contains BY_NAME_REFERENCE (qualified name) - source.AssociationQualifiedName = extractString(raw["AssociationId"]) - return source - - default: - return nil - } -} - -func parseRange(raw map[string]any) *microflows.Range { - typeName, _ := raw["$Type"].(string) - - r := µflows.Range{} - r.ID = model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "Microflows$ConstantRange": - // MEASURED (Mendix 11.13.0, ako/TestApp MyFirstModule.RetrieveExamples — - // three retrieves, one per UI option): - // - // All ConstantRange{SingleObject:false} - // First ConstantRange{SingleObject:true} - // Custom CustomRange{LimitExpression, OffsetExpression} - // - // So a ConstantRange carries ONLY SingleObject, exactly as - // generated/metamodel and modelsdk/gen declare it, and the Limit/Offset - // read below has never been observed to fire. It is kept as tolerance - // for formats we have not sampled (no pre-11 document has been checked), - // NOT because Studio Pro is known to write that shape — an earlier - // comment here asserted it did, which is false for 11.13 and nearly cost - // a phantom bug report against the other engine. - // - // The engines differ on this input and that is deliberate: modelsdk's - // rangeFromGen cannot read it at all, because gen binds only - // SingleObject on ConstantRange. If a real document ever turns up with - // Limit on a ConstantRange, that asymmetry becomes a data-loss bug and - // gen needs a property override — see CLAUDE.md on gen's wrong keys. - r.Limit = extractString(raw["LimitExpression"]) - r.Offset = extractString(raw["OffsetExpression"]) - if singleObject := extractBool(raw["SingleObject"], false); singleObject { - r.RangeType = microflows.RangeTypeFirst - } else if r.Limit != "" || r.Offset != "" { - r.RangeType = microflows.RangeTypeCustom - } else { - r.RangeType = microflows.RangeTypeAll - } - case "Microflows$CustomRange": - r.RangeType = microflows.RangeTypeCustom - r.Limit = extractString(raw["LimitExpression"]) - r.Offset = extractString(raw["OffsetExpression"]) - default: - r.RangeType = microflows.RangeTypeAll - } - - return r -} - -func parseAggregateListAction(raw map[string]any) *microflows.AggregateListAction { - action := µflows.AggregateListAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // Storage name is AggregateVariableName, qualified name is inputListVariableName - action.InputVariable = extractString(raw["AggregateVariableName"]) - if action.InputVariable == "" { - action.InputVariable = extractString(raw["InputListVariableName"]) - } - // Storage name is VariableName, qualified name is outputVariableName - action.OutputVariable = extractString(raw["VariableName"]) - if action.OutputVariable == "" { - action.OutputVariable = extractString(raw["OutputVariableName"]) - } - - // Attribute is BY_NAME_REFERENCE - can be string (qualified name) or binary (legacy ID) - if attrStr, ok := raw["Attribute"].(string); ok { - action.AttributeQualifiedName = attrStr - } else { - action.AttributeID = model.ID(extractBsonID(raw["Attribute"])) - } - - if fn, ok := raw["AggregateFunction"].(string); ok { - action.Function = microflows.AggregateFunction(fn) - } - - if useExpr, ok := raw["UseExpression"].(bool); ok { - action.UseExpression = useExpr - } - if action.UseExpression { - action.Expression = extractString(raw["Expression"]) - } - - // Reduce's fold: what it starts from and what it folds to. Studio Pro writes - // both on every AggregateAction (empty initial value on All/Any), so read - // them unconditionally rather than only for Reduce — a rewrite that dropped - // them silently deleted the fold (#1004). - action.ReduceInitialValue = extractString(raw["ReduceInitialValueExpression"]) - if rt, ok := raw["ReduceReturnDataType"].(map[string]any); ok { - action.ReduceReturnType = parseMicroflowDataType(rt) - } - - return action -} - -func parseCreateListAction(raw map[string]any) *microflows.CreateListAction { - action := µflows.CreateListAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // Entity is BY_NAME_REFERENCE - can be string (qualified name) or binary (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - action.EntityQualifiedName = entityStr - } else { - action.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - action.OutputVariable = extractString(raw["VariableName"]) - return action -} - -func parseChangeListAction(raw map[string]any) *microflows.ChangeListAction { - action := µflows.ChangeListAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ChangeVariable = extractString(raw["ChangeVariableName"]) - action.Value = extractString(raw["Value"]) - if t, ok := raw["Type"].(string); ok { - action.Type = microflows.ChangeListType(t) - } - return action -} - -func parseListOperationAction(raw map[string]any) *microflows.ListOperationAction { - action := µflows.ListOperationAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.OutputVariable = extractString(raw["ResultVariableName"]) - - // Parse the operation from NewOperation (storage name for operation) - if opRaw, ok := raw["NewOperation"].(map[string]any); ok { - action.Operation = parseListOperation(opRaw) - } - - return action -} - -func parseListOperation(raw map[string]any) microflows.ListOperation { - typeName, _ := raw["$Type"].(string) - listVar := extractString(raw["ListName"]) - id := model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "Microflows$Head": - return µflows.HeadOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - case "Microflows$Tail": - return µflows.TailOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - case "Microflows$Find": - return µflows.FindByAttributeOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Attribute: extractString(raw["Attribute"]), - Association: extractString(raw["Association"]), - Expression: extractString(raw["Expression"]), - } - case "Microflows$FindByExpression": - return µflows.FindOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Expression: extractString(raw["Expression"]), - } - case "Microflows$Filter": - return µflows.FilterByAttributeOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Attribute: extractString(raw["Attribute"]), - Association: extractString(raw["Association"]), - Expression: extractString(raw["Expression"]), - } - case "Microflows$FilterByExpression": - return µflows.FilterOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Expression: extractString(raw["Expression"]), - } - case "Microflows$Sort": - sortOp := µflows.SortOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - sortOp.Sorting = parseSortItems(raw) - return sortOp - case "Microflows$Union": - return µflows.UnionOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$Intersect": - return µflows.IntersectOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$Subtract": - return µflows.SubtractOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$Contains": - return µflows.ContainsOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - ObjectVariable: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$ListEquals": - return µflows.EqualsOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$ListRange": - rangeOp := µflows.ListRangeOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - if cr := extractBsonMap(raw["CustomRange"]); cr != nil { - rangeOp.LimitExpression = extractString(cr["LimitExpression"]) - rangeOp.OffsetExpression = extractString(cr["OffsetExpression"]) - } - return rangeOp - default: - return nil - } -} - -func parseMicroflowDataType(raw map[string]any) microflows.DataType { - typeName, _ := raw["$Type"].(string) - - switch typeName { - case "DataTypes$BooleanType": - return µflows.BooleanType{} - case "DataTypes$IntegerType": - return µflows.IntegerType{} - case "DataTypes$LongType": - return µflows.LongType{} - case "DataTypes$DecimalType": - return µflows.DecimalType{} - case "DataTypes$StringType": - return µflows.StringType{} - case "DataTypes$DateTimeType": - return µflows.DateTimeType{} - case "DataTypes$BinaryType": - return µflows.BinaryType{} - case "DataTypes$VoidType": - return µflows.VoidType{} - case "DataTypes$ObjectType": - objType := µflows.ObjectType{} - // Entity can be BY_NAME_REFERENCE (string) or binary ID (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - objType.EntityQualifiedName = entityStr - } else { - objType.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - return objType - case "DataTypes$ListType": - listType := µflows.ListType{} - // Entity can be BY_NAME_REFERENCE (string) or binary ID (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - listType.EntityQualifiedName = entityStr - } else { - listType.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - return listType - case "DataTypes$EnumerationType": - enumType := µflows.EnumerationType{} - // Enumeration can be BY_NAME_REFERENCE (string) or binary ID (legacy) - if enumStr, ok := raw["Enumeration"].(string); ok { - enumType.EnumerationQualifiedName = enumStr - } else { - enumType.EnumerationID = model.ID(extractBsonID(raw["Enumeration"])) - } - return enumType - default: - return nil - } -} - -func parsePoint(raw any) model.Point { - switch v := raw.(type) { - case map[string]any: - return model.Point{ - X: extractInt(v["X"]), - Y: extractInt(v["Y"]), - } - case string: - // MPR v2 stores positions as "X;Y" strings, e.g. "570;297" - parts := strings.SplitN(v, ";", 2) - if len(parts) == 2 { - x, _ := strconv.Atoi(strings.TrimSpace(parts[0])) - y, _ := strconv.Atoi(strings.TrimSpace(parts[1])) - return model.Point{X: x, Y: y} - } - } - return model.Point{} -} - -// parseSize parses a Size from raw BSON data (stored as "W;H" string). -func parseSize(raw any) model.Size { - if s, ok := raw.(string); ok { - parts := strings.SplitN(s, ";", 2) - if len(parts) == 2 { - w, _ := strconv.Atoi(strings.TrimSpace(parts[0])) - h, _ := strconv.Atoi(strings.TrimSpace(parts[1])) - return model.Size{Width: w, Height: h} - } - } - return model.Size{} -} - -// parseNanoflow parses nanoflow contents from BSON. diff --git a/sdk/mpr/parser_microflow_actions.go b/sdk/mpr/parser_microflow_actions.go deleted file mode 100644 index b41ff8a5fc..0000000000 --- a/sdk/mpr/parser_microflow_actions.go +++ /dev/null @@ -1,1073 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func parseCallExternalAction(raw map[string]any) *microflows.CallExternalAction { - action := µflows.CallExternalAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ConsumedODataService = extractString(raw["ConsumedODataService"]) - action.Name = extractString(raw["Name"]) - action.ResultVariableName = extractString(raw["VariableName"]) - action.UseReturnVariable = action.ResultVariableName != "" - - // Parse parameter mappings - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.ExternalActionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.ParameterName = extractString(mMap["ParameterName"]) - mapping.Argument = extractString(mMap["Argument"]) - mapping.CanBeEmpty = extractBool(mMap["CanBeEmpty"], false) - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - return action -} - -func parseMicroflowCallAction(raw map[string]any) *microflows.MicroflowCallAction { - action := µflows.MicroflowCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ResultVariableName = extractString(raw["ResultVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse nested MicroflowCall structure - if mfCall, ok := raw["MicroflowCall"].(map[string]any); ok { - call := µflows.MicroflowCall{} - call.ID = model.ID(extractBsonID(mfCall["$ID"])) - call.Microflow = extractString(mfCall["Microflow"]) - - // Parse parameter mappings from MicroflowCall (use extractBsonArray for BSON array format) - if mappings := extractBsonArray(mfCall["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.MicroflowCallParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - mapping.Argument = extractString(mMap["Argument"]) - call.ParameterMappings = append(call.ParameterMappings, mapping) - } - } - } - call.QueueSettings = parseQueueSettings(mfCall) - action.MicroflowCall = call - } - - return action -} - -func parseNanoflowCallAction(raw map[string]any) *microflows.NanoflowCallAction { - action := µflows.NanoflowCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse nested NanoflowCall structure - if nfCall, ok := raw["NanoflowCall"].(map[string]any); ok { - call := µflows.NanoflowCall{} - call.ID = model.ID(extractBsonID(nfCall["$ID"])) - call.Nanoflow = extractString(nfCall["Nanoflow"]) - - if mappings := extractBsonArray(nfCall["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.NanoflowCallParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - mapping.Argument = extractString(mMap["Argument"]) - call.ParameterMappings = append(call.ParameterMappings, mapping) - } - } - } - action.NanoflowCall = call - } - - return action -} - -func parseJavaActionCallAction(raw map[string]any) *microflows.JavaActionCallAction { - action := µflows.JavaActionCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.JavaAction = extractString(raw["JavaAction"]) - action.QueueSettings = parseQueueSettings(raw) - action.ResultVariableName = extractString(raw["ResultVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse parameter mappings (use extractBsonArray to handle BSON array format) - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.JavaActionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - // Parse Value - it can be various types - if value, ok := mMap["Value"].(map[string]any); ok { - mapping.Value = parseCodeActionParameterValue(value) - } - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - return action -} - -func parseJavaScriptActionCallAction(raw map[string]any) *microflows.JavaScriptActionCallAction { - action := µflows.JavaScriptActionCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.JavaScriptAction = extractString(raw["JavaScriptAction"]) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse parameter mappings - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.JavaScriptActionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - // BSON key is "ParameterValue"; Go struct JSON tag is "value" — intentional asymmetry - if value, ok := mMap["ParameterValue"].(map[string]any); ok { - mapping.Value = parseCodeActionParameterValue(value) - } - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - return action -} - -func parseCodeActionParameterValue(raw map[string]any) microflows.CodeActionParameterValue { - if raw == nil { - return nil - } - typeName := extractString(raw["$Type"]) - switch typeName { - case "Microflows$StringTemplateParameterValue": - value := µflows.StringTemplateParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - if tt, ok := raw["TypedTemplate"].(map[string]any); ok { - value.TypedTemplate = µflows.TypedTemplate{} - value.TypedTemplate.ID = model.ID(extractBsonID(tt["$ID"])) - value.TypedTemplate.Text = extractString(tt["Text"]) - } - return value - case "Microflows$ExpressionBasedCodeActionParameterValue": - value := µflows.ExpressionBasedCodeActionParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Expression = extractString(raw["Expression"]) - return value - case "Microflows$BasicCodeActionParameterValue": - value := µflows.BasicCodeActionParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Argument = extractString(raw["Argument"]) - return value - case "Microflows$MicroflowParameterValue": - value := µflows.MicroflowParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Microflow = extractString(raw["Microflow"]) - return value - case "Microflows$EntityTypeCodeActionParameterValue": - value := µflows.EntityTypeCodeActionParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Entity = extractString(raw["Entity"]) - return value - } - return nil -} - -func parseShowPageAction(raw map[string]any) *microflows.ShowPageAction { - action := µflows.ShowPageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.PageID = model.ID(extractBsonID(raw["Page"])) - action.PassedObject = extractString(raw["PassedObjectVariableName"]) - - // Parse FormSettings (modern Mendix 10+ format with BY_NAME_REFERENCE) - if fs := toMap(raw["FormSettings"]); fs != nil { - action.PageName = extractString(fs["Form"]) - action.FormSettingsID = model.ID(extractBsonID(fs["$ID"])) - // Parse ParameterMappings from FormSettings - action.PageParameterMappings = parseParameterMappingsAny(fs["ParameterMappings"]) - } - - // Parse PageSettings (legacy format) - if ps := toMap(raw["PageSettings"]); ps != nil { - action.PageSettings = µflows.PageSettings{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(ps["$ID"]))}, - Location: microflows.PageLocation(extractString(ps["Location"])), - } - } - - // Parse PageParameterMappings from top-level (legacy format, only if not already parsed from FormSettings) - if action.PageParameterMappings == nil { - action.PageParameterMappings = parseParameterMappingsAny(raw["ParameterMappings"]) - } - - return action -} - -// parseParameterMappingsAny parses parameter mappings from any array type (primitive.A or []any). -func parseParameterMappingsAny(v any) []*microflows.PageParameterMapping { - arr := extractBsonArray(v) - if len(arr) == 0 { - return nil - } - var result []*microflows.PageParameterMapping - for _, m := range arr { - mMap := toMap(m) - if mMap != nil { - mapping := µflows.PageParameterMapping{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mMap["$ID"]))}, - Parameter: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - result = append(result, mapping) - } - } - return result -} - -// parseFormParameterMappings parses parameter mappings from FormSettings (primitive.A type). -func parseFormParameterMappings(mappings primitive.A) []*microflows.PageParameterMapping { - var result []*microflows.PageParameterMapping - for _, m := range mappings { - // Skip the count element - if _, isInt := m.(int32); isInt { - continue - } - mMap := toMap(m) - if mMap != nil { - mapping := µflows.PageParameterMapping{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mMap["$ID"]))}, - Parameter: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - result = append(result, mapping) - } - } - return result -} - -// parseFormParameterMappingsSlice parses parameter mappings from FormSettings ([]interface{} type). -func parseFormParameterMappingsSlice(mappings []any) []*microflows.PageParameterMapping { - var result []*microflows.PageParameterMapping - for _, m := range mappings { - // Skip the count element - if _, isInt := m.(int32); isInt { - continue - } - mMap := toMap(m) - if mMap != nil { - mapping := µflows.PageParameterMapping{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mMap["$ID"]))}, - Parameter: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - result = append(result, mapping) - } - } - return result -} - -func parseShowHomePageAction(raw map[string]any) *microflows.ShowHomePageAction { - action := µflows.ShowHomePageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - return action -} - -func parseClosePageAction(raw map[string]any) *microflows.ClosePageAction { - action := µflows.ClosePageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - // Issue #585: collapse the int32/int64 dispatch to the shared extractInt - // helper. Default of 1 is preserved when the field is absent. - // Storage name is "NumberOfPages"; also accept the legacy "NumberOfPagesToClose" - // that older mxcli/Mendix wrote, for round-trip fidelity on existing projects. - if _, ok := raw["NumberOfPages"]; ok { - action.NumberOfPages = extractInt(raw["NumberOfPages"]) - } else if _, ok := raw["NumberOfPagesToClose"]; ok { - action.NumberOfPages = extractInt(raw["NumberOfPagesToClose"]) - } else { - action.NumberOfPages = 1 - } - return action -} - -func parseShowMessageAction(raw map[string]any) *microflows.ShowMessageAction { - action := µflows.ShowMessageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.Blocking = extractBool(raw["Blocking"], false) - - if msgType, ok := raw["Type"].(string); ok { - action.Type = microflows.MessageType(msgType) - } - - // Parse template (nested Microflows$TextTemplate -> Texts$Text) - if template, ok := raw["Template"].(map[string]any); ok { - // TextTemplate contains a nested Text property with the actual translations - if text, ok := template["Text"].(map[string]any); ok { - action.Template = parseText(text) - } - - // Extract template parameters from Microflows$TextTemplate.Parameters - if params := extractBsonArray(template["Parameters"]); len(params) > 0 { - for _, p := range params { - if paramMap, ok := p.(map[string]any); ok { - if expr := extractString(paramMap["Expression"]); expr != "" { - action.TemplateParameters = append(action.TemplateParameters, expr) - } - } - } - } - } - - return action -} - -func parseValidationFeedbackAction(raw map[string]any) *microflows.ValidationFeedbackAction { - action := µflows.ValidationFeedbackAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ObjectVariable = extractString(raw["ValidationVariableName"]) - action.AttributeName = extractString(raw["Attribute"]) // BY_NAME_REFERENCE - action.AssociationName = extractString(raw["Association"]) // BY_NAME_REFERENCE - - // Parse template (nested Microflows$TextTemplate -> Texts$Text) - if template, ok := raw["FeedbackTemplate"].(map[string]any); ok { - // TextTemplate contains a nested Text property - if text, ok := template["Text"].(map[string]any); ok { - action.Template = parseText(text) - } - } - - return action -} - -func parseDownloadFileAction(raw map[string]any) *microflows.DownloadFileAction { - action := µflows.DownloadFileAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - if action.ErrorHandlingType == "" { - action.ErrorHandlingType = microflows.ErrorHandlingTypeRollback - } - action.FileDocument = extractString(raw["FileDocumentVariableName"]) - action.ShowInBrowser = extractBool(raw["ShowInBrowser"], false) - return action -} - -func parseLogMessageAction(raw map[string]any) *microflows.LogMessageAction { - action := µflows.LogMessageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.LogNodeName = extractString(raw["Node"]) - action.IncludeLastStackTrace = extractBool(raw["IncludeLatestStackTrace"], false) - - if level, ok := raw["Level"].(string); ok { - action.LogLevel = microflows.LogLevel(level) - } - - // Parse message template (Microflows$StringTemplate) - if template, ok := raw["MessageTemplate"].(map[string]any); ok { - action.MessageTemplate = parseText(template) - - // Extract template parameters from Microflows$StringTemplate.Parameters - if params := extractBsonArray(template["Parameters"]); len(params) > 0 { - for _, p := range params { - if paramMap, ok := p.(map[string]any); ok { - if expr := extractString(paramMap["Expression"]); expr != "" { - action.TemplateParameters = append(action.TemplateParameters, expr) - } - } - } - } - } - - return action -} - -func parseCastAction(raw map[string]any) *microflows.CastAction { - action := µflows.CastAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ObjectVariable = extractString(raw["ObjectVariableName"]) - action.OutputVariable = extractString(raw["OutputVariableName"]) - if action.OutputVariable == "" { - action.OutputVariable = extractString(raw["VariableName"]) - } - return action -} - -func parseRestCallAction(raw map[string]any) *microflows.RestCallAction { - action := µflows.RestCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.TimeoutExpression = extractString(raw["TimeOutExpression"]) - action.UseReturnVariable = extractBool(raw["UseRequestTimeOut"], false) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - - // Parse HttpConfiguration - if httpConfig, ok := raw["HttpConfiguration"].(map[string]any); ok { - action.HttpConfiguration = parseHttpConfiguration(httpConfig) - } else if httpConfigD, ok := raw["HttpConfiguration"].(primitive.D); ok { - action.HttpConfiguration = parseHttpConfiguration(httpConfigD.Map()) - } - - // Parse ResultHandling - resultHandlingType := extractString(raw["ResultHandlingType"]) - if resultHandling, ok := raw["ResultHandling"].(map[string]any); ok { - action.ResultHandling = parseResultHandling(resultHandling, resultHandlingType) - } else if resultHandlingD, ok := raw["ResultHandling"].(primitive.D); ok { - action.ResultHandling = parseResultHandling(resultHandlingD.Map(), resultHandlingType) - } - - // Parse RequestHandling - requestHandlingType := extractString(raw["RequestHandlingType"]) - if requestHandling, ok := raw["RequestHandling"].(map[string]any); ok { - action.RequestHandling = parseRequestHandling(requestHandling, requestHandlingType) - } else if requestHandlingD, ok := raw["RequestHandling"].(primitive.D); ok { - action.RequestHandling = parseRequestHandling(requestHandlingD.Map(), requestHandlingType) - } - - return action -} - -func parseWebServiceCallAction(raw map[string]any) *microflows.WebServiceCallAction { - action := µflows.WebServiceCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ServiceID = model.ID(extractString(raw["ImportedService"])) - action.OperationName = extractString(raw["OperationName"]) - action.TimeoutExpression = extractString(raw["TimeOutExpression"]) - - if resultHandling := extractBsonMap(raw["NewResultHandling"]); resultHandling != nil { - action.OutputVariable = extractString(resultHandling["ResultVariableName"]) - action.UseReturnVariable = action.OutputVariable != "" - if call := extractBsonMap(resultHandling["ImportMappingCall"]); call != nil { - action.ReceiveMappingID = model.ID(extractString(call["ReturnValueMapping"])) - } - } - // RequestHandling / ExportMappingCall is a shape no reference document - // carries — the real key is RequestBodyHandling, read below — so this never - // populated SendMappingID from a real project. - if requestHandling := extractBsonMap(raw["RequestHandling"]); requestHandling != nil { - if call := extractBsonMap(requestHandling["ExportMappingCall"]); call != nil { - action.SendMappingID = model.ID(extractString(call["Mapping"])) - } - } - parseWebServiceRequestBody(raw, action) - if webServiceActionRequiresRawBSON(raw) { - if rawBSON, err := bson.Marshal(raw); err == nil { - action.RawBSON = rawBSON - } - } - - return action -} - -// parseWebServiceRequestBody reads a SOAP call's RequestBodyHandling back into -// the semantic model. Mirrors modelsdkbackend.readWebServiceRequestBody. -// -// Dispatched on $Type, never on which fields are present: MappingRequestHandling -// and SimpleRequestHandling differ in arity, so assigning whichever keys turn up -// would quietly turn one into the other. -func parseWebServiceRequestBody(raw map[string]any, action *microflows.WebServiceCallAction) { - body := extractBsonMap(raw["RequestBodyHandling"]) - if body == nil { - return - } - switch extractString(body["$Type"]) { - case "Microflows$MappingRequestHandling": - // STORAGE NAMES: MappingId / MappingVariableName — not gen's Mapping / - // MappingArgumentVariableName, both of which its key audit lists as wrong. - action.SendMappingID = model.ID(extractString(body["MappingId"])) - action.SendMappingVariable = extractString(body["MappingVariableName"]) - action.SendMappingContentType = extractString(body["ContentType"]) - case "Microflows$SimpleRequestHandling": - for _, el := range extractBsonArray(body["ParameterMappings"]) { - pm := extractBsonMap(el) - if pm == nil || extractString(pm["$Type"]) != "Microflows$WebServiceOperationSimpleParameterMapping" { - // An advanced (per-parameter export mapping) entry, which MDL - // cannot author. The raw fallback carries the action; reading - // half of it here would be worse than reading none. - continue - } - path := extractString(pm["ParameterPath"]) - name := "" - if i := strings.LastIndex(path, "|"); i >= 0 { - name = path[i+1:] - } - action.Arguments = append(action.Arguments, microflows.WebServiceArgument{ - Name: name, - Path: path, - Expression: extractString(pm["Argument"]), - // Absent reads as true: both reference mappings carry true, and - // a bound parameter is one Studio Pro has ticked. - Checked: extractBool(pm["IsChecked"], true), - }) - } - } -} - -// webServiceActionRequiresRawBSON reports whether the structured describe form -// would fail to reproduce this action, in which case the renderer falls back to -// `call web service raw ''`. Mirrors -// modelsdkbackend.webServiceActionRequiresRawBSON decision for decision — see the -// comment there for why the six boilerplate keys are admitted only AT the value -// mxcli writes rather than by name. -func webServiceActionRequiresRawBSON(raw map[string]any) bool { - represented := map[string]bool{ - "$ID": true, - "$Type": true, - "ErrorHandlingType": true, - "ImportedService": true, - "OperationName": true, - "TimeOutExpression": true, - "RequestHandling": true, - // Re-read from the imported service document on write (the CE0386 fix), - // so DESCRIBE need not carry it. - "ServiceName": true, - } - for key, value := range raw { - if represented[key] { - continue - } - ok, known := webServiceFixedValueIsDefault(key, value) - if !known || !ok { - return true - } - } - return false -} - -// webServiceFixedValueIsDefault reports whether one of the keys mxcli writes at a -// FIXED value currently holds it. known is false for a key it does not judge. -func webServiceFixedValueIsDefault(key string, value any) (ok, known bool) { - switch key { - case "IsValidationRequired": - return !extractBool(value, true), true - case "UseRequestTimeOut": - return extractBool(value, false), true - case "RequestProxyType": - return extractString(value) == "DefaultProxy", true - case "ProxyConfiguration": - return value == nil, true - case "HttpConfiguration": - return isDefaultWebServiceHTTPConfig(extractBsonMap(value)), true - case "RequestHeaderHandling": - return isEmptySimpleRequestHandling(extractBsonMap(value)), true - case "RequestBodyHandling": - return webServiceRequestBodyIsRepresentable(extractBsonMap(value)), true - case "NewResultHandling": - return webServiceResultHandlingIsRepresentable(extractBsonMap(value)), true - } - return false, false -} - -// webServiceResultHandlingIsRepresentable reports whether a call's result -// handling is one the writer reproduces exactly. See the comment on the -// modelsdk twin for the two ako/TestApp calls that prove it cannot be admitted -// by name: a BooleanType result with no mapping, and Range.SingleObject false. -func webServiceResultHandlingIsRepresentable(doc map[string]any) bool { - if doc == nil || extractString(doc["$Type"]) != "Microflows$ResultHandling" { - return false - } - bound := extractString(doc["ResultVariableName"]) != "" - if extractBool(doc["Bind"], !bound) != bound { - return false - } - vt := extractBsonMap(doc["VariableType"]) - if vt == nil { - return false - } - imc := extractBsonMap(doc["ImportMappingCall"]) - if imc == nil { - return extractString(vt["$Type"]) == "DataTypes$VoidType" - } - if extractString(vt["$Type"]) != "DataTypes$ObjectType" { - return false - } - if extractString(imc["$Type"]) != "Microflows$ImportMappingCall" || - extractString(imc["Commit"]) != "YesWithoutEvents" || - extractString(imc["ContentType"]) != "Xml" || - extractString(imc["ObjectHandlingBackup"]) != "Create" || - extractString(imc["ParameterVariableName"]) != "" || - extractString(imc["ReturnValueMapping"]) == "" || - extractBool(imc["ForceSingleOccurrence"], true) { - return false - } - rng := extractBsonMap(imc["Range"]) - return rng != nil && - extractString(rng["$Type"]) == "Microflows$ConstantRange" && - extractBool(rng["SingleObject"], false) -} - -// isDefaultWebServiceHTTPConfig reports whether an HttpConfiguration is the one a -// SOAP call gets when nothing is configured — the only one mxcli writes. -func isDefaultWebServiceHTTPConfig(doc map[string]any) bool { - if doc == nil || extractString(doc["$Type"]) != "Microflows$HttpConfiguration" { - return false - } - for _, key := range []string{"ClientCertificate", "CustomLocation", - "HttpAuthenticationPassword", "HttpAuthenticationUserName"} { - if extractString(doc[key]) != "" { - return false - } - } - if doc["CustomLocationTemplate"] != nil { - return false - } - if extractString(doc["HttpMethod"]) != "Post" { - return false - } - if extractBool(doc["OverrideLocation"], true) || extractBool(doc["UseHttpAuthentication"], true) { - return false - } - return len(extractBsonArray(doc["HttpHeaderEntries"])) == 0 -} - -// isEmptySimpleRequestHandling reports whether a request handling is the bare -// Simple form — no parameter mappings — which is all mxcli writes for headers. -func isEmptySimpleRequestHandling(doc map[string]any) bool { - return doc != nil && - extractString(doc["$Type"]) == "Microflows$SimpleRequestHandling" && - extractString(doc["NullValueOption"]) == "LeaveOutElement" && - len(extractBsonArray(doc["ParameterMappings"])) == 0 -} - -// webServiceRequestBodyIsRepresentable reports whether a RequestBodyHandling is -// one MDL can spell: an export mapping, or simple parameter mappings whose names -// survive the round trip. -func webServiceRequestBodyIsRepresentable(doc map[string]any) bool { - if doc == nil { - return false - } - switch extractString(doc["$Type"]) { - case "Microflows$MappingRequestHandling": - return extractString(doc["MappingId"]) != "" && extractString(doc["MappingVariableName"]) != "" - case "Microflows$SimpleRequestHandling": - if extractString(doc["NullValueOption"]) != "LeaveOutElement" { - return false - } - for _, el := range extractBsonArray(doc["ParameterMappings"]) { - pm := extractBsonMap(el) - if pm == nil || - extractString(pm["$Type"]) != "Microflows$WebServiceOperationSimpleParameterMapping" || - extractString(pm["ParameterName"]) != "" { - return false - } - if !strings.Contains(extractString(pm["ParameterPath"]), "|") { - return false - } - } - return true - } - return false -} - -func parseWebServiceCallActionFromD(raw primitive.D) *microflows.WebServiceCallAction { - action := parseWebServiceCallAction(raw.Map()) - if rawBSON, err := bson.Marshal(raw); err == nil { - action.RawBSON = rawBSON - } - return action -} - -// parseRestOperationCallAction parses a Microflows$RestOperationCallAction from BSON. -func parseRestOperationCallAction(raw map[string]any) *microflows.RestOperationCallAction { - action := µflows.RestOperationCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.Operation = extractString(raw["Operation"]) - - // Parse OutputVariable (nested Microflows$OutputVariable) - if ov := extractBsonMap(raw["OutputVariable"]); ov != nil { - action.OutputVariable = µflows.RestOutputVar{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(ov["$ID"]))}, - VariableName: extractString(ov["VariableName"]), - } - } - - // Parse BodyVariable (nested object) - if bv := extractBsonMap(raw["BodyVariable"]); bv != nil { - action.BodyVariable = µflows.RestBodyVar{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(bv["$ID"]))}, - VariableName: extractString(bv["VariableName"]), - } - } - - // Parse ParameterMappings (path params) - for _, pm := range extractBsonArray(raw["ParameterMappings"]) { - if pmMap, ok := pm.(map[string]any); ok { - action.ParameterMappings = append(action.ParameterMappings, µflows.RestParameterMapping{ - Parameter: extractString(pmMap["Parameter"]), - Value: extractString(pmMap["Value"]), - }) - } - } - - // Parse QueryParameterMappings - for _, qm := range extractBsonArray(raw["QueryParameterMappings"]) { - if qmMap, ok := qm.(map[string]any); ok { - action.QueryParameterMappings = append(action.QueryParameterMappings, µflows.RestQueryParameterMapping{ - Parameter: extractString(qmMap["QueryParameter"]), - Value: extractString(qmMap["Value"]), - Included: extractString(qmMap["Included"]), - }) - } - } - - return action -} - -func parseHttpConfiguration(raw map[string]any) *microflows.HttpConfiguration { - config := µflows.HttpConfiguration{} - config.ID = model.ID(extractBsonID(raw["$ID"])) - config.HttpMethod = microflows.HttpMethod(extractString(raw["HttpMethod"])) - config.CustomLocation = extractString(raw["CustomLocation"]) - config.UseAuthentication = extractBool(raw["UseHttpAuthentication"], false) - config.Username = extractString(raw["HttpAuthenticationUserName"]) - config.Password = extractString(raw["HttpAuthenticationPassword"]) - - // Parse CustomLocationTemplate (URL template with parameters) - if locTemplate, ok := raw["CustomLocationTemplate"].(map[string]any); ok { - config.LocationTemplate = extractString(locTemplate["Text"]) - config.LocationParams = parseTemplateParameters(locTemplate) - } else if locTemplateD, ok := raw["CustomLocationTemplate"].(primitive.D); ok { - locTemplateM := locTemplateD.Map() - config.LocationTemplate = extractString(locTemplateM["Text"]) - config.LocationParams = parseTemplateParameters(locTemplateM) - } - - // Parse HttpHeaderEntries - if headers, ok := raw["HttpHeaderEntries"].(primitive.A); ok { - for _, h := range headers { - if hMap, ok := h.(primitive.D); ok { - header := parseHttpHeader(hMap.Map()) - if header != nil { - config.CustomHeaders = append(config.CustomHeaders, header) - } - } else if hMap, ok := h.(map[string]any); ok { - header := parseHttpHeader(hMap) - if header != nil { - config.CustomHeaders = append(config.CustomHeaders, header) - } - } - } - } - - return config -} - -func parseTemplateParameters(raw map[string]any) []string { - var params []string - if paramsArr, ok := raw["Parameters"].(primitive.A); ok { - for _, p := range paramsArr { - if pMap, ok := p.(primitive.D); ok { - expr := extractString(pMap.Map()["Expression"]) - params = append(params, expr) - } else if pMap, ok := p.(map[string]any); ok { - expr := extractString(pMap["Expression"]) - params = append(params, expr) - } - } - } - return params -} - -func parseHttpHeader(raw map[string]any) *microflows.HttpHeader { - if raw == nil { - return nil - } - return µflows.HttpHeader{ - Name: extractString(raw["Key"]), - Value: extractString(raw["Value"]), - } -} - -func parseResultHandling(raw map[string]any, handlingType string) microflows.ResultHandling { - switch handlingType { - case "String": - result := µflows.ResultHandlingString{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.VariableName = extractString(raw["ResultVariableName"]) - return result - case "HttpResponse": - result := µflows.ResultHandlingHttpResponse{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.VariableName = extractString(raw["ResultVariableName"]) - return result - case "FileDocument": - // The entity lives in VariableType and is always a specialization of - // System.FileDocument — the base is rejected as a return type (CE0362). - // Without this case the whole handling read back as nil, which the - // describer rendered as `returns String` while also losing the output - // variable, so a describe → exec round trip silently retyped the - // activity and still built clean. Issue #922. - result := µflows.ResultHandlingFileDocument{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.VariableName = extractString(raw["ResultVariableName"]) - if varType := toMap(raw["VariableType"]); varType != nil { - result.EntityRef = extractString(varType["Entity"]) - } - return result - case "Mapping": - result := µflows.ResultHandlingMapping{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.ResultVariable = extractString(raw["ResultVariableName"]) - if call := toMap(raw["ImportMappingCall"]); call != nil { - // Newer BSON uses "Mapping", older uses "ReturnValueMapping" - mappingRef := extractString(call["Mapping"]) - if mappingRef == "" { - mappingRef = extractString(call["ReturnValueMapping"]) - } - result.MappingID = model.ID(mappingRef) - forceSingleOccurrence := extractBool(call["ForceSingleOccurrence"], false) - result.ForceSingleOccurrence = &forceSingleOccurrence - // The Range is polymorphic: a ConstantRange carries SingleObject - // (All/First) while a CustomRange carries the limit and offset - // expressions. Reading only SingleObject dropped the Custom setting - // entirely, so a describe→edit→exec cycle turned a bounded import - // into an unbounded one (issue #881). - if rangeMap := toMap(call["Range"]); rangeMap != nil { - switch extractString(rangeMap["$Type"]) { - case "Microflows$CustomRange": - result.LimitExpression = extractString(rangeMap["LimitExpression"]) - result.OffsetExpression = extractString(rangeMap["OffsetExpression"]) - default: - result.SingleObject = extractBool(rangeMap["SingleObject"], false) - } - } - } - if varType := toMap(raw["VariableType"]); varType != nil { - result.ResultEntityID = model.ID(extractString(varType["Entity"])) - // A bounded range is a LIST, so an ObjectType variable cannot make it - // single — without this guard a CustomRange read back as First. - if extractString(varType["$Type"]) == "DataTypes$ObjectType" && - result.LimitExpression == "" && result.OffsetExpression == "" { - result.SingleObject = true - } - } - return result - case "None": - result := µflows.ResultHandlingNone{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - return result - default: - return nil - } -} - -func parseRequestHandling(raw map[string]any, handlingType string) microflows.RequestHandling { - switch handlingType { - case "Custom": - result := µflows.CustomRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - if template, ok := raw["Template"].(map[string]any); ok { - result.Template = extractString(template["Text"]) - result.TemplateParams = parseTemplateParameters(template) - } else if templateD, ok := raw["Template"].(primitive.D); ok { - templateM := templateD.Map() - result.Template = extractString(templateM["Text"]) - result.TemplateParams = parseTemplateParameters(templateM) - } - return result - case "Binary": - result := µflows.BinaryRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.Expression = extractString(raw["Expression"]) - return result - case "Mapping": - result := µflows.MappingRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - // ExportMappingCall would be parsed here if needed - return result - case "FormData": - result := µflows.FormDataRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - return result - default: - return nil - } -} - -func parseExecuteDatabaseQueryAction(raw map[string]any) *microflows.ExecuteDatabaseQueryAction { - action := µflows.ExecuteDatabaseQueryAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.Query = extractString(raw["Query"]) - action.DynamicQuery = extractString(raw["DynamicQuery"]) - - // Parse ParameterMappings - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.DatabaseQueryParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.ParameterName = extractString(mMap["ParameterName"]) - mapping.Value = extractString(mMap["Value"]) - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - // Parse ConnectionParameterMappings - if mappings := extractBsonArray(raw["ConnectionParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.DatabaseConnectionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.ParameterName = extractString(mMap["ParameterName"]) - mapping.Value = extractString(mMap["Value"]) - action.ConnectionParameterMappings = append(action.ConnectionParameterMappings, mapping) - } - } - } - - return action -} - -func parseImportXmlAction(raw map[string]any) *microflows.ImportXmlAction { - action := µflows.ImportXmlAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.IsValidationRequired = extractBool(raw["IsValidationRequired"], false) - action.XmlDocumentVariable = extractString(raw["XmlDocumentVariableName"]) - - if rh := toMap(raw["ResultHandling"]); rh != nil { - handling := µflows.ResultHandlingMapping{} - handling.ID = model.ID(extractBsonID(rh["$ID"])) - handling.ResultVariable = extractString(rh["ResultVariableName"]) - if call := toMap(rh["ImportMappingCall"]); call != nil { - mappingRef := extractString(call["Mapping"]) - if mappingRef == "" { - mappingRef = extractString(call["ReturnValueMapping"]) - } - handling.MappingID = model.ID(mappingRef) - if varType := toMap(call["VariableType"]); varType != nil { - handling.ResultEntityID = model.ID(extractString(varType["Entity"])) - } - forceSingleOccurrence := extractBool(call["ForceSingleOccurrence"], false) - handling.ForceSingleOccurrence = &forceSingleOccurrence - // The Range is polymorphic — a ConstantRange carries SingleObject - // (Studio Pro's All/First), a CustomRange the limit and offset - // expressions. Reading only SingleObject dropped Custom entirely, so - // describe→edit→exec turned a bounded import unbounded. (issue #881) - if rangeMap := toMap(call["Range"]); rangeMap != nil { - switch extractString(rangeMap["$Type"]) { - case "Microflows$CustomRange": - handling.LimitExpression = extractString(rangeMap["LimitExpression"]) - handling.OffsetExpression = extractString(rangeMap["OffsetExpression"]) - default: - single := extractBool(rangeMap["SingleObject"], false) - handling.RangeSingleObject = &single - handling.SingleObject = single - } - } - // The result variable's cardinality is the stored VariableType where - // there is one; it does NOT track the range (Mendix's own - // SUB_Feedback_PostToAppInsights pairs ConstantRange{SingleObject:false} - // with an ObjectType). Only otherwise does ForceSingleOccurrence stand - // in — and never for a bounded range, which is always a list, or a - // Custom range reads back as First and loses the limit. - switch extractString(toMap(rh["VariableType"])["$Type"]) { - case "DataTypes$ObjectType": - handling.SingleObject = true - case "DataTypes$ListType": - handling.SingleObject = false - default: - if !handling.SingleObject && handling.LimitExpression == "" && handling.OffsetExpression == "" { - handling.SingleObject = forceSingleOccurrence - } - } - } - // The writer stores VariableType on the ResultHandling, not on the - // ImportMappingCall, so the lookup above finds nothing on anything mxcli or - // Studio Pro writes — leaving the result entity empty. - if varType := toMap(rh["VariableType"]); varType != nil && handling.ResultEntityID == "" { - handling.ResultEntityID = model.ID(extractString(varType["Entity"])) - } - action.ResultHandling = handling - } - - return action -} - -func parseTransformJsonAction(raw map[string]any) *microflows.TransformJsonAction { - action := µflows.TransformJsonAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.InputVariableName = extractString(raw["InputVariableName"]) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.Transformation = extractString(raw["Transformation"]) - return action -} - -func parseExportXmlAction(raw map[string]any) *microflows.ExportXmlAction { - action := µflows.ExportXmlAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.IsValidationRequired = extractBool(raw["IsValidationRequired"], false) - - // OutputMethod: ExportXmlAction$StringExport has OutputVariableName - if om := toMap(raw["OutputMethod"]); om != nil { - action.OutputVariable = extractString(om["OutputVariableName"]) - } - - // ResultHandling: Microflows$MappingRequestHandling with MappingId and MappingVariableName - if rh := toMap(raw["ResultHandling"]); rh != nil { - handling := µflows.MappingRequestHandling{} - handling.ID = model.ID(extractBsonID(rh["$ID"])) - handling.ParameterVariable = extractString(rh["MappingVariableName"]) - handling.MappingID = model.ID(extractString(rh["MappingId"])) - action.RequestHandling = handling - } - - return action -} - -// parseQueueSettings reads a call's Queues$QueueSettings child — the binding to -// a task queue. Without it the legacy engine's DESCRIBE rendered a queued call -// as an ordinary one, so a describe → exec round trip dropped the binding and -// nothing on this engine could see it (FINDINGS #25's "describe showing nothing -// is not evidence of nothing"). -func parseQueueSettings(raw map[string]any) *microflows.QueueSettings { - qs, ok := raw["QueueSettings"].(map[string]any) - if !ok || qs == nil { - return nil - } - out := µflows.QueueSettings{Queue: extractString(qs["Queue"])} - out.ID = model.ID(extractBsonID(qs["$ID"])) - if retry, ok := qs["Retry"]; ok && retry != nil { - out.Retry = retry - } - return out -} diff --git a/sdk/mpr/parser_microflow_error_handling_1078_test.go b/sdk/mpr/parser_microflow_error_handling_1078_test.go deleted file mode 100644 index 431e5eb0d9..0000000000 --- a/sdk/mpr/parser_microflow_error_handling_1078_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// mendixlabs/mxcli#1078, the legacy engine's half. -// -// Fixing the describer made the default (modelsdk) engine round-trip an error -// handler again, and MXCLI_ENGINE=legacy still dropped it — for a second, -// independent reason: nine parse functions never read ErrorHandlingType off the -// BSON at all, so the value was gone before the describer could be asked about -// it. Measured on the same project: 0 errors on modelsdk, handler still missing -// on legacy, until these were fixed too. -// -// The two defects are stacked, which is why fixing one looked like fixing both. -func TestParse1078_ActionsReadErrorHandlingType(t *testing.T) { - // "Custom" is what Studio Pro's "custom with rollback" stores, and it is the - // value the reporter's create-variable activity carried. - const custom = "Custom" - - for _, tc := range []struct { - name string - got func() microflows.ErrorHandlingType - }{ - {"create variable", func() microflows.ErrorHandlingType { - return parseCreateVariableAction(map[string]any{ - "$ID": "a", "VariableName": "name", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"change variable", func() microflows.ErrorHandlingType { - return parseChangeVariableAction(map[string]any{ - "$ID": "a", "ChangeVariableName": "name", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"create object", func() microflows.ErrorHandlingType { - return parseCreateObjectAction(map[string]any{ - "$ID": "a", "Entity": "Mod.Car", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"change object", func() microflows.ErrorHandlingType { - return parseChangeObjectAction(map[string]any{ - "$ID": "a", "ChangeVariableName": "Car", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"close page", func() microflows.ErrorHandlingType { - return parseClosePageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"log message", func() microflows.ErrorHandlingType { - return parseLogMessageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"show message", func() microflows.ErrorHandlingType { - return parseShowMessageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"show page", func() microflows.ErrorHandlingType { - return parseShowPageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"validation feedback", func() microflows.ErrorHandlingType { - return parseValidationFeedbackAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - } { - t.Run(tc.name, func(t *testing.T) { - if got := tc.got(); got != microflows.ErrorHandlingTypeCustom { - t.Errorf("ErrorHandlingType = %q, want %q — the whole error branch is "+ - "dropped from DESCRIBE when this is empty", - got, microflows.ErrorHandlingTypeCustom) - } - }) - } -} - -// Control. An absent ErrorHandlingType must stay empty rather than being invented: -// #840 established that a rendered `on error rollback` puts a clause in the -// user's script they never wrote, and these parsers are read by the same -// describer. -func TestParse1078_AbsentErrorHandlingTypeStaysEmpty(t *testing.T) { - if got := parseCreateVariableAction(map[string]any{ - "$ID": "a", "VariableName": "name", - }).ErrorHandlingType; got != "" { - t.Errorf("ErrorHandlingType = %q, want empty for BSON that carries none", got) - } -} diff --git a/sdk/mpr/parser_microflow_import_range_test.go b/sdk/mpr/parser_microflow_import_range_test.go deleted file mode 100644 index 4bcd28d93d..0000000000 --- a/sdk/mpr/parser_microflow_import_range_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// upstream #881, legacy engine. Both engines share the semantic model, so a fix -// in one is invisible to anyone running the other (MXCLI_ENGINE=legacy). These -// mirror the modelsdk tests against the legacy parser/serializer. - -// A CustomRange must survive the round trip: the reader dispatches on $Type, and -// the writer selects the variant. Before this, "Custom" was unrepresentable and -// a bounded import silently became unbounded on the next exec. -func TestLegacyImportXmlActionRoundTripsCustomRange(t *testing.T) { - doc := serializeImportXmlAction(µflows.ImportXmlAction{ - BaseElement: model.BaseElement{ID: model.ID("a-1")}, - ResultHandling: µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID("rh-1")}, - MappingID: model.ID("M.IMM"), - ResultEntityID: model.ID("M.Root"), - ResultVariable: "Out", - LimitExpression: "10", - OffsetExpression: "5", - }, - XmlDocumentVariable: "Resp", - }) - - rhFields := bsonDMap(asD(t, bsonDMap(doc)["ResultHandling"])) - call := bsonDMap(asD(t, rhFields["ImportMappingCall"])) - rangeDoc := bsonDMap(asD(t, call["Range"])) - - if got := rangeDoc["$Type"]; got != "Microflows$CustomRange" { - t.Fatalf("Range $Type = %v, want Microflows$CustomRange", got) - } - if got := rangeDoc["LimitExpression"]; got != "10" { - t.Errorf("LimitExpression = %v, want 10", got) - } - if got := rangeDoc["OffsetExpression"]; got != "5" { - t.Errorf("OffsetExpression = %v, want 5", got) - } - if _, ok := rangeDoc["SingleObject"]; ok { - t.Error("a CustomRange must not carry SingleObject — a bounded range is always bounded") - } -} - -// The read side of the same. The result entity also lives on the ResultHandling, -// not on the ImportMappingCall, which is where the legacy parser looked — so it -// came back empty for everything mxcli or Studio Pro writes. -func TestLegacyParseImportXmlActionReadsCustomRange(t *testing.T) { - got := parseImportXmlAction(map[string]any{ - "$ID": "a-1", - "XmlDocumentVariableName": "Resp", - "ResultHandling": map[string]any{ - "$ID": "rh-1", - "ResultVariableName": "Out", - "ImportMappingCall": map[string]any{ - "ReturnValueMapping": "M.IMM", - "ForceSingleOccurrence": false, - "Range": map[string]any{ - "$Type": "Microflows$CustomRange", - "LimitExpression": "10", - "OffsetExpression": "5", - }, - }, - "VariableType": map[string]any{ - "$Type": "DataTypes$ListType", - "Entity": "M.Root", - }, - }, - }) - - if got.ResultHandling == nil { - t.Fatal("ResultHandling missing") - } - h := got.ResultHandling - if h.LimitExpression != "10" || h.OffsetExpression != "5" { - t.Errorf("limit/offset = %q/%q, want 10/5", h.LimitExpression, h.OffsetExpression) - } - if h.SingleObject { - t.Error("SingleObject = true, want false (ListType variable)") - } - if string(h.ResultEntityID) != "M.Root" { - t.Errorf("ResultEntityID = %q, want M.Root — VariableType is stored on the "+ - "ResultHandling, not on the ImportMappingCall", h.ResultEntityID) - } -} - -// The shape Mendix ships in the blank app: range All against an OBJECT variable. -// The range and the variable's cardinality are separate axes, and folding one -// into the other describes this as `first` — rewriting the activity on re-exec. -func TestLegacyParseImportXmlActionSeparatesRangeFromCardinality(t *testing.T) { - got := parseImportXmlAction(map[string]any{ - "$ID": "a-1", - "XmlDocumentVariableName": "Resp", - "ResultHandling": map[string]any{ - "$ID": "rh-1", - "ResultVariableName": "Out", - "ImportMappingCall": map[string]any{ - "ReturnValueMapping": "M.IMM", - "ForceSingleOccurrence": false, - "Range": map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": false}, - }, - "VariableType": map[string]any{"$Type": "DataTypes$ObjectType", "Entity": "M.Root"}, - }, - }) - - h := got.ResultHandling - if h == nil { - t.Fatal("ResultHandling missing") - } - if h.RangeSingleObject == nil || *h.RangeSingleObject { - t.Errorf("RangeSingleObject = %v, want explicit false — the range is All", h.RangeSingleObject) - } - if !h.SingleObject { - t.Error("SingleObject = false, want true — the stored ObjectType is the authority " + - "on the variable's cardinality, and mxbuild rejects the mismatch with CE0243") - } -} - -// The writer's variant choice must read the RANGE's flag, not the variable's: -// serializing Mendix's own All-against-an-object shape as First changes it. -func TestLegacySerializeImportXmlActionWritesRangeFlagNotCardinality(t *testing.T) { - no := false - doc := serializeImportXmlAction(µflows.ImportXmlAction{ - BaseElement: model.BaseElement{ID: model.ID("a-1")}, - ResultHandling: µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID("rh-1")}, - MappingID: model.ID("M.IMM"), - ResultEntityID: model.ID("M.Root"), - ResultVariable: "Out", - SingleObject: true, // an object-rooted mapping - RangeSingleObject: &no, // …with the range left at All - }, - XmlDocumentVariable: "Resp", - }) - - rhFields := bsonDMap(asD(t, bsonDMap(doc)["ResultHandling"])) - call := bsonDMap(asD(t, rhFields["ImportMappingCall"])) - rangeDoc := bsonDMap(asD(t, call["Range"])) - if got := rangeDoc["SingleObject"]; got != false { - t.Errorf("Range.SingleObject = %v, want false — the range is All", got) - } - varType := bsonDMap(asD(t, rhFields["VariableType"])) - if got := varType["$Type"]; got != "DataTypes$ObjectType" { - t.Errorf("VariableType = %v, want DataTypes$ObjectType — the variable follows the "+ - "mapping, not the range", got) - } -} - -// asD narrows a nested BSON value so a missing sub-document fails at the field -// it is missing from rather than as a bare type-assertion panic. -func asD(t *testing.T, v any) primitive.D { - t.Helper() - d, ok := v.(primitive.D) - if !ok { - t.Fatalf("expected a BSON document, got %T", v) - } - return d -} diff --git a/sdk/mpr/parser_microflow_test.go b/sdk/mpr/parser_microflow_test.go deleted file mode 100644 index 2c8688712c..0000000000 --- a/sdk/mpr/parser_microflow_test.go +++ /dev/null @@ -1,408 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -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" -) - -func TestParseSequenceFlow_NewCaseValueEnumerationCase(t *testing.T) { - flow := parseSequenceFlow(map[string]any{ - "$ID": "flow-1", - "OriginPointer": "start-1", - "DestinationPointer": "dest-1", - "OriginConnectionIndex": int32(1), - "DestinationConnectionIndex": int32(2), - "NewCaseValue": primitive.D{ - {Key: "$ID", Value: "case-1"}, - {Key: "$Type", Value: "Microflows$EnumerationCase"}, - {Key: "Value", Value: "true"}, - }, - }) - - got, ok := flow.CaseValue.(*microflows.EnumerationCase) - if !ok { - t.Fatalf("expected *EnumerationCase, got %T", flow.CaseValue) - } - if got.Value != "true" { - t.Fatalf("expected true branch, got %q", got.Value) - } -} - -func TestParseSequenceFlow_NewCaseValueExpressionCase(t *testing.T) { - flow := parseSequenceFlow(map[string]any{ - "$ID": "flow-1", - "OriginPointer": "start-1", - "DestinationPointer": "dest-1", - "NewCaseValue": primitive.D{ - {Key: "$ID", Value: "case-1"}, - {Key: "$Type", Value: "Microflows$ExpressionCase"}, - {Key: "Expression", Value: "false"}, - }, - }) - - got, ok := flow.CaseValue.(*microflows.ExpressionCase) - if !ok { - t.Fatalf("expected *ExpressionCase, got %T", flow.CaseValue) - } - if got.Expression != "false" { - t.Fatalf("expected false branch, got %q", got.Expression) - } -} - -func TestParseSequenceFlow_NewCaseValueNoCase(t *testing.T) { - flow := parseSequenceFlow(map[string]any{ - "$ID": "flow-1", - "OriginPointer": "start-1", - "DestinationPointer": "dest-1", - "NewCaseValue": primitive.D{ - {Key: "$ID", Value: "case-1"}, - {Key: "$Type", Value: "Microflows$NoCase"}, - }, - }) - - if _, ok := flow.CaseValue.(*microflows.NoCase); !ok { - t.Fatalf("expected *NoCase, got %T", flow.CaseValue) - } -} - -func TestParseCommitAction_ErrorHandlingTypeExplicit(t *testing.T) { - action := parseCommitAction(map[string]any{ - "$ID": "commit-1", - "CommitVariableName": "Order", - "WithEvents": true, - "RefreshInClient": false, - "ErrorHandlingType": "Continue", - }) - - if action.ErrorHandlingType != microflows.ErrorHandlingTypeContinue { - t.Errorf("expected Continue, got %q", action.ErrorHandlingType) - } - if action.CommitVariable != "Order" { - t.Errorf("expected CommitVariable Order, got %q", action.CommitVariable) - } -} - -func TestParseCommitAction_ErrorHandlingTypeDefaultsToRollback(t *testing.T) { - // When ErrorHandlingType is absent from BSON, the describer must still - // emit "on error rollback" — matching Mendix Studio Pro's default. - // Without this default, describe → exec → describe drops the suffix - // because the writer omits the field when it equals Rollback. - action := parseCommitAction(map[string]any{ - "$ID": "commit-1", - "CommitVariableName": "Order", - "WithEvents": false, - "RefreshInClient": false, - }) - - if action.ErrorHandlingType != microflows.ErrorHandlingTypeRollback { - t.Errorf("expected default Rollback, got %q", action.ErrorHandlingType) - } -} - -func TestParseCodeActionParameterValue_MicroflowParameterValue(t *testing.T) { - value := parseCodeActionParameterValue(map[string]any{ - "$ID": "value-1", - "$Type": "Microflows$MicroflowParameterValue", - "Microflow": "SyntheticModule.Callback", - }) - - got, ok := value.(*microflows.MicroflowParameterValue) - if !ok { - t.Fatalf("value = %T, want *MicroflowParameterValue", value) - } - if got.Microflow != "SyntheticModule.Callback" { - t.Fatalf("microflow = %q", got.Microflow) - } -} - -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 TestSerializeSortItemPreservesIndirectEntityRef(t *testing.T) { - doc := serializeSortItem(µflows.SortItem{ - BaseElement: model.BaseElement{ID: model.ID("sort-1")}, - AttributeQualifiedName: "SampleApps.ApplicationView.CreatedAt", - EntityRefSteps: []microflows.EntityRefStep{ - { - Association: "SampleApps.DeploymentTarget_ApplicationView", - DestinationEntity: "SampleApps.ApplicationView", - }, - }, - Direction: microflows.SortDirectionDescending, - }) - - attrRef, ok := bsonDMap(doc)["AttributeRef"].(primitive.D) - if !ok { - t.Fatalf("AttributeRef missing or wrong type: %T", bsonDMap(doc)["AttributeRef"]) - } - entityRef, ok := bsonDMap(attrRef)["EntityRef"].(primitive.D) - if !ok { - t.Fatalf("EntityRef missing or wrong type: %T", bsonDMap(attrRef)["EntityRef"]) - } - if got := bsonDMap(entityRef)["$Type"]; got != "DomainModels$IndirectEntityRef" { - t.Fatalf("EntityRef.$Type = %v, want DomainModels$IndirectEntityRef", got) - } - steps, ok := bsonDMap(entityRef)["Steps"].(primitive.A) - if !ok || len(steps) != 2 { - t.Fatalf("Steps = %#v, want marker plus one step", bsonDMap(entityRef)["Steps"]) - } - step, ok := steps[1].(primitive.D) - if !ok { - t.Fatalf("step type = %T, want primitive.D", steps[1]) - } - stepFields := bsonDMap(step) - if got := stepFields["Association"]; got != "SampleApps.DeploymentTarget_ApplicationView" { - t.Fatalf("Association = %v", got) - } - if got := stepFields["DestinationEntity"]; got != "SampleApps.ApplicationView" { - t.Fatalf("DestinationEntity = %v", got) - } -} - -func TestParseSortItemsPreservesIndirectEntityRef(t *testing.T) { - got := parseSortItems(map[string]any{ - "NewSortings": map[string]any{ - "Sortings": []any{ - int32(2), - map[string]any{ - "$ID": "sort-1", - "$Type": "Microflows$RetrieveSorting", - "SortOrder": "Descending", - "AttributeRef": map[string]any{ - "$Type": "DomainModels$AttributeRef", - "Attribute": "SampleApps.ApplicationView.CreatedAt", - "EntityRef": map[string]any{ - "$Type": "DomainModels$IndirectEntityRef", - "Steps": []any{ - int32(2), - map[string]any{ - "$Type": "DomainModels$EntityRefStep", - "Association": "SampleApps.DeploymentTarget_ApplicationView", - "DestinationEntity": "SampleApps.ApplicationView", - }, - }, - }, - }, - }, - }, - }, - }) - - if len(got) != 1 { - t.Fatalf("got %d sort items, want 1", len(got)) - } - if steps := got[0].EntityRefSteps; len(steps) != 1 || steps[0].Association != "SampleApps.DeploymentTarget_ApplicationView" || steps[0].DestinationEntity != "SampleApps.ApplicationView" { - t.Fatalf("EntityRefSteps = %#v", steps) - } -} - -func TestParseActionActivityPreservesWebServiceActionRawBSONOrder(t *testing.T) { - rawAction := primitive.D{ - {Key: "$ID", Value: "web-service-action-ordered"}, - {Key: "$Type", Value: "Microflows$CallWebServiceAction"}, - {Key: "ImportedService", Value: "SyntheticSOAP.OrderService"}, - {Key: "OperationName", Value: "FetchItemsByTenant"}, - {Key: "TimeOutExpression", Value: "30"}, - {Key: "NewResultHandling", Value: primitive.D{ - {Key: "$Type", Value: "Microflows$WebServiceOperationResultHandling"}, - {Key: "ResultVariableName", Value: "SampleResponse"}, - }}, - } - expectedRaw, err := bson.Marshal(rawAction) - if err != nil { - t.Fatal(err) - } - - activity := parseActionActivity(map[string]any{ - "$ID": "activity-with-web-service-action", - "$Type": "Microflows$ActionActivity", - "Action": rawAction, - }) - action, ok := activity.Action.(*microflows.WebServiceCallAction) - if !ok { - t.Fatalf("Action = %T, want *WebServiceCallAction", activity.Action) - } - if !bytes.Equal(action.RawBSON, expectedRaw) { - t.Fatalf("RawBSON was not preserved byte-for-byte") - } - - serializedRaw, err := bson.Marshal(serializeWebServiceCallAction(action)) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(serializedRaw, expectedRaw) { - t.Fatalf("serialized raw BSON was not preserved byte-for-byte") - } -} - -func TestParseWebServiceActionFallsBackToRawBSONForUnsupportedFields(t *testing.T) { - action := parseWebServiceCallAction(map[string]any{ - "$ID": "soap-action-with-simple-request", - "$Type": "Microflows$CallWebServiceAction", - "ImportedService": "SyntheticSOAP.OrderService", - "OperationName": "SubmitOrder", - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", - "ParameterMappings": []any{ - int32(2), - map[string]any{ - "$Type": "Microflows$WebServiceOperationSimpleParameterMapping", - "Argument": "$OrderID", - }, - }, - }, - }) - - if len(action.RawBSON) == 0 { - t.Fatal("RawBSON was empty for unsupported SOAP request details") - } - serialized := serializeWebServiceCallAction(action) - if got := bsonGetKey(serialized, "RequestBodyHandling"); got == nil { - t.Fatalf("RequestBodyHandling was not preserved in raw fallback: %#v", serialized) - } -} diff --git a/sdk/mpr/parser_microflow_workflow.go b/sdk/mpr/parser_microflow_workflow.go deleted file mode 100644 index da7ecaabb0..0000000000 --- a/sdk/mpr/parser_microflow_workflow.go +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -func parseWorkflowCallAction(raw map[string]any) *microflows.WorkflowCallAction { - action := µflows.WorkflowCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.Workflow = extractString(raw["Workflow"]) - action.WorkflowContextVariable = extractString(raw["WorkflowContextVariable"]) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - return action -} - -func parseGetWorkflowDataAction(raw map[string]any) *microflows.GetWorkflowDataAction { - action := µflows.GetWorkflowDataAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.Workflow = extractString(raw["Workflow"]) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseGetWorkflowsAction(raw map[string]any) *microflows.GetWorkflowsAction { - action := µflows.GetWorkflowsAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.WorkflowContextVariableName = extractString(raw["WorkflowContextVariableName"]) - return action -} - -func parseGetWorkflowActivityRecordsAction(raw map[string]any) *microflows.GetWorkflowActivityRecordsAction { - action := µflows.GetWorkflowActivityRecordsAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseWorkflowOperationAction(raw map[string]any) *microflows.WorkflowOperationAction { - action := µflows.WorkflowOperationAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - - if opRaw, ok := raw["Operation"].(map[string]any); ok { - action.Operation = parseWorkflowOperation(opRaw) - } - return action -} - -func parseWorkflowOperation(raw map[string]any) microflows.WorkflowOperation { - typeName := extractString(raw["$Type"]) - wfVar := extractString(raw["WorkflowVariable"]) - - switch typeName { - case "Microflows$AbortOperation": - op := µflows.AbortOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - // Reason is a StringTemplate - if reason, ok := raw["Reason"].(map[string]any); ok { - op.Reason = extractString(reason["Text"]) - } - return op - case "Microflows$ContinueOperation": - op := µflows.ContinueOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$PauseOperation": - op := µflows.PauseOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$RestartOperation": - op := µflows.RestartOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$RetryOperation": - op := µflows.RetryOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$UnpauseOperation": - op := µflows.UnpauseOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - } - return nil -} - -func parseSetTaskOutcomeAction(raw map[string]any) *microflows.SetTaskOutcomeAction { - action := µflows.SetTaskOutcomeAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutcomeValue = extractString(raw["OutcomeValue"]) - action.WorkflowTaskVariable = extractString(raw["WorkflowTaskVariable"]) - return action -} - -func parseOpenUserTaskAction(raw map[string]any) *microflows.OpenUserTaskAction { - action := µflows.OpenUserTaskAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.UserTaskVariable = extractString(raw["UserTaskVariable"]) - return action -} - -func parseNotifyWorkflowAction(raw map[string]any) *microflows.NotifyWorkflowAction { - action := µflows.NotifyWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseOpenWorkflowAction(raw map[string]any) *microflows.OpenWorkflowAction { - action := µflows.OpenWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseLockWorkflowAction(raw map[string]any) *microflows.LockWorkflowAction { - action := µflows.LockWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.PauseAllWorkflows = extractBool(raw["PauseAllWorkflows"], false) - - if sel, ok := raw["WorkflowSelection"].(map[string]any); ok { - selType := extractString(sel["$Type"]) - switch selType { - case "Workflows$WorkflowDefinitionNameSelection": - action.Workflow = extractString(sel["Workflow"]) - case "Workflows$WorkflowDefinitionObjectSelection": - action.WorkflowVariable = extractString(sel["WorkflowDefinitionVariable"]) - } - } - return action -} - -func parseUnlockWorkflowAction(raw map[string]any) *microflows.UnlockWorkflowAction { - action := µflows.UnlockWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ResumeAllPausedWorkflows = extractBool(raw["ResumeAllPausedWorkflows"], false) - - if sel, ok := raw["WorkflowSelection"].(map[string]any); ok { - selType := extractString(sel["$Type"]) - switch selType { - case "Workflows$WorkflowDefinitionNameSelection": - action.Workflow = extractString(sel["Workflow"]) - case "Workflows$WorkflowDefinitionObjectSelection": - action.WorkflowVariable = extractString(sel["WorkflowDefinitionVariable"]) - } - } - return action -} diff --git a/sdk/mpr/parser_misc.go b/sdk/mpr/parser_misc.go deleted file mode 100644 index 690140df72..0000000000 --- a/sdk/mpr/parser_misc.go +++ /dev/null @@ -1,838 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func (r *Reader) resolveContents(unitID string, contents []byte) ([]byte, error) { - // For MPR v1, contents are stored directly in the database - if r.version == MPRVersionV1 { - return contents, nil - } - - // For MPR v2, check if contents is a reference to an external file - // Contents might be empty or contain just a hash - if len(contents) > 0 { - // Check if it's actual BSON content (starts with length prefix) - if len(contents) >= 4 { - return contents, nil - } - } - - // Look for the external file in mprcontents - externalPath := filepath.Join(r.contentsDir, unitID) - if _, err := os.Stat(externalPath); err == nil { - return os.ReadFile(externalPath) - } - - // Try with common extensions - for _, ext := range []string{".mxunit", ".json", ""} { - path := filepath.Join(r.contentsDir, unitID+ext) - if data, err := os.ReadFile(path); err == nil { - return data, nil - } - } - - return contents, nil -} - -// parseSnippet parses snippet contents from BSON. -func (r *Reader) parseSnippet(unitID, containerID string, contents []byte) (*pages.Snippet, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - snippet := &pages.Snippet{} - snippet.ID = model.ID(unitID) - snippet.TypeName = "Pages$Snippet" - snippet.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - snippet.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - snippet.Documentation = doc - } - // Excluded must survive read→rebuild→write (#914). - if excl, ok := raw["Excluded"].(bool); ok { - snippet.Excluded = excl - } - if entityID := extractID(raw["Entity"]); entityID != "" { - snippet.EntityID = model.ID(entityID) - } - - // Parse snippet parameters so callers can validate SNIPPETCALL param mappings. - if params, ok := raw["Parameters"].(bson.A); ok { - for i := 1; i < len(params); i++ { - var paramMap map[string]any - switch v := params[i].(type) { - case bson.D: - paramMap = make(map[string]any, len(v)) - for _, elem := range v { - paramMap[elem.Key] = elem.Value - } - case map[string]any: - paramMap = v - } - if paramMap == nil { - continue - } - sp := &pages.SnippetParameter{} - if id := extractID(paramMap["$ID"]); id != "" { - sp.ID = model.ID(id) - } - if n, ok := paramMap["Name"].(string); ok { - sp.Name = n - } - if sp.Name == "" { - continue - } - // ParameterType: either bson.D or map[string]any - extractParamType := func(m map[string]any) { - if t, ok := m["$Type"].(string); ok { - sp.Type = t - } - if e, ok := m["Entity"].(string); ok { - sp.EntityName = e - } - } - switch pt := paramMap["ParameterType"].(type) { - case bson.D: - m := make(map[string]any, len(pt)) - for _, e := range pt { - m[e.Key] = e.Value - } - extractParamType(m) - case map[string]any: - extractParamType(pt) - } - snippet.Parameters = append(snippet.Parameters, sp) - } - } - - return snippet, nil -} - -// parseJavaAction parses Java action contents from BSON. -func (r *Reader) parseJavaAction(unitID, containerID string, contents []byte) (*JavaAction, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ja := &JavaAction{} - ja.ID = model.ID(unitID) - ja.TypeName = "JavaActions$JavaAction" - ja.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - ja.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - ja.Documentation = doc - } - // Excluded must survive read→rebuild→write; defaulting it to false - // un-excludes the document on the next CREATE OR MODIFY (#914). - if excl, ok := raw["Excluded"].(bool); ok { - ja.Excluded = excl - } - - return ja, nil -} - -// extractID extracts an ID from various BSON representations. -// IDs in Mendix BSON can be strings, binary UUIDs, or nested structures. -func extractID(v any) string { - if v == nil { - return "" - } - - switch val := v.(type) { - case string: - return val - case []byte: - return blobToUUID(val) - case map[string]any: - // Could be a reference structure with $ID - if id, ok := val["$ID"].(string); ok { - return id - } - if id, ok := val["$ID"].([]byte); ok { - return blobToUUID(id) - } - } - - return "" -} - -// WriteJSON serializes the given element to JSON. -func WriteJSON(element any) ([]byte, error) { - return json.MarshalIndent(element, "", " ") -} - -// parseJavaScriptAction parses JavaScript action contents from BSON. -func (r *Reader) parseJavaScriptAction(unitID, containerID string, contents []byte) (*JavaScriptAction, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - jsa := &JavaScriptAction{} - jsa.ID = model.ID(unitID) - jsa.TypeName = "JavaScriptActions$JavaScriptAction" - jsa.ContainerID = model.ID(containerID) - - // Basic fields - jsa.Name = extractString(raw["Name"]) - jsa.Documentation = extractString(raw["Documentation"]) - jsa.Platform = extractString(raw["Platform"]) - jsa.Excluded = extractBool(raw["Excluded"], false) - jsa.ExportLevel = extractString(raw["ExportLevel"]) - jsa.ActionDefaultReturnName = extractString(raw["ActionDefaultReturnName"]) - - // Parse return type - switch rt := raw["JavaReturnType"].(type) { - case map[string]any: - jsa.ReturnType = parseCodeActionReturnType(rt) - case primitive.D: - jsa.ReturnType = parseCodeActionReturnType(primitiveToMap(rt)) - } - - // Parse parameters - switch params := raw["Parameters"].(type) { - case []any: - for _, p := range params { - if pMap := toMap(p); pMap != nil { - if param := parseJavaActionParameter(pMap); param != nil { - jsa.Parameters = append(jsa.Parameters, param) - } - } - } - case primitive.A: - for _, p := range params { - if pMap := toMap(p); pMap != nil { - if param := parseJavaActionParameter(pMap); param != nil { - jsa.Parameters = append(jsa.Parameters, param) - } - } - } - } - - // Parse type parameters - switch typeParams := raw["TypeParameters"].(type) { - case []any: - for _, tp := range typeParams { - if tpMap := toMap(tp); tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - jsa.TypeParameters = append(jsa.TypeParameters, &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - }) - } - } - } - case primitive.A: - for _, tp := range typeParams { - if tpMap := toMap(tp); tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - jsa.TypeParameters = append(jsa.TypeParameters, &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - }) - } - } - } - } - - // Parse MicroflowActionInfo - if mai := toMap(raw["MicroflowActionInfo"]); mai != nil { - jsa.MicroflowActionInfo = parseMicroflowActionInfo(mai) - } - - // Resolve type parameter names for EntityTypeParameterType and TypeParameter - for _, param := range jsa.Parameters { - switch pt := param.ParameterType.(type) { - case *javaactions.EntityTypeParameterType: - pt.TypeParameterName = jsa.FindTypeParameterName(pt.TypeParameterID) - case *javaactions.TypeParameter: - if pt.TypeParameterID != "" && pt.TypeParameter == "" { - pt.TypeParameter = jsa.FindTypeParameterName(pt.TypeParameterID) - } - } - } - - // Resolve type parameter name for return type - if tp, ok := jsa.ReturnType.(*javaactions.TypeParameter); ok { - if tp.TypeParameterID != "" && tp.TypeParameter == "" { - tp.TypeParameter = jsa.FindTypeParameterName(tp.TypeParameterID) - } - } - - return jsa, nil -} - -// ReadJavaScriptActionByName reads a JavaScript action by qualified name (Module.ActionName). -func (r *Reader) ReadJavaScriptActionByName(qualifiedName string) (*JavaScriptAction, error) { - units, err := r.listUnitsByType("JavaScriptActions$JavaScriptAction") - if err != nil { - return nil, err - } - - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - folders, err := r.ListFolders() - if err != nil { - return nil, err - } - folderContainers := make(map[model.ID]model.ID) - for _, f := range folders { - folderContainers[f.ID] = f.ContainerID - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - name := extractString(raw["Name"]) - - modName := "" - containerID := model.ID(u.ContainerID) - for range 20 { - if mn, ok := moduleNames[containerID]; ok { - modName = mn - break - } - if parent, ok := folderContainers[containerID]; ok { - containerID = parent - } else { - break - } - } - - if modName+"."+name == qualifiedName { - return r.parseJavaScriptAction(u.ID, u.ContainerID, contents) - } - } - - return nil, fmt.Errorf("javascript action not found: %s", qualifiedName) -} - -// parseBuildingBlock parses building block contents from BSON. -func (r *Reader) parseBuildingBlock(unitID, containerID string, contents []byte) (*pages.BuildingBlock, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - bb := &pages.BuildingBlock{} - bb.ID = model.ID(unitID) - bb.TypeName = "Pages$BuildingBlock" - bb.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - bb.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - bb.Documentation = doc - } - if displayName, ok := raw["DisplayName"].(string); ok { - bb.DisplayName = displayName - } - if platform, ok := raw["Platform"].(string); ok { - bb.Platform = platform - } - if category, ok := raw["TemplateCategory"].(string); ok { - bb.TemplateCategory = category - } - - return bb, nil -} - -// parsePageTemplate parses page template contents from BSON. -func (r *Reader) parsePageTemplate(unitID, containerID string, contents []byte) (*pages.PageTemplate, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - pt := &pages.PageTemplate{} - pt.ID = model.ID(unitID) - pt.TypeName = "Forms$PageTemplate" - pt.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - pt.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - pt.Documentation = doc - } - - return pt, nil -} - -// parseNavigationDocument parses navigation document contents from BSON. -func (r *Reader) parseNavigationDocument(unitID, containerID string, contents []byte) (*NavigationDocument, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - nav := &NavigationDocument{} - nav.ID = model.ID(unitID) - nav.TypeName = "Navigation$NavigationDocument" - nav.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - nav.Name = name - } - - // Parse navigation profiles - for _, item := range extractBsonArray(raw["Profiles"]) { - profMap, ok := item.(map[string]any) - if !ok { - continue - } - profile := parseNavigationProfile(profMap) - if profile != nil { - nav.Profiles = append(nav.Profiles, profile) - } - } - - return nav, nil -} - -// parseNavigationProfile parses a single navigation profile from BSON. -func parseNavigationProfile(raw map[string]any) *NavigationProfile { - typeName := extractString(raw["$Type"]) - profile := &NavigationProfile{ - Name: extractString(raw["Name"]), - Kind: extractString(raw["Kind"]), - } - - if typeName == "Navigation$NativeNavigationProfile" { - profile.IsNative = true - // Native home page - if hp, ok := raw["NativeHomePage"].(map[string]any); ok { - page := extractString(hp["HomePagePage"]) - nanoflow := extractString(hp["HomePageNanoflow"]) - if page != "" || nanoflow != "" { - profile.HomePage = &NavHomePage{Page: page, Microflow: nanoflow} - } - } - // Native role-based home pages - for _, item := range extractBsonArray(raw["RoleBasedNativeHomePages"]) { - if rbMap, ok := item.(map[string]any); ok { - rbh := &NavRoleBasedHome{ - UserRole: extractString(rbMap["UserRole"]), - Page: extractString(rbMap["HomePagePage"]), - Microflow: extractString(rbMap["HomePageNanoflow"]), - } - if rbh.UserRole != "" { - profile.RoleBasedHomePages = append(profile.RoleBasedHomePages, rbh) - } - } - } - // Native bottom bar items contribute to menu - for _, item := range extractBsonArray(raw["BottomBarItems"]) { - if barMap, ok := item.(map[string]any); ok { - mi := parseNavMenuItemFromBottomBar(barMap) - if mi != nil { - profile.MenuItems = append(profile.MenuItems, mi) - } - } - } - } else { - // Web profile (Navigation$NavigationProfile) - // Default home page - if hp, ok := raw["HomePage"].(map[string]any); ok { - page := extractString(hp["Page"]) - mf := extractString(hp["Microflow"]) - if page != "" || mf != "" { - profile.HomePage = &NavHomePage{Page: page, Microflow: mf} - } - } - // Role-based home pages (stored as "HomeItems") - for _, item := range extractBsonArray(raw["HomeItems"]) { - if rbMap, ok := item.(map[string]any); ok { - rbh := &NavRoleBasedHome{ - UserRole: extractString(rbMap["UserRole"]), - Page: extractString(rbMap["Page"]), - Microflow: extractString(rbMap["Microflow"]), - } - if rbh.UserRole != "" { - profile.RoleBasedHomePages = append(profile.RoleBasedHomePages, rbh) - } - } - } - // Login page (stored as "LoginPageSettings" with type Forms$FormSettings) - if lps, ok := raw["LoginPageSettings"].(map[string]any); ok { - profile.LoginPage = extractString(lps["Form"]) - } - // Not-found page - if nfp, ok := raw["NotFoundHomepage"].(map[string]any); ok { - profile.NotFoundPage = extractString(nfp["Page"]) - if profile.NotFoundPage == "" { - profile.NotFoundPage = extractString(nfp["Microflow"]) - } - } - // Menu items (stored as "Menu" → MenuItemCollection) - if menu, ok := raw["Menu"].(map[string]any); ok { - for _, item := range extractBsonArray(menu["Items"]) { - if miMap, ok := item.(map[string]any); ok { - mi := parseNavMenuItem(miMap) - if mi != nil { - profile.MenuItems = append(profile.MenuItems, mi) - } - } - } - } - } - - // Studio Pro writes this on every web profile, online ones included, and - // neither gen nor generated/metamodel declares it — so it is read straight - // off the raw document. Defaulting to true matches every reference profile - // and Studio Pro's own checked-by-default box, so a document that somehow - // lacks the key is not silently flipped to "do not throw". - profile.ThrowPartialSyncError = extractBool(raw["ThrowPartialSyncError"], true) - - // Offline entity configs (both web and native) - for _, item := range extractBsonArray(raw["OfflineEntityConfigs"]) { - if oeMap, ok := item.(map[string]any); ok { - oe := &NavOfflineEntity{ - Entity: extractString(oeMap["Entity"]), - SyncMode: extractString(oeMap["SyncMode"]), - Constraint: extractString(oeMap["Constraint"]), - CompatibilityMode: extractBool(oeMap["CompatibilityMode"], false), - } - if oe.Entity != "" { - profile.OfflineEntities = append(profile.OfflineEntities, oe) - } - } - } - - return profile -} - -// parseNavMenuItem parses a Menus$MenuItem from BSON. -func parseNavMenuItem(raw map[string]any) *NavMenuItem { - mi := &NavMenuItem{} - - // Extract caption text (Caption → Items → first Translation → Text) - if caption, ok := raw["Caption"].(map[string]any); ok { - mi.Caption = extractTextFromBson(caption) - } - - // Icon is polymorphic: Forms$IconCollectionIcon and Forms$ImageIcon carry a - // qualified Image, Forms$GlyphIcon carries a numeric Code and no name at - // all. Keep the $Type so callers can tell which one they got. - if icon, ok := raw["Icon"].(map[string]any); ok { - mi.IconType = extractString(icon["$Type"]) - mi.Icon = extractString(icon["Image"]) - // The glyph's Code identifies WHICH glyph; without it a caller knows one - // was there and nothing more, so it cannot be re-emitted or carried - // through a rewrite. - mi.IconCode = extractInt(icon["Code"]) - } - - // Extract action type and target from Action - if action, ok := raw["Action"].(map[string]any); ok { - actionType := extractString(action["$Type"]) - switch { - case strings.HasSuffix(actionType, "FormAction") || strings.HasSuffix(actionType, "PageClientAction"): - mi.ActionType = "PageAction" - if fs, ok := action["FormSettings"].(map[string]any); ok { - mi.Page = extractString(fs["Form"]) - } - case strings.HasSuffix(actionType, "MicroflowAction") || strings.HasSuffix(actionType, "MicroflowClientAction"): - mi.ActionType = "MicroflowAction" - if ms, ok := action["MicroflowSettings"].(map[string]any); ok { - mi.Microflow = extractString(ms["Microflow"]) - } - case strings.HasSuffix(actionType, "SignOutClientAction"): - // Named rather than left to the raw-type-name default: DESCRIBE and - // both writers key on "SignOutAction", so a round trip only closes - // if the reader produces the name the writer consumes. - mi.ActionType = "SignOutAction" - case strings.HasSuffix(actionType, "OpenLinkAction") || strings.HasSuffix(actionType, "OpenLinkClientAction"): - mi.ActionType = "OpenLinkAction" - case strings.HasSuffix(actionType, "NoAction") || strings.HasSuffix(actionType, "NoClientAction"): - mi.ActionType = "NoAction" - default: - mi.ActionType = actionType - } - } - - // Recurse into sub-items - for _, item := range extractBsonArray(raw["Items"]) { - if subMap, ok := item.(map[string]any); ok { - sub := parseNavMenuItem(subMap) - if sub != nil { - mi.Items = append(mi.Items, sub) - } - } - } - - // Only return if we have at least a caption or a page - if mi.Caption == "" && mi.Page == "" && len(mi.Items) == 0 { - return nil - } - return mi -} - -// parseNavMenuItemFromBottomBar parses a NativePages$BottomBarItem as a NavMenuItem. -func parseNavMenuItemFromBottomBar(raw map[string]any) *NavMenuItem { - mi := &NavMenuItem{} - if caption, ok := raw["Caption"].(map[string]any); ok { - mi.Caption = extractTextFromBson(caption) - } - mi.Page = extractString(raw["Page"]) - if mi.Caption == "" && mi.Page == "" { - return nil - } - return mi -} - -// extractTextFromBson extracts the first English text from a Texts$Text BSON object. -// Tries Items array first (Items → Translation → Text), then Translations map. -func extractTextFromBson(raw map[string]any) string { - // Try Items array: [{LanguageCode: "en_US", Text: "..."}] - for _, item := range extractBsonArray(raw["Items"]) { - if transMap, ok := item.(map[string]any); ok { - text := extractString(transMap["Text"]) - if text != "" { - return text - } - } - } - // Try Translations array - for _, item := range extractBsonArray(raw["Translations"]) { - if transMap, ok := item.(map[string]any); ok { - text := extractString(transMap["Text"]) - if text != "" { - return text - } - } - } - return "" -} - -// parseImageCollection parses image collection contents from BSON. -func (r *Reader) parseImageCollection(unitID, containerID string, contents []byte) (*ImageCollection, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ic := &ImageCollection{} - ic.ID = model.ID(unitID) - ic.TypeName = "Images$ImageCollection" - ic.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - ic.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - ic.Documentation = doc - } - if exp, ok := raw["ExportLevel"].(string); ok { - ic.ExportLevel = exp - } - - // Parse images in the collection - if images, ok := raw["Images"].(bson.A); ok { - for _, img := range images { - if imgMap, ok := img.(map[string]any); ok { - image := Image{} - if id := extractID(imgMap["$ID"]); id != "" { - image.ID = model.ID(id) - } - if name, ok := imgMap["Name"].(string); ok { - image.Name = name - } - if format, ok := imgMap["ImageFormat"].(string); ok { - image.Format = format - } - if data, ok := imgMap["Image"].(primitive.Binary); ok { - image.Data = data.Data - } else if data, ok := imgMap["Image"].([]byte); ok { - image.Data = data - } - ic.Images = append(ic.Images, image) - } - } - } - - return ic, nil -} - -// parseJsonStructure parses JSON structure contents from BSON. -func (r *Reader) parseJsonStructure(unitID, containerID string, contents []byte) (*JsonStructure, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - js := &JsonStructure{} - js.ID = model.ID(unitID) - js.TypeName = "JsonStructures$JsonStructure" - js.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - js.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - js.Documentation = doc - } - if snippet, ok := raw["JsonSnippet"].(string); ok { - js.JsonSnippet = snippet - } - if exp, ok := raw["ExportLevel"].(string); ok { - js.ExportLevel = exp - } - if exc, ok := raw["Excluded"].(bool); ok { - js.Excluded = exc - } - - // Parse elements (bson.A with version prefix) - if elements, ok := raw["Elements"].(bson.A); ok { - for _, elem := range elements { - if elemMap, ok := elem.(map[string]any); ok { - js.Elements = append(js.Elements, parseJsonElement(elemMap)) - } - } - } - - return js, nil -} - -// parseJsonElement recursively parses a JsonStructures$JsonElement from BSON. -func parseJsonElement(raw map[string]any) *JsonElement { - elem := &JsonElement{ - MaxLength: -1, - FractionDigits: -1, - TotalDigits: -1, - } - - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["ExposedItemName"].(string); ok { - elem.ExposedItemName = v - } - if v, ok := raw["Path"].(string); ok { - elem.Path = v - } - if v, ok := raw["ElementType"].(string); ok { - elem.ElementType = v - } - if v, ok := raw["PrimitiveType"].(string); ok { - elem.PrimitiveType = v - } - // Issue #585: Studio Pro writes these numeric facets as BSON int64; - // mxcli's writer emits int32. extractInt accepts both (plus int and - // float64). Default values for MaxLength/FractionDigits/TotalDigits - // stay at -1 (set in the literal above) when the field is absent. - if _, ok := raw["MinOccurs"]; ok { - elem.MinOccurs = extractInt(raw["MinOccurs"]) - } - if _, ok := raw["MaxOccurs"]; ok { - elem.MaxOccurs = extractInt(raw["MaxOccurs"]) - } - if v, ok := raw["Nillable"].(bool); ok { - elem.Nillable = v - } - if v, ok := raw["IsDefaultType"].(bool); ok { - elem.IsDefaultType = v - } - if _, ok := raw["MaxLength"]; ok { - elem.MaxLength = extractInt(raw["MaxLength"]) - } - if _, ok := raw["FractionDigits"]; ok { - elem.FractionDigits = extractInt(raw["FractionDigits"]) - } - if _, ok := raw["TotalDigits"]; ok { - elem.TotalDigits = extractInt(raw["TotalDigits"]) - } - if v, ok := raw["OriginalValue"].(string); ok { - elem.OriginalValue = v - } - - // Parse children (bson.A with version prefix) - if children, ok := raw["Children"].(bson.A); ok { - for _, child := range children { - if childMap, ok := child.(map[string]any); ok { - elem.Children = append(elem.Children, parseJsonElement(childMap)) - } - } - } - - return elem -} diff --git a/sdk/mpr/parser_misc_test.go b/sdk/mpr/parser_misc_test.go deleted file mode 100644 index a12d01b0c5..0000000000 --- a/sdk/mpr/parser_misc_test.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -// Issue #585: parseJsonElement asserted `raw[field].(int32)` for every numeric -// facet on a JSON-structure element. Mendix Studio Pro stores these fields as -// BSON int64, so the assertion failed silently and the parsed value defaulted -// to 0 — the same class of bug fixed for StringAttributeType.Length in #583. -// -// Each numeric facet must round-trip across every BSON numeric width that the -// mongo-driver may produce (int32, int64, int, float64) and preserve the -// default when the field is missing. -func TestParseJsonElement_NumericFields_BsonNumericWidths(t *testing.T) { - type fieldCase struct { - name string - bsonKey string - read func(*JsonElement) int - missing int // expected zero value when field is absent - } - fields := []fieldCase{ - {"MinOccurs", "MinOccurs", func(e *JsonElement) int { return e.MinOccurs }, 0}, - {"MaxOccurs", "MaxOccurs", func(e *JsonElement) int { return e.MaxOccurs }, 0}, - {"MaxLength", "MaxLength", func(e *JsonElement) int { return e.MaxLength }, -1}, - {"FractionDigits", "FractionDigits", func(e *JsonElement) int { return e.FractionDigits }, -1}, - {"TotalDigits", "TotalDigits", func(e *JsonElement) int { return e.TotalDigits }, -1}, - } - - widths := []struct { - name string - value any - }{ - {"int32 (mxcli writer)", int32(42)}, - {"int64 (Studio Pro writer)", int64(42)}, - {"int", int(42)}, - {"float64 (extended JSON)", float64(42)}, - } - - for _, f := range fields { - for _, w := range widths { - t.Run(f.name+"/"+w.name, func(t *testing.T) { - raw := map[string]any{f.bsonKey: w.value} - elem := parseJsonElement(raw) - if got := f.read(elem); got != 42 { - t.Errorf("%s = %d, want 42 (input %T(%v))", f.name, got, w.value, w.value) - } - }) - } - t.Run(f.name+"/missing", func(t *testing.T) { - elem := parseJsonElement(map[string]any{}) - if got := f.read(elem); got != f.missing { - t.Errorf("%s = %d, want default %d when field absent", f.name, got, f.missing) - } - }) - } -} diff --git a/sdk/mpr/parser_module.go b/sdk/mpr/parser_module.go deleted file mode 100644 index 076482e126..0000000000 --- a/sdk/mpr/parser_module.go +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseModule(unitID string, contents []byte) (*model.Module, error) { - // For MPR v2, contents might be a reference to an external file - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - // Parse BSON contents - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - module := &model.Module{} - module.ID = model.ID(unitID) - module.TypeName = "Projects$Module" - - if name, ok := raw["Name"].(string); ok { - module.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - module.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - module.Excluded = excluded - } - if fromAppStore, ok := raw["FromAppStore"].(bool); ok { - module.FromAppStore = fromAppStore - } - if appStoreVersion, ok := raw["AppStoreVersion"].(string); ok { - module.AppStoreVersion = appStoreVersion - } - if appStoreGuid, ok := raw["AppStoreGuid"].(string); ok { - module.AppStoreGuid = appStoreGuid - } - if isReusable, ok := raw["IsReusableComponent"].(bool); ok { - module.IsReusableComponent = isReusable - } - - return module, nil -} - -// parseDomainModel parses domain model contents from BSON. diff --git a/sdk/mpr/parser_nanoflow.go b/sdk/mpr/parser_nanoflow.go deleted file mode 100644 index bf60f1b930..0000000000 --- a/sdk/mpr/parser_nanoflow.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseNanoflow(unitID, containerID string, contents []byte) (*microflows.Nanoflow, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - nf := µflows.Nanoflow{} - nf.ID = model.ID(unitID) - nf.TypeName = "Microflows$Nanoflow" - nf.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - nf.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - nf.Documentation = doc - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - nf.MarkAsUsed = markAsUsed - } - if excluded, ok := raw["Excluded"].(bool); ok { - nf.Excluded = excluded - } - - // Parse AllowedModuleRoles - for _, role := range extractBsonArray(raw["AllowedModuleRoles"]) { - if roleID, ok := role.(string); ok { - nf.AllowedModuleRoles = append(nf.AllowedModuleRoles, model.ID(roleID)) - } - } - - // Parse parameters (same format variants as microflows) - var paramsArray any - if mpc, ok := raw["MicroflowParameterCollection"]; ok { - if mpcMap := extractBsonMap(mpc); mpcMap != nil { - paramsArray = mpcMap["Parameters"] - } - } else { - paramKey := "MicroflowParameters" - if _, ok := raw[paramKey]; !ok { - paramKey = "Parameters" - } - paramsArray = raw[paramKey] - } - for _, p := range extractBsonSlice(paramsArray) { - if paramMap := extractBsonMap(p); paramMap != nil { - param := parseMicroflowParameter(paramMap, len(nf.Parameters)) - nf.Parameters = append(nf.Parameters, param) - } - } - - // Parse return type (uses same BSON key as microflows) - if rt, ok := raw["MicroflowReturnType"].(map[string]any); ok { - nf.ReturnType = parseMicroflowDataType(rt) - } - - // Parse object collection (activities) - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - nf.ObjectCollection = parseMicroflowObjectCollection(oc) - } - - // Also extract parameters from ObjectCollection.Objects (modern format) - if len(nf.Parameters) == 0 { - if ocRaw := extractBsonMap(raw["ObjectCollection"]); ocRaw != nil { - for _, obj := range extractBsonSlice(ocRaw["Objects"]) { - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - param := parseMicroflowParameter(objMap, len(nf.Parameters)) - nf.Parameters = append(nf.Parameters, param) - } - } - } - } - } - - // Parse Flows array (SequenceFlows and AnnotationFlows at root level) - if flowsRaw := raw["Flows"]; flowsRaw != nil { - if nf.ObjectCollection == nil { - nf.ObjectCollection = µflows.MicroflowObjectCollection{} - } - for _, f := range extractBsonSlice(flowsRaw) { - if flowMap := extractBsonMap(f); flowMap != nil { - typeName, _ := flowMap["$Type"].(string) - switch typeName { - case "Microflows$AnnotationFlow": - if af := parseAnnotationFlow(flowMap); af != nil { - nf.ObjectCollection.AnnotationFlows = append(nf.ObjectCollection.AnnotationFlows, af) - } - default: - if flow := parseSequenceFlow(flowMap); flow != nil { - nf.ObjectCollection.Flows = append(nf.ObjectCollection.Flows, flow) - } - } - } - } - } - - return nf, nil -} - -// parsePage parses page contents from BSON. diff --git a/sdk/mpr/parser_odata.go b/sdk/mpr/parser_odata.go deleted file mode 100644 index b4e8a52084..0000000000 --- a/sdk/mpr/parser_odata.go +++ /dev/null @@ -1,364 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseConsumedODataService parses a consumed OData service (OData client) from BSON. -func (r *Reader) parseConsumedODataService(unitID, containerID string, contents []byte) (*model.ConsumedODataService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.ConsumedODataService{} - svc.ID = model.ID(unitID) - svc.TypeName = "Rest$ConsumedODataService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Version = extractString(raw["Version"]) - svc.ServiceName = extractString(raw["ServiceName"]) - svc.ODataVersion = extractString(raw["ODataVersion"]) - svc.MetadataUrl = extractString(raw["MetadataUrl"]) - svc.TimeoutExpression = extractString(raw["TimeoutExpression"]) - svc.ProxyType = extractString(raw["ProxyType"]) - svc.Description = extractString(raw["Description"]) - svc.Validated = extractBool(raw["Validated"], false) - svc.Excluded = extractBool(raw["Excluded"], false) - - // Microflow references (BY_NAME). The microflow storage fields were renamed/ - // split across Mendix versions (see writer_odata.go / issue #728). Config - // slot: ConfigurationEntityMicroflow (11.10+) or the pre-11.10 single slot - // (ConfigurationMicroflow / ancient HeadersMicroflow). Headers slot is only - // distinct on 11.10+ (HeaderListMicroflow). - for _, key := range []string{"ConfigurationEntityMicroflow", "ConfigurationMicroflow", "HeadersMicroflow"} { - if v := extractString(raw[key]); v != "" { - svc.ConfigurationMicroflow = v - break - } - } - svc.HeadersMicroflow = extractString(raw["HeaderListMicroflow"]) - svc.ErrorHandlingMicroflow = extractString(raw["ErrorHandlingMicroflow"]) - - // Proxy constant references (BY_NAME) - svc.ProxyHost = extractString(raw["ProxyHost"]) - svc.ProxyPort = extractString(raw["ProxyPort"]) - svc.ProxyUsername = extractString(raw["ProxyUsername"]) - svc.ProxyPassword = extractString(raw["ProxyPassword"]) - - // Cached contract metadata - svc.Metadata = extractString(raw["Metadata"]) - svc.MetadataHash = extractString(raw["MetadataHash"]) - - // Mendix Catalog integration - svc.ApplicationId = extractString(raw["ApplicationId"]) - svc.EndpointId = extractString(raw["EndpointId"]) - svc.CatalogUrl = extractString(raw["CatalogUrl"]) - svc.EnvironmentType = extractString(raw["EnvironmentType"]) - - // Parse HTTP configuration (nested part) - if httpCfg, ok := raw["HttpConfiguration"].(map[string]any); ok { - svc.HttpConfiguration = parseODataHttpConfiguration(httpCfg) - } - - return svc, nil -} - -// parseODataHttpConfiguration parses a Microflows$HttpConfiguration BSON map -// into the model.HttpConfiguration type used by consumed OData services. -func parseODataHttpConfiguration(raw map[string]any) *model.HttpConfiguration { - cfg := &model.HttpConfiguration{} - cfg.ID = model.ID(extractBsonID(raw["$ID"])) - cfg.TypeName = extractString(raw["$Type"]) - cfg.UseAuthentication = extractBool(raw["UseHttpAuthentication"], false) - cfg.Username = extractString(raw["HttpAuthenticationUserName"]) - cfg.Password = extractString(raw["HttpAuthenticationPassword"]) - cfg.HttpMethod = extractString(raw["HttpMethod"]) - cfg.OverrideLocation = extractBool(raw["OverrideLocation"], false) - cfg.CustomLocation = extractString(raw["CustomLocation"]) - cfg.ClientCertificate = extractString(raw["ClientCertificate"]) - - // Parse header entries - headers := extractBsonArray(raw["HttpHeaderEntries"]) - for _, h := range headers { - if hMap, ok := h.(map[string]any); ok { - entry := &model.HttpHeaderEntry{} - entry.ID = model.ID(extractBsonID(hMap["$ID"])) - entry.TypeName = extractString(hMap["$Type"]) - entry.Key = extractString(hMap["Key"]) - entry.Value = extractString(hMap["Value"]) - cfg.HeaderEntries = append(cfg.HeaderEntries, entry) - } - } - - return cfg -} - -// parsePublishedODataService parses a published OData service from BSON. -func (r *Reader) parsePublishedODataService(unitID, containerID string, contents []byte) (*model.PublishedODataService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.PublishedODataService{} - svc.ID = model.ID(unitID) - svc.TypeName = "ODataPublish$PublishedODataService2" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Path = extractString(raw["Path"]) - svc.Namespace = extractString(raw["Namespace"]) - svc.ServiceName = extractString(raw["ServiceName"]) - svc.Version = extractString(raw["Version"]) - svc.ODataVersion = extractString(raw["ODataVersion"]) - svc.Summary = extractString(raw["Summary"]) - svc.Description = extractString(raw["Description"]) - svc.PublishAssociations = extractBool(raw["PublishAssociations"], false) - svc.SupportsGraphQL = extractBool(raw["SupportsGraphQL"], false) - svc.UseGeneralization = extractBool(raw["UseGeneralization"], false) - svc.Excluded = extractBool(raw["Excluded"], false) - svc.AuthMicroflow = extractString(raw["AuthenticationMicroflow"]) - - // Parse authentication types - authTypes := extractBsonArray(raw["AuthenticationTypes"]) - for _, at := range authTypes { - if s, ok := at.(string); ok { - svc.AuthenticationTypes = append(svc.AuthenticationTypes, s) - } - } - - // Parse allowed module roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedModuleRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - svc.AllowedModuleRoles = append(svc.AllowedModuleRoles, name) - } - } - - // Build map of entity type IDs for EntitySet -> EntityType resolution - entityTypeMap := make(map[string]*model.PublishedEntityType) // ID -> EntityType - - // Parse entity types - entityTypes := extractBsonArray(raw["EntityTypes"]) - for _, et := range entityTypes { - if etMap, ok := et.(map[string]any); ok { - entityType := parsePublishedEntityType(etMap) - svc.EntityTypes = append(svc.EntityTypes, entityType) - entityTypeMap[string(entityType.ID)] = entityType - } - } - - // Parse entity sets - entitySets := extractBsonArray(raw["EntitySets"]) - for _, es := range entitySets { - if esMap, ok := es.(map[string]any); ok { - entitySet := parsePublishedEntitySet(esMap, entityTypeMap) - svc.EntitySets = append(svc.EntitySets, entitySet) - } - } - - // Parse published microflows (OData actions) - for _, pm := range extractBsonArray(raw["Microflows"]) { - if pmMap, ok := pm.(map[string]any); ok { - svc.Microflows = append(svc.Microflows, parsePublishedMicroflow(pmMap)) - } - } - - return svc, nil -} - -// parsePublishedMicroflow parses an ODataPublish$PublishedMicroflow — an OData -// action — from a BSON map. -func parsePublishedMicroflow(raw map[string]any) *model.PublishedMicroflow { - pm := &model.PublishedMicroflow{ - Microflow: extractString(raw["Microflow"]), - ExposedName: extractString(raw["ExposedName"]), - Summary: extractString(raw["Summary"]), - Description: extractString(raw["Description"]), - } - pm.ID = model.ID(extractID(raw["$ID"])) - pm.TypeName = "ODataPublish$PublishedMicroflow" - pm.ReturnTypeKind, pm.ReturnTypeRef = parseODataDataType(raw["ReturnType"]) - - for _, p := range extractBsonArray(raw["Parameters"]) { - pMap, ok := p.(map[string]any) - if !ok { - continue - } - mp := &model.PublishedMicroflowParameter{ - MicroflowParameter: extractString(pMap["MicroflowParameter"]), - ExposedName: extractString(pMap["ExposedName"]), - CanBeEmpty: extractBool(pMap["CanBeEmpty"], false), - Summary: extractString(pMap["Summary"]), - Description: extractString(pMap["Description"]), - } - mp.ID = model.ID(extractID(pMap["$ID"])) - mp.TypeName = "ODataPublish$PublishedMicroflowParameter" - mp.DataTypeKind, mp.DataTypeRef = parseODataDataType(pMap["DataType"]) - pm.Parameters = append(pm.Parameters, mp) - } - return pm -} - -// parseODataDataType reads a DataTypes$* element back into the kind + ref pair -// the semantic model carries. Object/List name an Entity, Enumeration names an -// Enumeration; everything else is a bare primitive whose kind is the $Type with -// the "DataTypes$" prefix and "Type" suffix removed. -func parseODataDataType(v any) (kind, ref string) { - m, ok := v.(map[string]any) - if !ok { - return "", "" - } - t := extractString(m["$Type"]) - t = strings.TrimPrefix(t, "DataTypes$") - t = strings.TrimSuffix(t, "Type") - switch t { - case "": - return "", "" - case "Object", "List": - return t, extractString(m["Entity"]) - case "Enumeration": - return t, extractString(m["Enumeration"]) - } - return t, "" -} - -// parsePublishedEntityType parses a published entity type from a BSON map. -func parsePublishedEntityType(raw map[string]any) *model.PublishedEntityType { - et := &model.PublishedEntityType{} - et.ID = model.ID(extractBsonID(raw["$ID"])) - et.TypeName = extractString(raw["$Type"]) - et.Entity = extractString(raw["Entity"]) - et.ExposedName = extractString(raw["ExposedName"]) - et.Summary = extractString(raw["Summary"]) - et.Description = extractString(raw["Description"]) - - // Parse members (attributes, associations, ids) - members := extractBsonArray(raw["ChildMembers"]) - for _, m := range members { - if mMap, ok := m.(map[string]any); ok { - member := parsePublishedMember(mMap) - et.Members = append(et.Members, member) - } - } - - return et -} - -// parsePublishedEntitySet parses a published entity set from a BSON map. -func parsePublishedEntitySet(raw map[string]any, entityTypeMap map[string]*model.PublishedEntityType) *model.PublishedEntitySet { - es := &model.PublishedEntitySet{} - es.ID = model.ID(extractBsonID(raw["$ID"])) - es.TypeName = extractString(raw["$Type"]) - es.ExposedName = extractString(raw["ExposedName"]) - es.UsePaging = extractBool(raw["UsePaging"], false) - es.PageSize = extractInt(raw["PageSize"]) - - // Resolve EntityType pointer (BY_ID reference) - entityTypeID := extractBsonID(raw["EntityTypePointer"]) - if entityTypeID != "" { - if et, ok := entityTypeMap[entityTypeID]; ok { - es.EntityTypeName = et.Entity - } - } - - // Parse mode objects - es.ReadMode = parseChangeMode(raw["ReadMode"]) - es.InsertMode = parseChangeMode(raw["InsertMode"]) - es.UpdateMode = parseChangeMode(raw["UpdateMode"]) - es.DeleteMode = parseChangeMode(raw["DeleteMode"]) - - return es -} - -// parsePublishedMember parses a published member from a BSON map. -func parsePublishedMember(raw map[string]any) *model.PublishedMember { - m := &model.PublishedMember{} - m.ID = model.ID(extractBsonID(raw["$ID"])) - m.TypeName = extractString(raw["$Type"]) - m.ExposedName = extractString(raw["ExposedName"]) - m.Filterable = extractBool(raw["Filterable"], false) - m.Sortable = extractBool(raw["Sortable"], false) - m.IsPartOfKey = extractBool(raw["IsPartOfKey"], false) - - // Determine kind from $Type - switch m.TypeName { - case "ODataPublish$PublishedAttribute": - m.Kind = "attribute" - m.Name = extractString(raw["Attribute"]) - m.EdmType = extractString(raw["EdmType"]) - case "ODataPublish$PublishedAssociationEnd": - m.Kind = "association" - m.Name = extractString(raw["Association"]) - // Studio Pro stores the target entity and the bare association - // name (separate from ExposedName, which is the navigation - // property). They round-trip through these fields so that - // ALTER ODATA SERVICE doesn't blank them out. - m.AssociationTargetEntity = extractString(raw["Entity"]) - m.ExposedAssociationName = extractString(raw["ExposedAssociationName"]) - m.IsMany = extractBool(raw["IsMany"], false) - case "ODataPublish$PublishedId": - m.Kind = "id" - m.Name = extractString(raw["Attribute"]) - default: - m.Kind = "unknown" - } - - return m -} - -// parseChangeMode extracts the mode string from a change/read source BSON object. -func parseChangeMode(v any) string { - if v == nil { - return "" - } - modeMap, ok := v.(map[string]any) - if !ok { - return "" - } - - typeName := extractString(modeMap["$Type"]) - switch typeName { - case "ODataPublish$ReadSource": - return "ReadFromDatabase" - case "ODataPublish$CallMicroflowToRead": - mfName := extractString(modeMap["Microflow"]) - if mfName != "" { - return "CallMicroflow:" + mfName - } - return "CallMicroflow" - case "ODataPublish$ChangeSource": - return "ChangeFromDatabase" - case "ODataPublish$ChangeNotSupported": - return "NotSupported" - case "ODataPublish$CallMicroflowToChange": - mfName := extractString(modeMap["Microflow"]) - if mfName != "" { - return "CallMicroflow:" + mfName - } - return "CallMicroflow" - default: - return typeName - } -} diff --git a/sdk/mpr/parser_page.go b/sdk/mpr/parser_page.go deleted file mode 100644 index 7df870d2a3..0000000000 --- a/sdk/mpr/parser_page.go +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parsePage(unitID, containerID string, contents []byte) (*pages.Page, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - page := &pages.Page{} - page.ID = model.ID(unitID) - page.TypeName = "Pages$Page" - page.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - page.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - page.Documentation = doc - } - // URL field is stored as "Url" (not "URL") - if url, ok := raw["Url"].(string); ok { - page.URL = url - } else if url, ok := raw["URL"].(string); ok { - // Fallback for legacy format - page.URL = url - } - if layoutID, ok := raw["Layout"].(string); ok { - page.LayoutID = model.ID(layoutID) - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - page.MarkAsUsed = markAsUsed - } - if excluded, ok := raw["Excluded"].(bool); ok { - page.Excluded = excluded - } - - // Parse allowed module roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedModuleRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - page.AllowedRoles = append(page.AllowedRoles, model.ID(name)) - } - } - - // Parse title - if title, ok := raw["Title"].(map[string]any); ok { - page.Title = parseText(title) - } - - // Parse parameters - // Format: [3] for empty, [3, {param1}, {param2}...] for non-empty - // Each parameter is a bson.D document directly in the array - if params, ok := raw["Parameters"].(bson.A); ok { - // Skip version marker (first element), iterate through rest - for i := 1; i < len(params); i++ { - // Primary format: direct bson.D document - if paramDoc, ok := params[i].(bson.D); ok { - paramMapInterface := make(map[string]any) - for _, elem := range paramDoc { - paramMapInterface[elem.Key] = elem.Value - } - param := parsePageParameter(paramMapInterface) - page.Parameters = append(page.Parameters, param) - } else if paramMap, ok := params[i].(map[string]any); ok { - // Alternative: direct map - param := parsePageParameter(paramMap) - page.Parameters = append(page.Parameters, param) - } - } - } - - return page, nil -} - -func parseText(raw map[string]any) *model.Text { - text := &model.Text{} - - text.ID = model.ID(extractBsonID(raw["$ID"])) - - text.Translations = make(map[string]string) - - // Handle Microflows$StringTemplate format (direct "Text" field) - if textVal, ok := raw["Text"].(string); ok { - text.Translations["en_US"] = textVal - return text - } - - // Try "Translations" format - could be a map or an array - if translations, ok := raw["Translations"].(map[string]any); ok { - for lang, val := range translations { - if str, ok := val.(string); ok { - text.Translations[lang] = str - } - } - } - - // Also try "Translations" as an array of Translation objects (BSON format: [2, {$Type: "Texts$Translation", ...}]) - if transArray := extractBsonArray(raw["Translations"]); len(transArray) > 0 { - for _, item := range transArray { - if transMap, ok := item.(map[string]any); ok { - langCode := extractString(transMap["LanguageCode"]) - textVal := extractString(transMap["Text"]) - if langCode != "" { - text.Translations[langCode] = textVal - } - } - } - } - - // Also try "Items" format (array of Translation objects) - if items := extractBsonArray(raw["Items"]); len(items) > 0 { - for _, item := range items { - if transMap, ok := item.(map[string]any); ok { - langCode := extractString(transMap["LanguageCode"]) - textVal := extractString(transMap["Text"]) - if langCode != "" { - text.Translations[langCode] = textVal - } - } - } - } - - return text -} - -func parsePageParameter(raw map[string]any) *pages.PageParameter { - param := &pages.PageParameter{} - - if id, ok := raw["$ID"].(string); ok { - param.ID = model.ID(id) - } - if name, ok := raw["Name"].(string); ok { - param.Name = name - } - if defaultValue, ok := raw["DefaultValue"].(string); ok { - param.DefaultValue = defaultValue - } - if isRequired, ok := raw["IsRequired"].(bool); ok { - param.IsRequired = isRequired - } - - // Entity can be in two places: - // 1. Old format: directly as "Entity" field (string ID) - // 2. New format: nested in ParameterType[0].Entity (qualified name) - if entityID, ok := raw["Entity"].(string); ok { - param.EntityID = model.ID(entityID) - } - - // Parse ParameterType to get entity name and/or primitive type - // ParameterType can be a map/bson.D (single object) or array (with version marker) - parseParamTypeDoc := func(doc bson.D) { - for _, elem := range doc { - switch elem.Key { - case "$Type": - if typeName, ok := elem.Value.(string); ok && typeName != "DataTypes$ObjectType" { - param.TypeName = typeName - } - case "Entity": - if entity, ok := elem.Value.(string); ok { - param.EntityName = entity - } - } - } - } - parseParamTypeMap := func(m map[string]any) { - if typeName, ok := m["$Type"].(string); ok && typeName != "DataTypes$ObjectType" { - param.TypeName = typeName - } - if entity, ok := m["Entity"].(string); ok { - param.EntityName = entity - } - } - - if paramType, ok := raw["ParameterType"].(bson.D); ok { - parseParamTypeDoc(paramType) - } else if paramType, ok := raw["ParameterType"].(map[string]any); ok { - parseParamTypeMap(paramType) - } else if paramTypeArr, ok := raw["ParameterType"].(bson.A); ok { - for _, item := range paramTypeArr { - if typeDoc, ok := item.(bson.D); ok { - parseParamTypeDoc(typeDoc) - } else if typeMap, ok := item.(map[string]any); ok { - parseParamTypeMap(typeMap) - } - } - } - - return param -} - -// parseLayout parses layout contents from BSON. -func (r *Reader) parseLayout(unitID, containerID string, contents []byte) (*pages.Layout, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - layout := &pages.Layout{} - layout.ID = model.ID(unitID) - layout.TypeName = "Pages$Layout" - layout.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - layout.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - layout.Documentation = doc - } - if layoutType, ok := raw["LayoutType"].(string); ok { - layout.LayoutType = pages.LayoutType(layoutType) - } - - return layout, nil -} - -// parseEnumeration parses enumeration contents from BSON. diff --git a/sdk/mpr/parser_queued_call_test.go b/sdk/mpr/parser_queued_call_test.go deleted file mode 100644 index adbdcf8287..0000000000 --- a/sdk/mpr/parser_queued_call_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// TestParseQueueSettings covers the legacy engine's half of the queued-call -// round trip (FINDINGS #25). -// -// The legacy writer stored the binding correctly, but the legacy PARSER never -// read it back — so on `--engine legacy` a queued call described as an ordinary -// one, and a describe → exec cycle dropped the binding. The two engines -// disagreed about the same stored document, which is the shape of bug that -// survives longest: each looks self-consistent. -func TestParseQueueSettings(t *testing.T) { - call := map[string]any{ - "$Type": "Microflows$MicroflowCall", - "Microflow": "Q.Target", - "QueueSettings": map[string]any{ - "$Type": "Queues$QueueSettings", - "Queue": "Q.MyQueue", - "Retry": nil, - }, - } - - qs := parseQueueSettings(call) - if qs == nil { - t.Fatal("QueueSettings not read back — a describe→exec round trip drops the binding") - } - if qs.Queue != "Q.MyQueue" { - t.Errorf("Queue = %q, want Q.MyQueue", qs.Queue) - } - if qs.Retry != nil { - t.Errorf("Retry = %v, want nil for an explicit BSON null", qs.Retry) - } - - // An unqueued call must stay unqueued — the common case by far. - if got := parseQueueSettings(map[string]any{"QueueSettings": nil}); got != nil { - t.Errorf("unqueued call produced %+v, want nil", got) - } - if got := parseQueueSettings(map[string]any{}); got != nil { - t.Errorf("absent QueueSettings produced %+v, want nil", got) - } - - // A stored retry must survive the read, because checkNoQueuedCalls refuses - // the rewrite on its presence — losing it here would re-enable the reset. - withRetry := parseQueueSettings(map[string]any{"QueueSettings": map[string]any{ - "Queue": "Q.MyQueue", - "Retry": map[string]any{"$Type": "Queues$QueueFixedRetry"}, - }}) - if withRetry == nil || withRetry.Retry == nil { - t.Fatal("a stored Retry must be carried, or the guard that refuses resetting it goes blind") - } -} diff --git a/sdk/mpr/parser_range_test.go b/sdk/mpr/parser_range_test.go deleted file mode 100644 index 134997f898..0000000000 --- a/sdk/mpr/parser_range_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// TestParseRange_StudioProShapes pins how Studio Pro actually stores a database -// retrieve's Range, measured rather than assumed. -// -// Source: ako/TestApp, MyFirstModule.RetrieveExamples on Mendix 11.13.0 — one -// retrieve per UI option, dumped with `mxcli bson dump --type microflow`: -// -// All Microflows$ConstantRange {SingleObject:false} -// First Microflows$ConstantRange {SingleObject:true} -// Custom (limit 4, off 2) Microflows$CustomRange {LimitExpression:"4", OffsetExpression:"2"} -// -// This matters beyond the parser. Range is a POLYMORPHIC child, the shape that -// has produced repeated data loss when a reader pulls one scalar out of it -// without dispatching on $Type (DomainModels$RuleInfo, and the import-mapping -// Range in #881). Pinning the real variants is what makes the dispatch here -// checkable instead of folkloric. -func TestParseRange_StudioProShapes(t *testing.T) { - tests := []struct { - name string - raw map[string]any - wantType microflows.RangeType - wantLimit string - wantOffset string - }{ - { - name: "All", - raw: map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": false}, - wantType: microflows.RangeTypeAll, - }, - { - name: "First", - raw: map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": true}, - wantType: microflows.RangeTypeFirst, - }, - { - name: "Custom", - raw: map[string]any{ - "$Type": "Microflows$CustomRange", - "LimitExpression": "4", - "OffsetExpression": "2", - }, - wantType: microflows.RangeTypeCustom, - wantLimit: "4", - wantOffset: "2", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseRange(tt.raw) - if got == nil { - t.Fatal("parseRange returned nil") - } - if got.RangeType != tt.wantType { - t.Errorf("RangeType = %v, want %v", got.RangeType, tt.wantType) - } - if got.Limit != tt.wantLimit { - t.Errorf("Limit = %q, want %q", got.Limit, tt.wantLimit) - } - if got.Offset != tt.wantOffset { - t.Errorf("Offset = %q, want %q", got.Offset, tt.wantOffset) - } - }) - } -} - -// TestParseRange_ConstantRangeWithLimitIsUnobserved documents the tolerance -// branch rather than endorsing it. -// -// No Studio Pro document has been seen storing Limit/Offset on a ConstantRange; -// the branch exists only for formats we have not sampled. modelsdk cannot read -// this shape at all (gen binds only SingleObject on ConstantRange), so if it -// ever turns up in a real project the engines diverge and the fix belongs in -// gen, not here. This test exists so that discovery lands on a named case. -func TestParseRange_ConstantRangeWithLimitIsUnobserved(t *testing.T) { - got := parseRange(map[string]any{ - "$Type": "Microflows$ConstantRange", - "SingleObject": false, - "LimitExpression": "10", - }) - if got.RangeType != microflows.RangeTypeCustom || got.Limit != "10" { - t.Errorf("legacy tolerance changed: got %v/%q — if this is now intended, "+ - "check whether modelsdk's rangeFromGen was taught to read it too", - got.RangeType, got.Limit) - } -} diff --git a/sdk/mpr/parser_rest.go b/sdk/mpr/parser_rest.go deleted file mode 100644 index 5aceea6e60..0000000000 --- a/sdk/mpr/parser_rest.go +++ /dev/null @@ -1,407 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parsePublishedRestService parses a published REST service from BSON. -func (r *Reader) parsePublishedRestService(unitID, containerID string, contents []byte) (*model.PublishedRestService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.PublishedRestService{} - svc.ID = model.ID(unitID) - svc.TypeName = "Rest$PublishedRestService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Path = extractString(raw["Path"]) - svc.Version = extractString(raw["Version"]) - svc.ServiceName = extractString(raw["ServiceName"]) - svc.Excluded = extractBool(raw["Excluded"], false) - - // Parse allowed roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - svc.AllowedRoles = append(svc.AllowedRoles, name) - } - } - - // Parse resources - resources := extractBsonArray(raw["Resources"]) - for _, res := range resources { - if resMap, ok := res.(map[string]any); ok { - resource := &model.PublishedRestResource{} - resource.ID = model.ID(extractBsonID(resMap["$ID"])) - resource.TypeName = extractString(resMap["$Type"]) - resource.Name = extractString(resMap["Name"]) - - // Parse operations - ops := extractBsonArray(resMap["Operations"]) - for _, op := range ops { - if opMap, ok := op.(map[string]any); ok { - operation := &model.PublishedRestOperation{} - operation.ID = model.ID(extractBsonID(opMap["$ID"])) - operation.TypeName = extractString(opMap["$Type"]) - operation.Path = extractString(opMap["Path"]) - operation.HTTPMethod = extractString(opMap["HttpMethod"]) - operation.Summary = extractString(opMap["Summary"]) - operation.Microflow = extractString(opMap["Microflow"]) - operation.Deprecated = extractBool(opMap["Deprecated"], false) - resource.Operations = append(resource.Operations, operation) - } - } - - svc.Resources = append(svc.Resources, resource) - } - } - - return svc, nil -} - -// parseConsumedRestService parses a consumed REST service from BSON. -func (r *Reader) parseConsumedRestService(unitID, containerID string, contents []byte) (*model.ConsumedRestService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.ConsumedRestService{} - svc.ID = model.ID(unitID) - svc.TypeName = "Rest$ConsumedRestService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Excluded = extractBool(raw["Excluded"], false) - - // Parse BaseUrl from Rest$ValueTemplate - if baseUrlMap := extractBsonMap(raw["BaseUrl"]); baseUrlMap != nil { - svc.BaseUrl = extractString(baseUrlMap["Value"]) - } - - // Parse AuthenticationScheme (polymorphic: null or Rest$BasicAuthenticationScheme) - if authMap := extractBsonMap(raw["AuthenticationScheme"]); authMap != nil { - authType := extractString(authMap["$Type"]) - if authType == "Rest$BasicAuthenticationScheme" { - auth := &model.RestAuthentication{Scheme: "Basic"} - auth.Username = extractRestValue(authMap["Username"]) - auth.Password = extractRestValue(authMap["Password"]) - svc.Authentication = auth - } - } - - // Parse OpenApiFile (present when created from spec; stores the raw spec text). - // Field names are PascalCase matching Studio Pro serialization. - if openApiFile, ok := raw["OpenApiFile"].(map[string]any); ok && openApiFile != nil { - svc.OpenApiContent = extractString(openApiFile["Content"]) - } - - // Parse Operations - ops := extractBsonArray(raw["Operations"]) - for _, op := range ops { - opMap, ok := op.(map[string]any) - if !ok { - continue - } - operation := parseRestOperation(opMap) - svc.Operations = append(svc.Operations, operation) - } - - return svc, nil -} - -// parseRestOperation parses a single Rest$RestOperation from BSON. -func parseRestOperation(opMap map[string]any) *model.RestClientOperation { - op := &model.RestClientOperation{} - op.Name = extractString(opMap["Name"]) - op.Timeout = extractInt(opMap["Timeout"]) - - // Parse Tags (versioned string array: [versionInt, tag1, tag2, ...]) - for _, t := range extractBsonArray(opMap["Tags"]) { - if s, ok := t.(string); ok { - op.Tags = append(op.Tags, s) - } - } - - // Parse Method (polymorphic: WithBody or WithoutBody) - if methodMap := extractBsonMap(opMap["Method"]); methodMap != nil { - methodType := extractString(methodMap["$Type"]) - httpMethod := extractString(methodMap["HttpMethod"]) - op.HttpMethod = httpMethodToUpper(httpMethod) - - if methodType == "Rest$RestOperationMethodWithBody" { - parseRestBody(methodMap["Body"], op) - } - } - - // Parse Path from Rest$ValueTemplate - if pathMap := extractBsonMap(opMap["Path"]); pathMap != nil { - op.Path = extractString(pathMap["Value"]) - } - - // Parse Headers - headers := extractBsonArray(opMap["Headers"]) - for _, h := range headers { - if hMap, ok := h.(map[string]any); ok { - header := &model.RestClientHeader{ - Name: extractString(hMap["Name"]), - } - if valMap := extractBsonMap(hMap["Value"]); valMap != nil { - header.Value = extractString(valMap["Value"]) - } - op.Headers = append(op.Headers, header) - } - } - - // Parse Parameters (path parameters) - params := extractBsonArray(opMap["Parameters"]) - for _, p := range params { - if pMap, ok := p.(map[string]any); ok { - param := &model.RestClientParameter{ - Name: extractString(pMap["Name"]), - DataType: extractRestDataType(pMap["DataType"]), - } - op.Parameters = append(op.Parameters, param) - } - } - - // Parse QueryParameters - queryParams := extractBsonArray(opMap["QueryParameters"]) - for _, q := range queryParams { - if qMap, ok := q.(map[string]any); ok { - param := &model.RestClientParameter{ - Name: extractString(qMap["Name"]), - DataType: extractRestDataType(qMap["DataType"]), - } - op.QueryParameters = append(op.QueryParameters, param) - } - } - - // Parse ResponseHandling (polymorphic) - if respMap := extractBsonMap(opMap["ResponseHandling"]); respMap != nil { - respType := extractString(respMap["$Type"]) - switch respType { - case "Rest$NoResponseHandling": - // Detect response type from ContentType for roundtrip support - contentType := extractString(respMap["ContentType"]) - switch contentType { - case "application/json": - op.ResponseType = "JSON" - case "text/plain": - op.ResponseType = "STRING" - case "application/octet-stream": - op.ResponseType = "FILE" - default: - op.ResponseType = "NONE" - } - case "Rest$ImplicitMappingResponseHandling": - op.ResponseType = "MAPPING" - if rootMap := extractBsonMap(respMap["RootMappingElement"]); rootMap != nil { - op.ResponseEntity = extractString(rootMap["Entity"]) - op.ResponseMappings = parseMappingChildren(rootMap) - } - } - } - - return op -} - -// parseRestBody extracts body information from the Method's Body field. -func parseRestBody(bodyVal any, op *model.RestClientOperation) { - bodyMap := extractBsonMap(bodyVal) - if bodyMap == nil { - return - } - bodyType := extractString(bodyMap["$Type"]) - switch bodyType { - case "Rest$ImplicitMappingBody": - op.BodyType = "EXPORT_MAPPING" - if rootMap := extractBsonMap(bodyMap["RootMappingElement"]); rootMap != nil { - op.BodyVariable = extractString(rootMap["Entity"]) - op.BodyMappings = parseExportMappingChildren(rootMap) - } - case "Rest$JsonBody": - op.BodyType = "JSON" - op.BodyVariable = extractString(bodyMap["Value"]) - case "Rest$StringBody": - op.BodyType = "TEMPLATE" // String body with a value template (may contain {param} placeholders) - if vt := extractBsonMap(bodyMap["ValueTemplate"]); vt != nil { - op.BodyVariable = extractString(vt["Value"]) - } - } -} - -// parseMappingChildren recursively parses Children from an ImportMappings$ObjectMappingElement. -// Returns a flat/nested list of RestResponseMapping entries covering both value and object elements. -func parseMappingChildren(parentMap map[string]any) []*model.RestResponseMapping { - parentEntity := extractString(parentMap["Entity"]) - entityPrefix := parentEntity + "." - children := extractBsonArray(parentMap["Children"]) - - var mappings []*model.RestResponseMapping - for _, child := range children { - childMap, ok := child.(map[string]any) - if !ok { - continue - } - childType := extractString(childMap["$Type"]) - switch childType { - case "ImportMappings$ValueMappingElement": - attr := extractString(childMap["Attribute"]) - exposed := extractString(childMap["ExposedName"]) - if attr == "" || exposed == "" { - continue - } - mappings = append(mappings, &model.RestResponseMapping{ - Attribute: strings.TrimPrefix(attr, entityPrefix), - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - }) - case "ImportMappings$ObjectMappingElement": - entity := extractString(childMap["Entity"]) - assoc := extractString(childMap["Association"]) - exposed := extractString(childMap["ExposedName"]) - mappings = append(mappings, &model.RestResponseMapping{ - Entity: entity, - Association: assoc, - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - Children: parseMappingChildren(childMap), - }) - } - } - return mappings -} - -// parseExportMappingChildren recursively parses Children from an ExportMappings$ObjectMappingElement. -// Same structure as parseMappingChildren but for ExportMappings$ types. -func parseExportMappingChildren(parentMap map[string]any) []*model.RestResponseMapping { - parentEntity := extractString(parentMap["Entity"]) - entityPrefix := parentEntity + "." - children := extractBsonArray(parentMap["Children"]) - - var mappings []*model.RestResponseMapping - for _, child := range children { - childMap, ok := child.(map[string]any) - if !ok { - continue - } - childType := extractString(childMap["$Type"]) - switch childType { - case "ExportMappings$ValueMappingElement": - attr := extractString(childMap["Attribute"]) - exposed := extractString(childMap["ExposedName"]) - if attr == "" || exposed == "" { - continue - } - mappings = append(mappings, &model.RestResponseMapping{ - Attribute: strings.TrimPrefix(attr, entityPrefix), - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - }) - case "ExportMappings$ObjectMappingElement": - entity := extractString(childMap["Entity"]) - assoc := extractString(childMap["Association"]) - exposed := extractString(childMap["ExposedName"]) - mappings = append(mappings, &model.RestResponseMapping{ - Entity: entity, - Association: assoc, - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - Children: parseExportMappingChildren(childMap), - }) - } - } - return mappings -} - -// extractRestValue extracts a value from a polymorphic Rest$Value (StringValue or ConstantValue). -func extractRestValue(v any) string { - valMap := extractBsonMap(v) - if valMap == nil { - return "" - } - valType := extractString(valMap["$Type"]) - switch valType { - case "Rest$StringValue": - return extractString(valMap["Value"]) - case "Rest$ConstantValue": - // The BSON field is "Value" (QualifiedName of the constant). - // Historical code wrote "Constant" — try both for backward compat. - if v := extractString(valMap["Value"]); v != "" { - return "$" + v - } - if v := extractString(valMap["Constant"]); v != "" { - return "$" + v - } - return "" - } - return "" -} - -// extractRestDataType extracts a data type name from a DataTypes$DataType BSON object. -// Handles both DataTypes$IntegerType (consumed REST) and DataTypes$IntegerAttributeType formats. -func extractRestDataType(v any) string { - dtMap := extractBsonMap(v) - if dtMap == nil { - return "String" - } - dtType := extractString(dtMap["$Type"]) - switch dtType { - case "DataTypes$IntegerType", "DataTypes$IntegerAttributeType": - return "Integer" - case "DataTypes$LongType", "DataTypes$LongAttributeType": - return "Long" - case "DataTypes$DecimalType", "DataTypes$DecimalAttributeType": - return "Decimal" - case "DataTypes$BooleanType", "DataTypes$BooleanAttributeType": - return "Boolean" - case "DataTypes$StringType", "DataTypes$StringAttributeType": - return "String" - default: - return "String" - } -} - -// httpMethodToUpper converts Mendix HTTP method names to uppercase. -func httpMethodToUpper(method string) string { - switch method { - case "Get": - return "GET" - case "Post": - return "POST" - case "Put": - return "PUT" - case "Patch": - return "PATCH" - case "Delete": - return "DELETE" - case "Head": - return "HEAD" - case "Options": - return "OPTIONS" - default: - return method - } -} diff --git a/sdk/mpr/parser_rule.go b/sdk/mpr/parser_rule.go deleted file mode 100644 index 3ba1f01697..0000000000 --- a/sdk/mpr/parser_rule.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseRule parses a Microflows$Rule document. A rule shares a microflow's -// object collection, flows, parameters and return type, so this mirrors -// parseNanoflow with two differences measured against Studio Pro-authored rules -// (ako/TestApp, Mendix 11.13.0): -// -// - a rule stores no AllowedModuleRoles — it is not independently callable, so -// it has no module-role security; -// - gen declares a ReturnType string beside MicroflowReturnType, but Studio Pro -// does not write it and generated/metamodel does not list it, so it is not -// read and must not be written. -func (r *Reader) parseRule(unitID, containerID string, contents []byte) (*microflows.Rule, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - rule := µflows.Rule{} - rule.ID = model.ID(unitID) - rule.TypeName = "Microflows$Rule" - rule.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - rule.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - rule.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - rule.Excluded = excluded - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - rule.MarkAsUsed = markAsUsed - } - if applyEntityAccess, ok := raw["ApplyEntityAccess"].(bool); ok { - rule.ApplyEntityAccess = applyEntityAccess - } - if returnVariableName, ok := raw["ReturnVariableName"].(string); ok { - rule.ReturnVariableName = returnVariableName - } - - // Return type — Boolean or an enumeration, under the microflow's BSON key. - if rt, ok := raw["MicroflowReturnType"].(map[string]any); ok { - rule.ReturnType = parseMicroflowDataType(rt) - } - - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - rule.ObjectCollection = parseMicroflowObjectCollection(oc) - for _, obj := range extractBsonSlice(oc["Objects"]) { - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - rule.Parameters = append(rule.Parameters, parseMicroflowParameter(objMap, len(rule.Parameters))) - } - } - } - } - - if flowsRaw := raw["Flows"]; flowsRaw != nil { - if rule.ObjectCollection == nil { - rule.ObjectCollection = µflows.MicroflowObjectCollection{} - } - for _, f := range extractBsonSlice(flowsRaw) { - flowMap := extractBsonMap(f) - if flowMap == nil { - continue - } - typeName, _ := flowMap["$Type"].(string) - switch typeName { - case "Microflows$AnnotationFlow": - if af := parseAnnotationFlow(flowMap); af != nil { - rule.ObjectCollection.AnnotationFlows = append(rule.ObjectCollection.AnnotationFlows, af) - } - default: - if flow := parseSequenceFlow(flowMap); flow != nil { - rule.ObjectCollection.Flows = append(rule.ObjectCollection.Flows, flow) - } - } - } - } - - return rule, nil -} diff --git a/sdk/mpr/parser_rule_test.go b/sdk/mpr/parser_rule_test.go deleted file mode 100644 index d05343975c..0000000000 --- a/sdk/mpr/parser_rule_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -// studioProRuleBSON reproduces Rules.Rule2 from the reference app -// (ako/TestApp, Mendix 11.13.0) — an enumeration-returning rule with an entity -// parameter, in the key order Studio Pro stores. -// -// The two deliberate omissions are the point of the fixture: a real rule -// document carries no AllowedModuleRoles and no ReturnType, so a parser that -// reaches for either is reading a key Mendix never wrote. -func studioProRuleBSON(t *testing.T) []byte { - t.Helper() - doc := bson.D{ - {Key: "$ID", Value: "rule-1"}, - {Key: "$Type", Value: "Microflows$Rule"}, - {Key: "ApplyEntityAccess", Value: false}, - {Key: "Documentation", Value: "decides the outcome"}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "MicroflowReturnType", Value: bson.D{ - {Key: "$ID", Value: "rt-1"}, - {Key: "$Type", Value: "DataTypes$EnumerationType"}, - {Key: "Enumeration", Value: "Rules.RuleResult"}, - }}, - {Key: "Name", Value: "Rule2"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "p-1"}, - {Key: "$Type", Value: "Microflows$MicroflowParameter"}, - {Key: "Name", Value: "pName"}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: "vt-1"}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: "Pages.Bus"}, - }}, - }, - bson.D{ - {Key: "$ID", Value: "end-1"}, - {Key: "$Type", Value: "Microflows$EndEvent"}, - {Key: "ReturnValue", Value: "Rules.RuleResult.Approved"}, - }, - }}, - }}, - {Key: "ReturnVariableName", Value: "Variable"}, - } - b, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal rule fixture: %v", err) - } - return b -} - -// The legacy engine reads a rule with the same fidelity as the codec engine: -// name, documentation, the enumeration return type, the entity parameter, and -// ReturnVariableName (which Studio Pro writes and a rewrite must not drop). -func TestParseRule_StudioProDocument(t *testing.T) { - rule, err := testReader().parseRule("rule-1", "container-1", studioProRuleBSON(t)) - if err != nil { - t.Fatalf("parseRule: %v", err) - } - - if rule.Name != "Rule2" { - t.Errorf("Name = %q, want Rule2", rule.Name) - } - if rule.Documentation != "decides the outcome" { - t.Errorf("Documentation = %q", rule.Documentation) - } - if rule.ReturnVariableName != "Variable" { - t.Errorf("ReturnVariableName = %q, want %q", rule.ReturnVariableName, "Variable") - } - if rule.TypeName != "Microflows$Rule" { - t.Errorf("TypeName = %q, want Microflows$Rule", rule.TypeName) - } - - enum, ok := rule.ReturnType.(*microflows.EnumerationType) - if !ok { - t.Fatalf("ReturnType = %T, want *microflows.EnumerationType — a rule may return an enumeration, not only Boolean", rule.ReturnType) - } - if enum.EnumerationQualifiedName != "Rules.RuleResult" { - t.Errorf("enumeration = %q, want Rules.RuleResult", enum.EnumerationQualifiedName) - } - - if len(rule.Parameters) != 1 { - t.Fatalf("Parameters = %d, want 1", len(rule.Parameters)) - } - if rule.Parameters[0].Name != "pName" { - t.Errorf("parameter = %q, want pName", rule.Parameters[0].Name) - } - if rule.ObjectCollection == nil || len(rule.ObjectCollection.Objects) == 0 { - t.Error("ObjectCollection did not come back") - } -} diff --git a/sdk/mpr/parser_scheduledevent_test.go b/sdk/mpr/parser_scheduledevent_test.go deleted file mode 100644 index 6e9c6a529e..0000000000 --- a/sdk/mpr/parser_scheduledevent_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// Issue #585: parseScheduledEvent asserted `raw["Interval"].(int32)`. Studio -// Pro writes Interval as BSON int64, so the assertion failed silently and -// every scheduled event read from a Studio Pro-written MPR appeared with -// Interval=0 — the same misreport pattern fixed in #583 for -// StringAttributeType.Length. -func TestParseScheduledEvent_Interval_BsonNumericWidths(t *testing.T) { - cases := []struct { - name string - interval any - want int - }{ - {"int32 (mxcli writer)", int32(15), 15}, - {"int64 (Studio Pro writer)", int64(15), 15}, - {"int", int(15), 15}, - {"float64 (extended JSON)", float64(15), 15}, - {"missing field", nil, 0}, - } - - r := &Reader{version: MPRVersionV1} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - doc := bson.M{ - "$Type": "ScheduledEvents$ScheduledEvent", - "Name": "MyEvent", - "Enabled": true, - "IntervalType": "Hour", - } - if tc.interval != nil { - doc["Interval"] = tc.interval - } - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("bson.Marshal: %v", err) - } - event, err := r.parseScheduledEvent("unit-id", "container-id", data) - if err != nil { - t.Fatalf("parseScheduledEvent: %v", err) - } - if event.Interval != tc.want { - t.Errorf("Interval = %d, want %d (input %T(%v))", event.Interval, tc.want, tc.interval, tc.interval) - } - }) - } -} diff --git a/sdk/mpr/parser_security.go b/sdk/mpr/parser_security.go deleted file mode 100644 index 8a49270307..0000000000 --- a/sdk/mpr/parser_security.go +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/security" - "go.mongodb.org/mongo-driver/bson" -) - -// parseProjectSecurity parses a Security$ProjectSecurity BSON document. -func (r *Reader) parseProjectSecurity(unitID, containerID string, contents []byte) (*security.ProjectSecurity, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ps := &security.ProjectSecurity{} - ps.ID = model.ID(unitID) - ps.TypeName = "Security$ProjectSecurity" - - ps.SecurityLevel = extractString(raw["SecurityLevel"]) - ps.AdminUserName = extractString(raw["AdminUserName"]) - ps.AdminPassword = extractString(raw["AdminPassword"]) - ps.AdminUserRole = extractString(raw["AdminUserRole"]) - ps.CheckSecurity = extractBool(raw["CheckSecurity"], false) - ps.StrictMode = extractBool(raw["StrictMode"], false) - ps.StrictPageUrlCheck = extractBool(raw["StrictPageUrlCheck"], false) - ps.EnableDemoUsers = extractBool(raw["EnableDemoUsers"], false) - ps.EnableGuestAccess = extractBool(raw["EnableGuestAccess"], false) - ps.GuestUserRole = extractString(raw["GuestUserRole"]) - - // Parse user roles - userRoles := extractBsonArray(raw["UserRoles"]) - for _, ur := range userRoles { - urMap := toMap(ur) - if urMap == nil { - continue - } - role := parseUserRole(urMap) - ps.UserRoles = append(ps.UserRoles, role) - } - - // Parse demo users - demoUsers := extractBsonArray(raw["DemoUsers"]) - for _, du := range demoUsers { - duMap := toMap(du) - if duMap == nil { - continue - } - user := parseDemoUser(duMap) - ps.DemoUsers = append(ps.DemoUsers, user) - } - - // Parse password policy - if ppRaw, ok := raw["PasswordPolicySettings"]; ok { - ppMap := toMap(ppRaw) - if ppMap != nil { - ps.PasswordPolicy = parsePasswordPolicy(ppMap) - } - } - - return ps, nil -} - -// parseUserRole parses a Security$UserRole from a BSON map. -func parseUserRole(raw map[string]any) *security.UserRole { - role := &security.UserRole{} - role.ID = model.ID(extractBsonID(raw["$ID"])) - role.TypeName = "Security$UserRole" - role.Name = extractString(raw["Name"]) - role.Description = extractString(raw["Description"]) - role.ManageAllRoles = extractBool(raw["ManageAllRoles"], false) - role.ManageUsersWithoutRoles = extractBool(raw["ManageUsersWithoutRoles"], false) - role.CheckSecurity = extractBool(raw["CheckSecurity"], false) - - // Module roles are BY_NAME references (qualified name strings) - moduleRoles := extractBsonArray(raw["ModuleRoles"]) - for _, mr := range moduleRoles { - if name, ok := mr.(string); ok { - role.ModuleRoles = append(role.ModuleRoles, name) - } - } - - // Manageable roles are BY_NAME references - manageableRoles := extractBsonArray(raw["ManageableRoles"]) - for _, mr := range manageableRoles { - if name, ok := mr.(string); ok { - role.ManageableRoles = append(role.ManageableRoles, name) - } - } - - return role -} - -// parseDemoUser parses a Security$DemoUserImpl from a BSON map. -func parseDemoUser(raw map[string]any) *security.DemoUser { - user := &security.DemoUser{} - user.ID = model.ID(extractBsonID(raw["$ID"])) - user.TypeName = "Security$DemoUserImpl" - user.UserName = extractString(raw["UserName"]) - user.Password = extractString(raw["Password"]) - user.Entity = extractString(raw["Entity"]) - - // User roles are BY_NAME references - userRoles := extractBsonArray(raw["UserRoles"]) - for _, ur := range userRoles { - if name, ok := ur.(string); ok { - user.UserRoles = append(user.UserRoles, name) - } - } - - return user -} - -// parsePasswordPolicy parses Security$PasswordPolicySettings from a BSON map. -func parsePasswordPolicy(raw map[string]any) *security.PasswordPolicy { - pp := &security.PasswordPolicy{} - pp.ID = model.ID(extractBsonID(raw["$ID"])) - pp.TypeName = "Security$PasswordPolicySettings" - pp.MinimumLength = extractInt(raw["MinimumLength"]) - pp.RequireDigit = extractBool(raw["RequireDigit"], false) - pp.RequireMixedCase = extractBool(raw["RequireMixedCase"], false) - pp.RequireSymbol = extractBool(raw["RequireSymbol"], false) - return pp -} - -// parseModuleSecurity parses a Security$ModuleSecurity BSON document. -func (r *Reader) parseModuleSecurity(unitID, containerID string, contents []byte) (*security.ModuleSecurity, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ms := &security.ModuleSecurity{} - ms.ID = model.ID(unitID) - ms.TypeName = "Security$ModuleSecurity" - ms.ContainerID = model.ID(containerID) - - // Parse module roles - roles := extractBsonArray(raw["ModuleRoles"]) - for _, r := range roles { - rMap := toMap(r) - if rMap == nil { - continue - } - role := &security.ModuleRole{} - role.ID = model.ID(extractBsonID(rMap["$ID"])) - role.TypeName = "Security$ModuleRole" - role.Name = extractString(rMap["Name"]) - role.Description = extractString(rMap["Description"]) - ms.ModuleRoles = append(ms.ModuleRoles, role) - } - - return ms, nil -} - -// toMap is defined in parser_javaactions.go diff --git a/sdk/mpr/parser_settings.go b/sdk/mpr/parser_settings.go deleted file mode 100644 index 3b02512f4a..0000000000 --- a/sdk/mpr/parser_settings.go +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/settingsoverlay" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseProjectSettings parses a Settings$ProjectSettings BSON document. -func (r *Reader) parseProjectSettings(unitID, containerID string, contents []byte) (*model.ProjectSettings, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ps := &model.ProjectSettings{} - ps.ID = model.ID(unitID) - ps.TypeName = "Settings$ProjectSettings" - - // Parse Settings array (versioned: starts with int32(2)) - settingsArr := extractBsonArray(raw["Settings"]) - for _, s := range settingsArr { - partMap := extractBsonMap(s) - if partMap == nil { - continue - } - // Preserve raw part for round-trip serialization - ps.RawParts = append(ps.RawParts, partMap) - - typeName := extractString(partMap["$Type"]) - switch typeName { - case "Forms$WebUIProjectSettingsPart": - ps.WebUI = parseWebUISettings(partMap) - case "Settings$IntegrationProjectSettingsPart": - ps.Integration = &model.IntegrationSettings{} - ps.Integration.ID = model.ID(extractBsonID(partMap["$ID"])) - ps.Integration.TypeName = typeName - case "Settings$ConfigurationSettings": - ps.Configuration = parseConfigurationSettings(partMap) - case "Settings$ModelSettings": - ps.Model = parseModelSettings(partMap) - case "Settings$ConventionSettings": - ps.Convention = parseConventionSettings(partMap) - case "Settings$LanguageSettings": - ps.Language = parseLanguageSettings(partMap) - case "Settings$CertificateSettings": - ps.Certificate = &model.CertificateSettings{} - ps.Certificate.ID = model.ID(extractBsonID(partMap["$ID"])) - ps.Certificate.TypeName = typeName - case "Settings$WorkflowsProjectSettingsPart": - ps.Workflows = parseWorkflowsSettings(partMap) - case "Settings$JarDeploymentSettings": - ps.JarDeployment = &model.JarDeploymentSettings{} - ps.JarDeployment.ID = model.ID(extractBsonID(partMap["$ID"])) - ps.JarDeployment.TypeName = typeName - case "Settings$DistributionSettings": - ps.Distribution = parseDistributionSettings(partMap) - } - } - - return ps, nil -} - -func parseWebUISettings(raw map[string]any) *model.WebUISettings { - s := &model.WebUISettings{} - s.ID = model.ID(extractBsonID(raw["$ID"])) - s.TypeName = extractString(raw["$Type"]) - s.EnableMicroflowReachabilityAnalysis = extractBool(raw["EnableMicroflowReachabilityAnalysis"], false) - s.UseOptimizedClient = extractString(raw["UseOptimizedClient"]) - s.UrlPrefix = extractString(raw["UrlPrefix"]) - return s -} - -func parseConfigurationSettings(raw map[string]any) *model.ConfigurationSettings { - cs := &model.ConfigurationSettings{} - cs.ID = model.ID(extractBsonID(raw["$ID"])) - cs.TypeName = extractString(raw["$Type"]) - - configs := extractBsonArray(raw["Configurations"]) - for _, c := range configs { - if cMap := extractBsonMap(c); cMap != nil { - cs.Configurations = append(cs.Configurations, parseServerConfiguration(cMap)) - } - } - - return cs -} - -func parseServerConfiguration(raw map[string]any) *model.ServerConfiguration { - sc := &model.ServerConfiguration{} - sc.ID = model.ID(extractBsonID(raw["$ID"])) - sc.TypeName = extractString(raw["$Type"]) - sc.Name = extractString(raw["Name"]) - sc.DatabaseType = extractString(raw["DatabaseType"]) - sc.DatabaseUrl = extractString(raw["DatabaseUrl"]) - sc.DatabaseName = extractString(raw["DatabaseName"]) - sc.DatabaseUserName = extractString(raw["DatabaseUserName"]) - sc.DatabasePassword = extractString(raw["DatabasePassword"]) - sc.DatabaseUseIntegratedSecurity = extractBool(raw["DatabaseUseIntegratedSecurity"], false) - sc.HttpPortNumber = extractInt(raw["HttpPortNumber"]) - sc.ServerPortNumber = extractInt(raw["ServerPortNumber"]) - sc.ApplicationRootUrl = extractString(raw["ApplicationRootUrl"]) - sc.MaxJavaHeapSize = extractInt(raw["MaxJavaHeapSize"]) - sc.ExtraJvmParameters = extractString(raw["ExtraJvmParameters"]) - sc.OpenAdminPort = extractBool(raw["OpenAdminPort"], false) - sc.OpenHttpPort = extractBool(raw["OpenHttpPort"], false) - - // Parse ConstantValues - cvArr := extractBsonArray(raw["ConstantValues"]) - for _, cv := range cvArr { - if cvMap := extractBsonMap(cv); cvMap != nil { - sc.ConstantValues = append(sc.ConstantValues, parseConstantValue(cvMap)) - } - } - - return sc -} - -func parseConstantValue(raw map[string]any) *model.ConstantValue { - cv := &model.ConstantValue{} - cv.ID = model.ID(extractBsonID(raw["$ID"])) - cv.TypeName = extractString(raw["$Type"]) - cv.ConstantId = extractString(raw["ConstantId"]) - - // Value is nested in SharedOrPrivateValue → Value. A Settings$PrivateValue - // carries no value at all: it marks an override whose value lives on the - // developer's workstation, outside the shared model. - if spv := extractBsonMap(raw["SharedOrPrivateValue"]); spv != nil { - if extractString(spv["$Type"]) == settingsoverlay.PrivateValueType { - cv.IsPrivate = true - } else { - cv.Value = extractString(spv["Value"]) - } - } - - return cv -} - -func parseModelSettings(raw map[string]any) *model.ModelSettings { - ms := &model.ModelSettings{} - ms.ID = model.ID(extractBsonID(raw["$ID"])) - ms.TypeName = extractString(raw["$Type"]) - ms.AfterStartupMicroflow = extractString(raw["AfterStartupMicroflow"]) - ms.BeforeShutdownMicroflow = extractString(raw["BeforeShutdownMicroflow"]) - ms.HealthCheckMicroflow = extractString(raw["HealthCheckMicroflow"]) - ms.AllowUserMultipleSessions = extractBool(raw["AllowUserMultipleSessions"], true) - ms.HashAlgorithm = extractString(raw["HashAlgorithm"]) - ms.BcryptCost = extractInt(raw["BcryptCost"]) - ms.JavaVersion = settingsoverlay.JavaVersion(raw) - ms.RoundingMode = extractString(raw["RoundingMode"]) - ms.ScheduledEventTimeZoneCode = extractString(raw["ScheduledEventTimeZoneCode"]) - ms.DefaultTimeZoneCode = extractString(raw["DefaultTimeZoneCode"]) - ms.FirstDayOfWeek = extractString(raw["FirstDayOfWeek"]) - ms.DecimalScale = extractInt(raw["DecimalScale"]) - ms.EnableDataStorageOptimisticLocking = extractBool(raw["EnableDataStorageOptimisticLocking"], false) - // The defaults below are only reached when the key is absent, which happens on - // older Mendix versions that do not store the property (a blank 9.24 project - // has none of UseOQLVersion2 / UseDatabaseForeignKeyConstraints / DecimalScale / - // SslCertificateAlgorithm). The overlay is presence-gated, so a value read from - // a default here is never written back — see settingsoverlay.SetModelSettings. - ms.UseDatabaseForeignKeyConstraints = extractBool(raw["UseDatabaseForeignKeyConstraints"], true) - ms.UseOQLVersion2 = extractBool(raw["UseOQLVersion2"], true) - ms.UseSystemContextForBackgroundTasks = extractBool(raw["UseSystemContextForBackgroundTasks"], false) - ms.SslCertificateAlgorithm = extractString(raw["SslCertificateAlgorithm"]) - return ms -} - -func parseConventionSettings(raw map[string]any) *model.ConventionSettings { - cs := &model.ConventionSettings{} - cs.ID = model.ID(extractBsonID(raw["$ID"])) - cs.TypeName = extractString(raw["$Type"]) - cs.LowerCaseMicroflowVariables = extractBool(raw["LowerCaseMicroflowVariables"], false) - cs.DefaultAssociationStorage = extractString(raw["DefaultAssociationStorage"]) - return cs -} - -func parseLanguageSettings(raw map[string]any) *model.LanguageSettings { - ls := &model.LanguageSettings{} - ls.ID = model.ID(extractBsonID(raw["$ID"])) - ls.TypeName = extractString(raw["$Type"]) - ls.DefaultLanguageCode = extractString(raw["DefaultLanguageCode"]) - for _, item := range extractBsonArray(raw["Languages"]) { - langMap := extractBsonMap(item) - if langMap == nil { - continue - } - ls.Languages = append(ls.Languages, model.Language{ - Code: extractString(langMap["Code"]), - CheckCompleteness: extractBool(langMap["CheckCompleteness"], false), - CustomDateFormat: extractString(langMap["CustomDateFormat"]), - CustomDateTimeFormat: extractString(langMap["CustomDateTimeFormat"]), - CustomTimeFormat: extractString(langMap["CustomTimeFormat"]), - }) - } - return ls -} - -func parseWorkflowsSettings(raw map[string]any) *model.WorkflowsSettings { - ws := &model.WorkflowsSettings{} - ws.ID = model.ID(extractBsonID(raw["$ID"])) - ws.TypeName = extractString(raw["$Type"]) - ws.UserEntity = extractString(raw["UserEntity"]) - ws.DefaultTaskParallelism = extractInt(raw["DefaultTaskParallelism"]) - ws.WorkflowEngineParallelism = extractInt(raw["WorkflowEngineParallelism"]) - return ws -} - -func parseDistributionSettings(raw map[string]any) *model.DistributionSettings { - ds := &model.DistributionSettings{} - ds.ID = model.ID(extractBsonID(raw["$ID"])) - ds.TypeName = extractString(raw["$Type"]) - ds.IsDistributable = extractBool(raw["IsDistributable"], false) - ds.Version = extractString(raw["Version"]) - return ds -} diff --git a/sdk/mpr/parser_settings_test.go b/sdk/mpr/parser_settings_test.go deleted file mode 100644 index 00a94a6539..0000000000 --- a/sdk/mpr/parser_settings_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// TestParseLanguageSettings_Languages verifies that Languages array items stored -// as primitive.D (the BSON decoded type) are correctly parsed via extractBsonMap. -// This is the fix for issue #480: bare .(map[string]any) assertions always fail -// on primitive.D values, so extractBsonMap must be used instead. -func TestParseLanguageSettings_Languages(t *testing.T) { - raw := map[string]any{ - "$ID": "settings-lang-1", - "$Type": "Settings$LanguageSettings", - "DefaultLanguageCode": "en_US", - "Languages": primitive.A{ - int32(2), - primitive.D{ - {Key: "$ID", Value: "lang-1"}, - {Key: "$Type", Value: "Texts$Language"}, - {Key: "Code", Value: "en_US"}, - {Key: "CheckCompleteness", Value: true}, - {Key: "CustomDateFormat", Value: "MM/dd/yyyy"}, - {Key: "CustomDateTimeFormat", Value: "MM/dd/yyyy HH:mm"}, - {Key: "CustomTimeFormat", Value: "HH:mm"}, - }, - primitive.D{ - {Key: "$ID", Value: "lang-2"}, - {Key: "$Type", Value: "Texts$Language"}, - {Key: "Code", Value: "fr_FR"}, - {Key: "CheckCompleteness", Value: false}, - }, - }, - } - - ls := parseLanguageSettings(raw) - - if ls.DefaultLanguageCode != "en_US" { - t.Errorf("DefaultLanguageCode = %q, want %q", ls.DefaultLanguageCode, "en_US") - } - if len(ls.Languages) != 2 { - t.Fatalf("len(Languages) = %d, want 2", len(ls.Languages)) - } - - en := ls.Languages[0] - if en.Code != "en_US" { - t.Errorf("Languages[0].Code = %q, want %q", en.Code, "en_US") - } - if !en.CheckCompleteness { - t.Errorf("Languages[0].CheckCompleteness = false, want true") - } - if en.CustomDateFormat != "MM/dd/yyyy" { - t.Errorf("Languages[0].CustomDateFormat = %q, want %q", en.CustomDateFormat, "MM/dd/yyyy") - } - if en.CustomDateTimeFormat != "MM/dd/yyyy HH:mm" { - t.Errorf("Languages[0].CustomDateTimeFormat = %q, want %q", en.CustomDateTimeFormat, "MM/dd/yyyy HH:mm") - } - if en.CustomTimeFormat != "HH:mm" { - t.Errorf("Languages[0].CustomTimeFormat = %q, want %q", en.CustomTimeFormat, "HH:mm") - } - - fr := ls.Languages[1] - if fr.Code != "fr_FR" { - t.Errorf("Languages[1].Code = %q, want %q", fr.Code, "fr_FR") - } - if fr.CheckCompleteness { - t.Errorf("Languages[1].CheckCompleteness = true, want false") - } -} - -// TestParseLanguageSettings_EmptyLanguages verifies that an absent or empty -// Languages array results in a nil/empty slice without panicking. -func TestParseLanguageSettings_EmptyLanguages(t *testing.T) { - raw := map[string]any{ - "$ID": "settings-lang-2", - "$Type": "Settings$LanguageSettings", - "DefaultLanguageCode": "en_US", - "Languages": primitive.A{int32(2)}, - } - - ls := parseLanguageSettings(raw) - if len(ls.Languages) != 0 { - t.Errorf("len(Languages) = %d, want 0", len(ls.Languages)) - } -} diff --git a/sdk/mpr/parser_unknown.go b/sdk/mpr/parser_unknown.go deleted file mode 100644 index 1ac32d9984..0000000000 --- a/sdk/mpr/parser_unknown.go +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// newUnknownObject creates an UnknownElement that preserves raw BSON fields -// for unrecognized $Type values, preventing silent data loss. -// FieldKinds is populated by inferPropertyKind so callers can see the inferred -// Mendix property kind for each field without inspecting the SDK JS source. -func newUnknownObject(typeName string, raw map[string]any) *model.UnknownElement { - id := "" - if raw != nil { - id = extractBsonID(raw["$ID"]) - } - // convert map to bson.D for storage - doc := make(bson.D, 0, len(raw)) - for k, v := range raw { - doc = append(doc, bson.E{Key: k, Value: v}) - } - elem := &model.UnknownElement{ - BaseElement: model.BaseElement{ID: model.ID(id), TypeName: typeName}, - RawDoc: doc, - } - if raw != nil { - elem.Position = parsePoint(raw["RelativeMiddlePoint"]) - elem.Name = extractString(raw["Name"]) - elem.Caption = extractString(raw["Caption"]) - elem.FieldKinds = make(map[string]string, len(raw)) - for k, v := range raw { - elem.FieldKinds[k] = inferPropertyKind(k, v) - } - } - return elem -} - -// newUnknownObjectFromD creates an UnknownElement from a bson.D document, -// preserving field ordering for round-trip fidelity. -func newUnknownObjectFromD(typeName string, raw bson.D) *model.UnknownElement { - elem := &model.UnknownElement{ - BaseElement: model.BaseElement{TypeName: typeName}, - RawDoc: raw, - } - if len(raw) > 0 { - elem.FieldKinds = make(map[string]string, len(raw)) - for _, e := range raw { - switch e.Key { - case "$ID": - elem.ID = model.ID(extractBsonID(e.Value)) - case "Name": - elem.Name = extractString(e.Value) - case "Caption": - elem.Caption = extractString(e.Value) - case "RelativeMiddlePoint": - elem.Position = parsePoint(e.Value) - } - elem.FieldKinds[e.Key] = inferPropertyKind(e.Key, e.Value) - } - } - return elem -} diff --git a/sdk/mpr/parser_webservice_source_test.go b/sdk/mpr/parser_webservice_source_test.go deleted file mode 100644 index 6d4a8e3c24..0000000000 --- a/sdk/mpr/parser_webservice_source_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// A mapping's SOAP binding must survive the read, because that is the only way -// a rewrite can be refused rather than silently dropping it (ako/mxcli#365). -// Before this, model.ImportMapping carried three source fields and a comment -// saying "at most one is set" — the fourth was not in the type at all. - -func TestParseWebServiceSourceReadsTheBinding(t *testing.T) { - got := parseWebServiceSource(map[string]any{ - "ImportedWebService": "Legacy.WS_Orders", - "ServiceName": "OrderService", - "OperationName": "GetOrder", - "RootElementName": "GetOrderResponse", - "ParameterName": "body", - "IsHeader": true, - }) - - if !got.IsSet() { - t.Fatal("IsSet false for a mapping with an imported web service") - } - if got.ImportedWebService != "Legacy.WS_Orders" { - t.Errorf("ImportedWebService = %q", got.ImportedWebService) - } - if got.ServiceName != "OrderService" || got.OperationName != "GetOrder" { - t.Errorf("service/operation = %q/%q", got.ServiceName, got.OperationName) - } - // RootElementName is stored under that key; the SDK calls it - // xsdRootElementName, which is what makes it easy to bind wrongly. - if got.RootElementName != "GetOrderResponse" { - t.Errorf("RootElementName = %q", got.RootElementName) - } - // Export-only, and carried for the same reason as the rest. - if got.ParameterName != "body" || !got.IsHeader { - t.Errorf("ParameterName/IsHeader = %q/%v", got.ParameterName, got.IsHeader) - } -} - -// TestParseWebServiceSourceIsEmptyForAnOrdinaryMapping is the control: every -// mapping mxcli can author reaches this with none of the keys present, and must -// come back not-set or the guard would refuse every rewrite. -func TestParseWebServiceSourceIsEmptyForAnOrdinaryMapping(t *testing.T) { - got := parseWebServiceSource(map[string]any{ - "Name": "IMM_Order", - "JsonStructure": "Shop.JSON_Order", - }) - if got.IsSet() { - t.Errorf("IsSet true for a JSON-sourced mapping: %+v", got) - } -} diff --git a/sdk/mpr/parser_workflow.go b/sdk/mpr/parser_workflow.go deleted file mode 100644 index 298e75488d..0000000000 --- a/sdk/mpr/parser_workflow.go +++ /dev/null @@ -1,750 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/workflows" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseWorkflow(unitID, containerID string, contents []byte) (*workflows.Workflow, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal workflow BSON: %w", err) - } - - w := &workflows.Workflow{} - w.ID = model.ID(unitID) - w.TypeName = "Workflows$Workflow" - w.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - w.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - w.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - w.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - w.ExportLevel = exportLevel - } - - // Parse Annotation - if annotRaw := raw["Annotation"]; annotRaw != nil { - annotMap := toMap(annotRaw) - if annotMap != nil { - if desc, ok := annotMap["Description"].(string); ok { - w.Annotation = desc - } - } - } - - // Parse Parameter (PART — DomainModels$IndirectEntityRef or similar) - if paramRaw := raw["Parameter"]; paramRaw != nil { - w.Parameter = parseWorkflowParameter(toMap(paramRaw)) - } - - // Parse OverviewPage (BY_NAME reference to Pages$Page) - if overviewPage, ok := raw["OverviewPage"].(string); ok { - w.OverviewPage = overviewPage - } - - // Parse AdminPage (BY_NAME reference) - if adminPage, ok := raw["AdminPage"].(string); ok { - w.AdminPage = adminPage - } - - // Parse WorkflowName (StringTemplate — extract text) - w.WorkflowName = extractStringTemplate(raw["WorkflowName"]) - - // Parse WorkflowDescription (StringTemplate — extract text) - w.WorkflowDescription = extractStringTemplate(raw["WorkflowDescription"]) - - // Parse DueDate expression - if dueDate, ok := raw["DueDate"].(string); ok { - w.DueDate = dueDate - } - - // Parse Flow (PART — Workflows$Flow) - if flowRaw := raw["Flow"]; flowRaw != nil { - w.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - - w.EventHandlers = parseWorkflowEventHandlers(raw["OnWorkflowEvent"]) - - return w, nil -} - -// parseWorkflowEventHandlers reads a workflow's OnWorkflowEvent list. -func parseWorkflowEventHandlers(v any) []*workflows.WorkflowEventHandler { - var out []*workflows.WorkflowEventHandler - for _, item := range extractBsonArray(v) { - m := toMap(item) - if m == nil || extractString(m["$Type"]) != "Workflows$WorkflowEventHandler" { - continue - } - h := &workflows.WorkflowEventHandler{ - Description: extractString(m["Description"]), - Documentation: extractString(m["Documentation"]), - } - h.ID = model.ID(extractBsonID(m["$ID"])) - for _, t := range extractBsonArray(m["EventTypes"]) { - if s, ok := t.(string); ok { - h.EventTypes = append(h.EventTypes, s) - } - } - if mh := toMap(m["MicroflowEventHandler"]); mh != nil { - h.Microflow = extractString(mh["Microflow"]) - } - out = append(out, h) - } - return out -} - -// extractStringTemplate extracts the text from a Mendix StringTemplate BSON structure. -// StringTemplates have a "Text" field with the template string. -func extractStringTemplate(v any) string { - m := toMap(v) - if m == nil { - return "" - } - // Direct text field - if text, ok := m["Text"].(string); ok { - return text - } - // Try Translations for localized strings - if translations := m["Translations"]; translations != nil { - transMap := toMap(translations) - if transMap != nil { - // Look for "en_US" or first available - for _, val := range transMap { - if s, ok := val.(string); ok && s != "" { - return s - } - } - } - } - return "" -} - -// parseWorkflowParameter parses the workflow context parameter. -func parseWorkflowParameter(raw map[string]any) *workflows.WorkflowParameter { - if raw == nil { - return nil - } - - param := &workflows.WorkflowParameter{} - param.ID = model.ID(extractBsonID(raw["$ID"])) - - // EntityRef is typically stored as an IndirectEntityRef with "EntityQualifiedName" or within an Entity field - if entityRef := raw["EntityRef"]; entityRef != nil { - entityMap := toMap(entityRef) - if entityMap != nil { - // Try EntityQualifiedName (new format) - if eqn, ok := entityMap["EntityQualifiedName"].(string); ok { - param.EntityRef = eqn - } - // Try QualifiedName - if qn, ok := entityMap["QualifiedName"].(string); ok && param.EntityRef == "" { - param.EntityRef = qn - } - } - } - - // Also try Entity field directly (BY_NAME reference) - if entity, ok := raw["Entity"].(string); ok && param.EntityRef == "" { - param.EntityRef = entity - } - - // Try EntityQualifiedName at parameter level - if eqn, ok := raw["EntityQualifiedName"].(string); ok && param.EntityRef == "" { - param.EntityRef = eqn - } - - return param -} - -// parseWorkflowFlow parses a Workflows$Flow from raw BSON data. -func parseWorkflowFlow(raw map[string]any) *workflows.Flow { - if raw == nil { - return nil - } - - flow := &workflows.Flow{} - flow.ID = model.ID(extractBsonID(raw["$ID"])) - - // Parse activities array - activitiesRaw := extractBsonArray(raw["Activities"]) - for _, actRaw := range activitiesRaw { - actMap := toMap(actRaw) - if actMap == nil { - continue - } - if activity := parseWorkflowActivity(actMap); activity != nil { - flow.Activities = append(flow.Activities, activity) - } - } - - return flow -} - -// workflowActivityParsers maps Mendix $Type strings to their workflow activity parser functions. -// Initialized in init() to avoid initialization cycle (parseParallelSplitActivity → parseWorkflowFlow → parseWorkflowActivity). -var workflowActivityParsers map[string]func(map[string]any) workflows.WorkflowActivity - -func init() { - workflowActivityParsers = map[string]func(map[string]any) workflows.WorkflowActivity{ - "Workflows$EndWorkflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseEndWorkflowActivity(r) }, - "Workflows$UserTask": func(r map[string]any) workflows.WorkflowActivity { return parseUserTask(r) }, - "Workflows$SingleUserTaskActivity": func(r map[string]any) workflows.WorkflowActivity { return parseUserTask(r) }, - "Workflows$MultiUserTaskActivity": func(r map[string]any) workflows.WorkflowActivity { return parseMultiUserTask(r) }, - "Workflows$CallMicroflowTask": func(r map[string]any) workflows.WorkflowActivity { return parseCallMicroflowTask(r) }, - "Workflows$CallMicroflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseCallMicroflowTask(r) }, - "Workflows$AIAgentTaskActivity": func(r map[string]any) workflows.WorkflowActivity { - t := parseCallMicroflowTask(r) - t.IsAgent = true - return t - }, - "Workflows$CallWorkflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseCallWorkflowActivity(r) }, - "Workflows$ExclusiveSplitActivity": func(r map[string]any) workflows.WorkflowActivity { return parseExclusiveSplitActivity(r) }, - "Workflows$ParallelSplitActivity": func(r map[string]any) workflows.WorkflowActivity { return parseParallelSplitActivity(r) }, - "Workflows$JumpToActivity": func(r map[string]any) workflows.WorkflowActivity { return parseJumpToActivity(r) }, - "Workflows$WaitForTimerActivity": func(r map[string]any) workflows.WorkflowActivity { return parseWaitForTimerActivity(r) }, - "Workflows$WaitForNotificationActivity": func(r map[string]any) workflows.WorkflowActivity { return parseWaitForNotificationActivity(r) }, - "Workflows$StartWorkflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseStartWorkflowActivity(r) }, - "Workflows$EndOfParallelSplitPathActivity": func(r map[string]any) workflows.WorkflowActivity { - a := &workflows.EndOfParallelSplitPathActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, r) - return a - }, - "Workflows$EndOfBoundaryEventPathActivity": func(r map[string]any) workflows.WorkflowActivity { - a := &workflows.EndOfBoundaryEventPathActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, r) - return a - }, - "Workflows$Annotation": func(r map[string]any) workflows.WorkflowActivity { - a := &workflows.WorkflowAnnotationActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, r) - if desc, ok := r["Description"].(string); ok { - a.Description = desc - } - return a - }, - "Workflows$SystemTask": func(r map[string]any) workflows.WorkflowActivity { return parseSystemTask(r) }, - } -} - -// parseWorkflowActivity dispatches activity parsing based on $Type. -func parseWorkflowActivity(raw map[string]any) workflows.WorkflowActivity { - typeName := extractString(raw["$Type"]) - if fn, ok := workflowActivityParsers[typeName]; ok { - return fn(raw) - } - if typeName != "" { - return parseGenericWorkflowActivity(raw, typeName) - } - return nil -} - -// parseEndWorkflowActivity parses an EndWorkflowActivity. -func parseStartWorkflowActivity(raw map[string]any) *workflows.StartWorkflowActivity { - a := &workflows.StartWorkflowActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - return a -} - -func parseEndWorkflowActivity(raw map[string]any) *workflows.EndWorkflowActivity { - a := &workflows.EndWorkflowActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - return a -} - -// parseUserTask parses a UserTask activity. -func parseUserTask(raw map[string]any) *workflows.UserTask { - a := &workflows.UserTask{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Page (BY_NAME reference) - if page, ok := raw["Page"].(string); ok { - a.Page = page - } - // Also try TaskPage — may be a nested Workflows$PageReference object - if a.Page == "" { - if page, ok := raw["TaskPage"].(string); ok { - a.Page = page - } else if taskPageMap := toMap(raw["TaskPage"]); taskPageMap != nil { - if page, ok := taskPageMap["Page"].(string); ok { - a.Page = page - } - } - } - - // TaskName (StringTemplate) - a.TaskName = extractStringTemplate(raw["TaskName"]) - - // TaskDescription (StringTemplate) - a.TaskDescription = extractStringTemplate(raw["TaskDescription"]) - - // DueDate - if dueDate, ok := raw["DueDate"].(string); ok { - a.DueDate = dueDate - } - - // UserTaskEntity (BY_NAME reference) - if ute, ok := raw["UserTaskEntity"].(string); ok { - a.UserTaskEntity = ute - } - - // OnCreatedEvent is a part — Workflows$MicroflowBasedEvent carrying the - // microflow, or Workflows$NoEvent. Reading it as a string, as this did, never - // matched a stored document, so every on-created microflow read as none. - if ev := toMap(raw["OnCreatedEvent"]); ev != nil && extractString(ev["$Type"]) == "Workflows$MicroflowBasedEvent" { - a.OnCreated = extractString(ev["Microflow"]) - } - - // UserSource (PART) — legacy field name - if userSourceRaw := raw["UserSource"]; userSourceRaw != nil { - a.UserSource = parseUserSource(toMap(userSourceRaw)) - } - // UserTargeting (PART) — current field name (Mendix 10.12+) - if a.UserSource == nil { - if userTargetingRaw := raw["UserTargeting"]; userTargetingRaw != nil { - a.UserSource = parseUserSource(toMap(userTargetingRaw)) - } - } - - // Outcomes - outcomesRaw := extractBsonArray(raw["Outcomes"]) - for _, outcomeRaw := range outcomesRaw { - outcomeMap := toMap(outcomeRaw) - if outcomeMap == nil { - continue - } - outcome := parseUserTaskOutcome(outcomeMap) - if outcome != nil { - a.Outcomes = append(a.Outcomes, outcome) - } - } - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseSystemTask parses a SystemTask (older type name for CallMicroflowTask). -func parseSystemTask(raw map[string]any) *workflows.SystemTask { - a := &workflows.SystemTask{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Microflow (BY_NAME reference) - if mf, ok := raw["Microflow"].(string); ok { - a.Microflow = mf - } - if mf, ok := raw["MicroflowName"].(string); ok && a.Microflow == "" { - a.Microflow = mf - } - - // Outcomes - a.Outcomes = parseConditionOutcomes(raw["Outcomes"]) - - // ParameterMappings - a.ParameterMappings = parseParameterMappings(raw["ParameterMappings"]) - - return a -} - -// parseCallMicroflowTask parses a CallMicroflowTask activity. -func parseCallMicroflowTask(raw map[string]any) *workflows.CallMicroflowTask { - a := &workflows.CallMicroflowTask{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Microflow (BY_NAME reference) - if mf, ok := raw["Microflow"].(string); ok { - a.Microflow = mf - } - if mf, ok := raw["MicroflowName"].(string); ok && a.Microflow == "" { - a.Microflow = mf - } - - // Outcomes - a.Outcomes = parseConditionOutcomes(raw["Outcomes"]) - - // ParameterMappings - a.ParameterMappings = parseParameterMappings(raw["ParameterMappings"]) - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseCallWorkflowActivity parses a CallWorkflowActivity. -func parseCallWorkflowActivity(raw map[string]any) *workflows.CallWorkflowActivity { - a := &workflows.CallWorkflowActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Workflow (BY_NAME reference) - if wf, ok := raw["Workflow"].(string); ok { - a.Workflow = wf - } - if wf, ok := raw["WorkflowName"].(string); ok && a.Workflow == "" { - a.Workflow = wf - } - - // ParameterExpression - if expr, ok := raw["ParameterExpression"].(string); ok { - a.ParameterExpression = expr - } - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseExclusiveSplitActivity parses an ExclusiveSplitActivity (decision). -func parseExclusiveSplitActivity(raw map[string]any) *workflows.ExclusiveSplitActivity { - a := &workflows.ExclusiveSplitActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Expression - if expr, ok := raw["Expression"].(string); ok { - a.Expression = expr - } - - // Outcomes - a.Outcomes = parseConditionOutcomes(raw["Outcomes"]) - - return a -} - -// parseParallelSplitActivity parses a ParallelSplitActivity. -func parseParallelSplitActivity(raw map[string]any) *workflows.ParallelSplitActivity { - a := &workflows.ParallelSplitActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Outcomes - outcomesRaw := extractBsonArray(raw["Outcomes"]) - for _, outcomeRaw := range outcomesRaw { - outcomeMap := toMap(outcomeRaw) - if outcomeMap == nil { - continue - } - outcome := &workflows.ParallelSplitOutcome{} - outcome.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - outcome.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - a.Outcomes = append(a.Outcomes, outcome) - } - - return a -} - -// parseJumpToActivity parses a JumpToActivity. -func parseJumpToActivity(raw map[string]any) *workflows.JumpToActivity { - a := &workflows.JumpToActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // TargetActivity (LOCAL_BY_NAME reference) - if target, ok := raw["TargetActivity"].(string); ok { - a.TargetActivity = target - } - if target, ok := raw["TargetActivityName"].(string); ok && a.TargetActivity == "" { - a.TargetActivity = target - } - - return a -} - -// parseWaitForTimerActivity parses a WaitForTimerActivity. -func parseWaitForTimerActivity(raw map[string]any) *workflows.WaitForTimerActivity { - a := &workflows.WaitForTimerActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - if expr, ok := raw["Delay"].(string); ok { - a.DelayExpression = expr - } else if expr, ok := raw["DelayExpression"].(string); ok { - // Legacy fallback - a.DelayExpression = expr - } - - return a -} - -// parseWaitForNotificationActivity parses a WaitForNotificationActivity. -func parseWaitForNotificationActivity(raw map[string]any) *workflows.WaitForNotificationActivity { - a := &workflows.WaitForNotificationActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseGenericWorkflowActivity creates a fallback for unknown activity types. -func parseGenericWorkflowActivity(raw map[string]any, typeName string) *workflows.GenericWorkflowActivity { - a := &workflows.GenericWorkflowActivity{TypeString: typeName} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - return a -} - -// parseBaseActivity extracts common fields for all workflow activities. -func parseBaseActivity(a *workflows.BaseWorkflowActivity, raw map[string]any) { - a.ID = model.ID(extractBsonID(raw["$ID"])) - a.TypeName = extractString(raw["$Type"]) - - if name, ok := raw["Name"].(string); ok { - a.Name = name - } - if caption, ok := raw["Caption"].(string); ok { - a.Caption = caption - } - - // Annotation (PART — Workflows$Annotation) - if annotRaw := raw["Annotation"]; annotRaw != nil { - annotMap := toMap(annotRaw) - if annotMap != nil { - if desc, ok := annotMap["Description"].(string); ok { - a.Annotation = desc - } - } - } -} - -// parseMultiUserTask parses a MultiUserTaskActivity, reusing parseUserTask with IsMulti flag. -func parseMultiUserTask(raw map[string]any) *workflows.UserTask { - task := parseUserTask(raw) - if task != nil { - task.IsMulti = true - } - return task -} - -// parseUserTaskOutcome parses a UserTaskOutcome. -func parseUserTaskOutcome(raw map[string]any) *workflows.UserTaskOutcome { - outcome := &workflows.UserTaskOutcome{} - outcome.ID = model.ID(extractBsonID(raw["$ID"])) - - if name, ok := raw["Name"].(string); ok { - outcome.Name = name - } - if caption, ok := raw["Caption"].(string); ok { - outcome.Caption = caption - } - if value, ok := raw["Value"].(string); ok { - outcome.Value = value - } - - if flowRaw := raw["Flow"]; flowRaw != nil { - outcome.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - - return outcome -} - -// parseConditionOutcomes parses an array of condition outcomes. -func parseConditionOutcomes(v any) []workflows.ConditionOutcome { - outcomesRaw := extractBsonArray(v) - var outcomes []workflows.ConditionOutcome - - for _, outcomeRaw := range outcomesRaw { - outcomeMap := toMap(outcomeRaw) - if outcomeMap == nil { - continue - } - - typeName := extractString(outcomeMap["$Type"]) - switch typeName { - case "Workflows$BooleanConditionOutcome": - o := &workflows.BooleanConditionOutcome{} - o.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if v, ok := outcomeMap["Value"].(bool); ok { - o.Value = v - } - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - o.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - outcomes = append(outcomes, o) - - case "Workflows$EnumerationValueConditionOutcome": - o := &workflows.EnumerationValueConditionOutcome{} - o.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if v, ok := outcomeMap["Value"].(string); ok { - o.Value = v - } - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - o.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - outcomes = append(outcomes, o) - - default: - // VoidConditionOutcome or unknown - o := &workflows.VoidConditionOutcome{} - o.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - o.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - outcomes = append(outcomes, o) - } - } - - return outcomes -} - -// parseUserSource parses a UserSource from raw BSON data. -// Mendix versions before 10.12 use "UserSource" BSON field with $Type names like -// "Workflows$MicroflowBasedUserSource". Mendix 10.12+ uses "UserTargeting" field -// with $Type names like "Workflows$MicroflowUserTargeting". Both are supported. -func parseUserSource(raw map[string]any) workflows.UserSource { - if raw == nil { - return &workflows.NoUserSource{} - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "Workflows$NoUserSource", "Workflows$NoUserTargeting": - return &workflows.NoUserSource{} - - case "Workflows$MicroflowBasedUserSource", "Workflows$MicroflowUserTargeting": - source := &workflows.MicroflowBasedUserSource{} - if mf, ok := raw["Microflow"].(string); ok { - source.Microflow = mf - } - if mf, ok := raw["MicroflowName"].(string); ok && source.Microflow == "" { - source.Microflow = mf - } - return source - - case "Workflows$XPathBasedUserSource", "Workflows$XPathUserTargeting": - source := &workflows.XPathBasedUserSource{} - if xpath, ok := raw["XPathConstraint"].(string); ok { - source.XPath = xpath - } - if xpath, ok := raw["XPath"].(string); ok && source.XPath == "" { - source.XPath = xpath - } - return source - - case "Workflows$MicroflowGroupTargeting": - source := &workflows.MicroflowGroupSource{} - if mf, ok := raw["Microflow"].(string); ok { - source.Microflow = mf - } - return source - - case "Workflows$XPathGroupTargeting": - source := &workflows.XPathGroupSource{} - if xpath, ok := raw["XPathConstraint"].(string); ok { - source.XPath = xpath - } - if xpath, ok := raw["XPath"].(string); ok && source.XPath == "" { - source.XPath = xpath - } - return source - - default: - return &workflows.NoUserSource{} - } -} - -// parseBoundaryEvents parses boundary events from a BSON array. -func parseBoundaryEvents(v any) []*workflows.BoundaryEvent { - eventsRaw := extractBsonArray(v) - var events []*workflows.BoundaryEvent - - for _, eventRaw := range eventsRaw { - eventMap := toMap(eventRaw) - if eventMap == nil { - continue - } - event := &workflows.BoundaryEvent{} - event.ID = model.ID(extractBsonID(eventMap["$ID"])) - event.TypeName = extractString(eventMap["$Type"]) - - if caption, ok := eventMap["Caption"].(string); ok { - event.Caption = caption - } - - // Timer delay — BSON field is "FirstExecutionTime" for both boundary event types - if delay, ok := eventMap["FirstExecutionTime"].(string); ok { - event.TimerDelay = delay - } - // Legacy fallbacks - if event.TimerDelay == "" { - if delay, ok := eventMap["DelayExpression"].(string); ok { - event.TimerDelay = delay - } - } - if event.TimerDelay == "" { - if delay, ok := eventMap["Delay"].(string); ok { - event.TimerDelay = delay - } - } - - // Event type from $Type - typeName := extractString(eventMap["$Type"]) - switch typeName { - case "Workflows$InterruptingTimerBoundaryEvent": - event.EventType = "InterruptingTimer" - case "Workflows$NonInterruptingTimerBoundaryEvent": - event.EventType = "NonInterruptingTimer" - case "Workflows$TimerBoundaryEvent": - event.EventType = "Timer" - default: - if typeName != "" { - // Extract the event type from the type name - event.EventType = strings.TrimPrefix(typeName, "Workflows$") - } - } - - // Flow - if flowRaw := eventMap["Flow"]; flowRaw != nil { - event.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - - events = append(events, event) - } - - return events -} - -// parseParameterMappings parses parameter mappings from an array. -func parseParameterMappings(v any) []*workflows.ParameterMapping { - mappingsRaw := extractBsonArray(v) - var mappings []*workflows.ParameterMapping - - for _, mappingRaw := range mappingsRaw { - mappingMap := toMap(mappingRaw) - if mappingMap == nil { - continue - } - mapping := &workflows.ParameterMapping{} - mapping.ID = model.ID(extractBsonID(mappingMap["$ID"])) - - if param, ok := mappingMap["Parameter"].(string); ok { - mapping.Parameter = param - } - if expr, ok := mappingMap["Expression"].(string); ok { - mapping.Expression = expr - } - - mappings = append(mappings, mapping) - } - - return mappings -} diff --git a/sdk/mpr/placeholder_test.go b/sdk/mpr/placeholder_test.go deleted file mode 100644 index 23146796c7..0000000000 --- a/sdk/mpr/placeholder_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -func TestValidateNoPlaceholderIDs_BinaryPattern(t *testing.T) { - // Simulate BSON contents containing the placeholder binary pattern. - // The GUID-swapped placeholder prefix is \x00\x00\x00\xaa followed by 9 zero bytes. - contents := []byte("some bson preamble") - contents = append(contents, 0x00, 0x00, 0x00, 0xaa) // GUID-swapped first 4 bytes - contents = append(contents, 0x00, 0x00, 0x00, 0x00, 0x00) // bytes 4-8 - contents = append(contents, 0x00, 0x00, 0x00, 0x00) // bytes 9-12 - contents = append(contents, 0x00, 0x00, 0x01) // counter bytes - contents = append(contents, []byte("more bson data")...) - - err := validateNoPlaceholderIDs("test-unit-id", contents) - if err == nil { - t.Fatal("expected error for placeholder binary pattern, got nil") - } - if got := err.Error(); got == "" { - t.Fatal("expected non-empty error message") - } -} - -func TestValidateNoPlaceholderIDs_StringPattern(t *testing.T) { - // Simulate BSON contents containing a placeholder as an ASCII string - contents := []byte("some bson preamble aa000000000000000000000000000003 more data") - - err := validateNoPlaceholderIDs("test-unit-id", contents) - if err == nil { - t.Fatal("expected error for placeholder string pattern, got nil") - } -} - -func TestValidateNoPlaceholderIDs_Clean(t *testing.T) { - // Normal BSON-like data with no placeholder patterns - contents := []byte{ - 0x1a, 0x00, 0x00, 0x00, // BSON document length - 0x02, // string type - 0x6e, 0x61, 0x6d, 0x65, 0x00, // "name\0" - 0x08, 0x00, 0x00, 0x00, // string length - 0x54, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x00, // "Testing\0" - 0x05, // binary type - 0x69, 0x64, 0x00, // "id\0" - 0x10, 0x00, 0x00, 0x00, 0x00, // binary length + subtype - 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, // legitimate UUID - 0x00, // document terminator - } - - err := validateNoPlaceholderIDs("test-unit-id", contents) - if err != nil { - t.Fatalf("expected no error for clean data, got: %v", err) - } -} diff --git a/sdk/mpr/queues.go b/sdk/mpr/queues.go deleted file mode 100644 index 907690da83..0000000000 --- a/sdk/mpr/queues.go +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" -) - -// Task queues (Queues$Queue). The document is small and flat apart from a single -// nested Config node, so it is read and written as raw BSON here rather than -// through a dedicated element type. -// -// The shape follows four Studio Pro-authored queues from the Mendix Business -// Events module. Note that Queues$BasicQueueConfig declares an int32 -// `Parallelism` in addition to `ParallelismExpression`, and Studio Pro wrote it -// in none of them — so only the expression is read and written. - -const queueUnitType = "Queues$Queue" - -// ListQueues reads every task queue in the project. -func (r *Reader) ListQueues() ([]*types.Queue, error) { - units, err := r.ListRawUnitsByType(queueUnitType) - if err != nil { - return nil, err - } - out := make([]*types.Queue, 0, len(units)) - for _, u := range units { - var doc bson.M - if err := bson.Unmarshal(u.Contents, &doc); err != nil { - return nil, fmt.Errorf("unmarshal queue %s: %w", u.ID, err) - } - q := &types.Queue{ContainerID: model.ID(u.ContainerID)} - q.ID = model.ID(u.ID) - q.TypeName = queueUnitType - q.Name, _ = doc["Name"].(string) - q.Documentation, _ = doc["Documentation"].(string) - q.Excluded, _ = doc["Excluded"].(bool) - q.ExportLevel, _ = doc["ExportLevel"].(string) - if cfg, ok := doc["Config"].(bson.M); ok { - q.Parallelism, _ = cfg["ParallelismExpression"].(string) - q.ClusterWide, _ = cfg["ClusterWide"].(bool) - } - out = append(out, q) - } - return out, nil -} - -// CreateQueue inserts a new task queue document. -func (w *Writer) CreateQueue(q *types.Queue) error { - if q == nil { - return fmt.Errorf("CreateQueue: nil queue") - } - if q.ID == "" { - q.ID = model.ID(generateUUID()) - } - contents, err := serializeQueueUnit(q) - if err != nil { - return err - } - return w.insertUnit(string(q.ID), string(q.ContainerID), "Documents", queueUnitType, contents) -} - -// UpdateQueue rewrites an existing task queue in place. -func (w *Writer) UpdateQueue(q *types.Queue) error { - if q == nil { - return fmt.Errorf("UpdateQueue: nil queue") - } - contents, err := serializeQueueUnit(q) - if err != nil { - return err - } - return w.UpdateRawUnit(string(q.ID), contents) -} - -// DeleteQueue removes a task queue by ID. -func (w *Writer) DeleteQueue(id string) error { - return w.deleteUnit(id) -} - -func serializeQueueUnit(q *types.Queue) ([]byte, error) { - parallelism := q.Parallelism - if parallelism == "" { - parallelism = "1" - } - exportLevel := q.ExportLevel - if exportLevel == "" { - exportLevel = "Hidden" - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(q.ID))}, - {Key: "$Type", Value: queueUnitType}, - {Key: "Config", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Queues$BasicQueueConfig"}, - {Key: "ClusterWide", Value: q.ClusterWide}, - {Key: "ParallelismExpression", Value: parallelism}, - }}, - {Key: "Documentation", Value: q.Documentation}, - {Key: "Excluded", Value: q.Excluded}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "Name", Value: q.Name}, - } - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/reader.go b/sdk/mpr/reader.go deleted file mode 100644 index 7bb79fd53c..0000000000 --- a/sdk/mpr/reader.go +++ /dev/null @@ -1,269 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr provides functionality for reading and writing Mendix project files (.mpr). -package mpr - -import ( - "database/sql" - "encoding/hex" - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr/version" - - _ "modernc.org/sqlite" -) - -// MPRVersion represents the MPR file format version. -type MPRVersion int - -const ( - // MPRVersionV1 is the original single-file format. - MPRVersionV1 MPRVersion = 1 - // MPRVersionV2 uses mprcontents folder (Mendix 10.18+). - MPRVersionV2 MPRVersion = 2 -) - -// Reader provides methods to read Mendix project files. -type Reader struct { - path string - db *sql.DB - version MPRVersion - contentsDir string - readOnly bool - projectVersion *version.ProjectVersion - - // Cache for unit metadata to avoid repeated file reads - unitCache []cachedUnit - unitCacheValid bool - - // Lazily-built index of (unit $Type + qualified name) → unit, so name - // lookups are O(1) instead of re-scanning and re-parsing every unit per - // call (the source-catalog build does thousands of such lookups). - nameIndex map[string]nameIndexEntry - nameIndexBuilt bool -} - -// cachedUnit stores metadata about a unit for fast filtering. -type cachedUnit struct { - ID string - ContainerID string - ContainmentName string - Type string -} - -// OpenOptions configures how the MPR file is opened. -type OpenOptions struct { - // ReadOnly opens the database in read-only mode. - ReadOnly bool -} - -// Open opens an MPR file for reading. -func Open(path string) (*Reader, error) { - return OpenWithOptions(path, OpenOptions{ReadOnly: true}) -} - -// OpenWithOptions opens an MPR file with the specified options. -func OpenWithOptions(path string, opts OpenOptions) (*Reader, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, fmt.Errorf("mpr file not found: %s", path) - } - - r := &Reader{ - path: path, - readOnly: opts.ReadOnly, - } - - // Check for MPR v2 (mprcontents folder) - dir := filepath.Dir(path) - contentsDir := filepath.Join(dir, "mprcontents") - if stat, err := os.Stat(contentsDir); err == nil && stat.IsDir() { - r.version = MPRVersionV2 - r.contentsDir = contentsDir - } else { - r.version = MPRVersionV1 - } - - // Open SQLite database - dsn := path - if opts.ReadOnly { - dsn = fmt.Sprintf("file:%s?mode=ro", path) - } - - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Limit to single connection to avoid lock contention with SQLite - db.SetMaxOpenConns(1) - - // Set busy timeout to prevent SQLITE_BUSY errors during multi-statement - // script execution (e.g., 12+ CREATE PAGE commands in sequence) - if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { - db.Close() - return nil, fmt.Errorf("failed to set busy_timeout: %w", err) - } - - r.db = db - - // Detect project version from metadata - pv, err := version.DetectFromDB(db) - if err != nil { - r.Close() - return nil, fmt.Errorf("failed to detect project version: %w", err) - } - r.projectVersion = pv - - // Reconcile version detection: the folder-based check can fail if the .mpr - // file was copied without the mprcontents/ folder. Check the actual DB schema - // to determine whether the Unit table has a Contents column. If it doesn't, - // we must use v2 code paths to avoid "no such column: Contents" errors. - if r.version == MPRVersionV1 && !r.unitTableHasContents() { - dir := filepath.Dir(path) - contentsDir := filepath.Join(dir, "mprcontents") - r.version = MPRVersionV2 - r.contentsDir = contentsDir - } - - // Verify it's a valid MPR file - if err := r.verify(); err != nil { - r.Close() - return nil, err - } - - return r, nil -} - -// Close closes the reader and releases resources. -func (r *Reader) Close() error { - if r.db != nil { - return r.db.Close() - } - return nil -} - -// unitTableHasContents checks whether the Unit table has a Contents column. -// MPR v2 schemas (Mendix 10.18+) drop this column; v1 schemas have it. -func (r *Reader) unitTableHasContents() bool { - rows, err := r.db.Query("PRAGMA table_info(Unit)") - if err != nil { - return false - } - defer rows.Close() - for rows.Next() { - var cid int - var name, colType string - var notNull, pk int - var dfltValue *string - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - continue - } - if name == "Contents" { - return true - } - } - return false -} - -// Path returns the path to the MPR file. -func (r *Reader) Path() string { - return r.path -} - -// Version returns the MPR file format version. -func (r *Reader) Version() MPRVersion { - return r.version -} - -// ContentsDir returns the path to the mprcontents directory for v2 format. -// Returns empty string for v1 format. -func (r *Reader) ContentsDir() string { - return r.contentsDir -} - -// ListAllUnitIDs returns all unit UUIDs from the Unit table. -func (r *Reader) ListAllUnitIDs() ([]string, error) { - rows, err := r.db.Query("SELECT UnitID FROM Unit") - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var unitID []byte - if err := rows.Scan(&unitID); err != nil { - return nil, fmt.Errorf("scanning unit ID: %w", err) - } - ids = append(ids, BlobToUUID(unitID)) - } - return ids, rows.Err() -} - -// ProjectVersion returns the Mendix project version information. -func (r *Reader) ProjectVersion() *version.ProjectVersion { - return r.projectVersion -} - -// verify checks that the file is a valid MPR database. -func (r *Reader) verify() error { - // Check for Unit table which is required - var count int - err := r.db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'Unit'").Scan(&count) - if err != nil { - return fmt.Errorf("failed to query tables: %w", err) - } - if count == 0 { - return errors.New("not a valid MPR file: Unit table not found") - } - return nil -} - -// GetProjectRootID returns the ID of the project root unit. -// The project root is the unit where UnitID equals ContainerID. -func (r *Reader) GetProjectRootID() (string, error) { - var unitID []byte - err := r.db.QueryRow("SELECT UnitID FROM Unit WHERE UnitID = ContainerID").Scan(&unitID) - if err != nil { - return "", fmt.Errorf("failed to get project root: %w", err) - } - return blobToUUID(unitID), nil -} - -// GetMendixVersion returns the Mendix version used to create the project. -func (r *Reader) GetMendixVersion() (string, error) { - var version string - // Try new schema first - err := r.db.QueryRow("SELECT _ProductVersion FROM _MetaData LIMIT 1").Scan(&version) - if err != nil { - // Try old schema - err = r.db.QueryRow("SELECT MendixVersion FROM _MetaData LIMIT 1").Scan(&version) - if err != nil { - return "", fmt.Errorf("failed to get Mendix version: %w", err) - } - } - return version, nil -} - -// blobToUUID delegates to types.BlobToUUID. -func blobToUUID(blob []byte) string { - return types.BlobToUUID(blob) -} - -// blobToUUIDSwapped converts a 16-byte blob to a UUID string using Microsoft GUID format. -// The first 3 groups are little-endian (byte-swapped), last 2 groups are big-endian. -// This is the format used by Mendix for file naming in mprcontents folder. -func blobToUUIDSwapped(blob []byte) string { - if len(blob) != 16 { - return hex.EncodeToString(blob) - } - return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", - blob[3], blob[2], blob[1], blob[0], - blob[5], blob[4], - blob[7], blob[6], - blob[8], blob[9], - blob[10], blob[11], blob[12], blob[13], blob[14], blob[15]) -} diff --git a/sdk/mpr/reader_agenteditor.go b/sdk/mpr/reader_agenteditor.go deleted file mode 100644 index 339b18f3e0..0000000000 --- a/sdk/mpr/reader_agenteditor.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Reader methods for agent-editor CustomBlobDocuments. -// -// Covers the four document types created by the Studio Pro Agent Editor -// extension: Agent, Model, Knowledge Base, Consumed MCP Service. Each -// shares the outer CustomBlobDocument BSON wrapper and is discriminated -// by CustomDocumentType. This file currently implements Model only; the -// other three will follow the same pattern. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// ListAgentEditorModels returns all agent-editor Model documents in the -// project (CustomDocumentType == "agenteditor.model"). -func (r *Reader) ListAgentEditorModels() ([]*agenteditor.Model, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.Model - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - // Skip units we can't decode; log to error list if useful later. - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeModel { - continue - } - m, err := r.parseAgentEditorModel(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor model %s: %w", u.ID, err) - } - result = append(result, m) - } - return result, nil -} - -// ListAgentEditorKnowledgeBases returns all agent-editor Knowledge Base -// documents in the project (CustomDocumentType == "agenteditor.knowledgebase"). -func (r *Reader) ListAgentEditorKnowledgeBases() ([]*agenteditor.KnowledgeBase, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.KnowledgeBase - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeKnowledgeBase { - continue - } - kb, err := r.parseAgentEditorKnowledgeBase(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor knowledge base %s: %w", u.ID, err) - } - result = append(result, kb) - } - return result, nil -} - -// ListAgentEditorConsumedMCPServices returns all agent-editor Consumed MCP -// Service documents in the project (CustomDocumentType == -// "agenteditor.consumedMCPService"). -func (r *Reader) ListAgentEditorConsumedMCPServices() ([]*agenteditor.ConsumedMCPService, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.ConsumedMCPService - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeConsumedMCPService { - continue - } - c, err := r.parseAgentEditorConsumedMCPService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor consumed MCP service %s: %w", u.ID, err) - } - result = append(result, c) - } - return result, nil -} - -// ListAgentEditorAgents returns all agent-editor Agent documents in the -// project (CustomDocumentType == "agenteditor.agent"). -func (r *Reader) ListAgentEditorAgents() ([]*agenteditor.Agent, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.Agent - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeAgent { - continue - } - a, err := r.parseAgentEditorAgent(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor agent %s: %w", u.ID, err) - } - result = append(result, a) - } - return result, nil -} diff --git a/sdk/mpr/reader_documents.go b/sdk/mpr/reader_documents.go deleted file mode 100644 index 4fabd2e491..0000000000 --- a/sdk/mpr/reader_documents.go +++ /dev/null @@ -1,1105 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Document listing and retrieval methods for Reader. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/sdk/pages" - "github.com/mendixlabs/mxcli/sdk/security" - "github.com/mendixlabs/mxcli/sdk/workflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// ListModules returns all modules in the project. -func (r *Reader) ListModules() ([]*model.Module, error) { - // Use Projects$ModuleImpl (not Projects$Module which also matches ModuleSettings) - units, err := r.listUnitsByType("Projects$ModuleImpl") - if err != nil { - return nil, err - } - - var modules []*model.Module - for _, u := range units { - module, err := r.parseModule(u.ID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse module %s: %w", u.ID, err) - } - modules = append(modules, module) - } - - // Append virtual System module - modules = append(modules, BuildSystemModule()) - - return modules, nil -} - -// GetModule retrieves a module by ID. -func (r *Reader) GetModule(id model.ID) (*model.Module, error) { - modules, err := r.ListModules() - if err != nil { - return nil, err - } - - for _, m := range modules { - if m.ID == id { - return m, nil - } - } - - return nil, fmt.Errorf("module not found: %s", id) -} - -// GetModuleByName retrieves a module by name. -func (r *Reader) GetModuleByName(name string) (*model.Module, error) { - modules, err := r.ListModules() - if err != nil { - return nil, err - } - - for _, m := range modules { - if m.Name == name { - return m, nil - } - } - - return nil, fmt.Errorf("module not found: %s", name) -} - -// ListDomainModels returns all domain models in the project. -func (r *Reader) ListDomainModels() ([]*domainmodel.DomainModel, error) { - units, err := r.listUnitsByType("DomainModels$DomainModel") - if err != nil { - return nil, err - } - - var domainModels []*domainmodel.DomainModel - for _, u := range units { - dm, err := r.parseDomainModel(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse domain model %s: %w", u.ID, err) - } - domainModels = append(domainModels, dm) - } - - // Load OQL queries for view entities - oqlMap, err := r.loadViewEntityOqlQueries() - if err != nil { - // Non-fatal error, just skip OQL population - return domainModels, nil - } - - // Populate OQL queries for view entities - for _, dm := range domainModels { - for _, entity := range dm.Entities { - if entity.SourceDocumentRef != "" { - if oql, ok := oqlMap[entity.SourceDocumentRef]; ok { - entity.OqlQuery = oql - } - } - } - } - - // Append virtual System module domain model - domainModels = append(domainModels, BuildSystemDomainModel()) - - return domainModels, nil -} - -// loadViewEntityOqlQueries loads all ViewEntitySourceDocuments and returns a map of qualified name -> OQL query. -func (r *Reader) loadViewEntityOqlQueries() (map[string]string, error) { - units, err := r.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return nil, err - } - - // Build module ID -> name map once (for efficiency) - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[string]string) - for _, m := range modules { - moduleNames[string(m.ID)] = m.Name - } - - result := make(map[string]string) - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - oql, _ := raw["Oql"].(string) - - if name != "" { - // Build qualified name from module + name - moduleName := moduleNames[u.ContainerID] - qualifiedName := moduleName + "." + name - result[qualifiedName] = oql - } - } - - return result, nil -} - -// GetDomainModel retrieves a domain model by module ID. -func (r *Reader) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, error) { - domainModels, err := r.ListDomainModels() - if err != nil { - return nil, err - } - - for _, dm := range domainModels { - if dm.ContainerID == moduleID { - return dm, nil - } - } - - return nil, fmt.Errorf("domain model not found for module: %s", moduleID) -} - -// GetDomainModelByID retrieves a domain model by its own ID. -func (r *Reader) GetDomainModelByID(id model.ID) (*domainmodel.DomainModel, error) { - domainModels, err := r.ListDomainModels() - if err != nil { - return nil, err - } - - for _, dm := range domainModels { - if dm.ID == id { - return dm, nil - } - } - - return nil, fmt.Errorf("domain model not found: %s", id) -} - -// ListMicroflows returns all microflows in the project. -func (r *Reader) ListMicroflows() ([]*microflows.Microflow, error) { - units, err := r.listUnitsByType("Microflows$Microflow") - if err != nil { - return nil, err - } - - var result []*microflows.Microflow - for _, u := range units { - mf, err := r.parseMicroflow(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse microflow %s: %w", u.ID, err) - } - result = append(result, mf) - } - - return result, nil -} - -// GetMicroflow retrieves a microflow by ID. -// Uses a direct unit lookup (O(1) for V1, O(cache) for V2) instead of loading all microflows. -func (r *Reader) GetMicroflow(id model.ID) (*microflows.Microflow, error) { - unit, err := r.getUnitByID(string(id)) - if err != nil { - return nil, err - } - if unit == nil { - return nil, fmt.Errorf("microflow not found: %s", id) - } - return r.parseMicroflow(unit.ID, unit.ContainerID, unit.Contents) -} - -// ListRules returns every rule document (Microflows$Rule). Rules are a distinct -// doctype and deliberately absent from ListMicroflows. -func (r *Reader) ListRules() ([]*microflows.Rule, error) { - units, err := r.listUnitsByType("Microflows$Rule") - if err != nil { - return nil, err - } - - var result []*microflows.Rule - for _, u := range units { - rule, err := r.parseRule(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse rule %s: %w", u.ID, err) - } - result = append(result, rule) - } - - return result, nil -} - -// GetRule retrieves a rule by ID. -func (r *Reader) GetRule(id model.ID) (*microflows.Rule, error) { - rules, err := r.ListRules() - if err != nil { - return nil, err - } - for _, rule := range rules { - if rule.ID == id { - return rule, nil - } - } - return nil, nil -} - -// IsRule reports whether the given qualified name refers to a rule -// (Microflows$Rule). Rules share the microflow namespace but are stored -// under a distinct BSON type — the flow-builder needs this distinction so -// it can emit RuleSplitCondition instead of ExpressionSplitCondition for -// rule-based IF statements. -func (r *Reader) IsRule(qualifiedName string) (bool, error) { - if qualifiedName == "" { - return false, nil - } - units, err := r.listUnitsByType("Microflows$Rule") - if err != nil { - return false, err - } - if len(units) == 0 { - return false, nil - } - modules, err := r.ListModules() - if err != nil { - return false, err - } - moduleMap := make(map[string]string, len(modules)) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - containerParent, err := r.buildContainerParent() - if err != nil { - return false, err - } - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - name, _ := raw["Name"].(string) - if name == "" { - continue - } - moduleName := resolveModuleName(u.ContainerID, moduleMap, containerParent) - fullName := name - if moduleName != "" { - fullName = moduleName + "." + name - } - if fullName == qualifiedName { - return true, nil - } - } - return false, nil -} - -// ListNanoflows returns all nanoflows in the project. -func (r *Reader) ListNanoflows() ([]*microflows.Nanoflow, error) { - units, err := r.listUnitsByType("Microflows$Nanoflow") - if err != nil { - return nil, err - } - - var result []*microflows.Nanoflow - for _, u := range units { - nf, err := r.parseNanoflow(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse nanoflow %s: %w", u.ID, err) - } - result = append(result, nf) - } - - return result, nil -} - -// GetNanoflow retrieves a nanoflow by ID. -// Uses a direct unit lookup (O(1) for V1, O(cache) for V2) instead of loading all nanoflows. -func (r *Reader) GetNanoflow(id model.ID) (*microflows.Nanoflow, error) { - unit, err := r.getUnitByID(string(id)) - if err != nil { - return nil, err - } - if unit == nil { - return nil, fmt.Errorf("nanoflow not found: %s", id) - } - return r.parseNanoflow(unit.ID, unit.ContainerID, unit.Contents) -} - -// ListPages returns all pages in the project. -func (r *Reader) ListPages() ([]*pages.Page, error) { - // Try Forms$Page first (Mendix 10+), then Pages$Page (older versions) - units, err := r.listUnitsByType("Forms$Page") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Pages$Page") - if err != nil { - return nil, err - } - } - - var result []*pages.Page - for _, u := range units { - page, err := r.parsePage(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse page %s: %w", u.ID, err) - } - result = append(result, page) - } - - return result, nil -} - -// GetPage retrieves a page by ID. -func (r *Reader) GetPage(id model.ID) (*pages.Page, error) { - pagesList, err := r.ListPages() - if err != nil { - return nil, err - } - - for _, p := range pagesList { - if p.ID == id { - return p, nil - } - } - - return nil, fmt.Errorf("page not found: %s", id) -} - -// ListLayouts returns all layouts in the project. -func (r *Reader) ListLayouts() ([]*pages.Layout, error) { - // Try Forms$Layout first (Mendix 10+), then Pages$Layout (older versions) - units, err := r.listUnitsByType("Forms$Layout") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Pages$Layout") - if err != nil { - return nil, err - } - } - - var result []*pages.Layout - for _, u := range units { - layout, err := r.parseLayout(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse layout %s: %w", u.ID, err) - } - result = append(result, layout) - } - - return result, nil -} - -// GetLayout retrieves a layout by ID. -func (r *Reader) GetLayout(id model.ID) (*pages.Layout, error) { - layouts, err := r.ListLayouts() - if err != nil { - return nil, err - } - - for _, l := range layouts { - if l.ID == id { - return l, nil - } - } - - return nil, fmt.Errorf("layout not found: %s", id) -} - -// ListEnumerations returns all enumerations in the project. -func (r *Reader) ListEnumerations() ([]*model.Enumeration, error) { - units, err := r.listUnitsByType("Enumerations$Enumeration") - if err != nil { - return nil, err - } - - var result []*model.Enumeration - for _, u := range units { - enum, err := r.parseEnumeration(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse enumeration %s: %w", u.ID, err) - } - result = append(result, enum) - } - - return result, nil -} - -// GetEnumeration retrieves an enumeration by ID. -func (r *Reader) GetEnumeration(id model.ID) (*model.Enumeration, error) { - enums, err := r.ListEnumerations() - if err != nil { - return nil, err - } - - for _, e := range enums { - if e.ID == id { - return e, nil - } - } - - return nil, fmt.Errorf("enumeration not found: %s", id) -} - -// ListConstants returns all constants in the project. -func (r *Reader) ListConstants() ([]*model.Constant, error) { - units, err := r.listUnitsByType("Constants$Constant") - if err != nil { - return nil, err - } - - var result []*model.Constant - for _, u := range units { - constant, err := r.parseConstant(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse constant %s: %w", u.ID, err) - } - result = append(result, constant) - } - - return result, nil -} - -// GetConstant retrieves a constant by ID. -func (r *Reader) GetConstant(id model.ID) (*model.Constant, error) { - constants, err := r.ListConstants() - if err != nil { - return nil, err - } - - for _, c := range constants { - if c.ID == id { - return c, nil - } - } - - return nil, fmt.Errorf("constant not found: %s", id) -} - -// GetRawUnit retrieves raw BSON data for a unit by ID as a map. -func (r *Reader) GetRawUnit(id model.ID) (map[string]any, error) { - // Try to get raw contents for the unit - var contents []byte - var err error - - if r.version == MPRVersionV2 { - // V2: Read from mprcontents folder - contents, err = r.readMprContents(string(id)) - if err != nil { - return nil, fmt.Errorf("failed to read unit contents: %w", err) - } - } else { - // V1: Read from database — convert UUID to GUID blob for the query - unitIDBlob := types.UUIDToBlob(string(id)) - row := r.db.QueryRow("SELECT Contents FROM Unit WHERE UnitID = ?", unitIDBlob) - err = row.Scan(&contents) - if err != nil { - return nil, fmt.Errorf("failed to read unit from database: %w", err) - } - } - - contents, err = r.resolveContents(string(id), contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - return raw, nil -} - -// ListScheduledEvents returns all scheduled events in the project. -func (r *Reader) ListScheduledEvents() ([]*model.ScheduledEvent, error) { - units, err := r.listUnitsByType("ScheduledEvents$ScheduledEvent") - if err != nil { - return nil, err - } - - var result []*model.ScheduledEvent - for _, u := range units { - event, err := r.parseScheduledEvent(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse scheduled event %s: %w", u.ID, err) - } - result = append(result, event) - } - - return result, nil -} - -// GetScheduledEvent retrieves a scheduled event by ID. -func (r *Reader) GetScheduledEvent(id model.ID) (*model.ScheduledEvent, error) { - events, err := r.ListScheduledEvents() - if err != nil { - return nil, err - } - - for _, e := range events { - if e.ID == id { - return e, nil - } - } - - return nil, fmt.Errorf("scheduled event not found: %s", id) -} - -// ListSnippets returns all snippets in the project. -func (r *Reader) ListSnippets() ([]*pages.Snippet, error) { - // Try Forms$Snippet first (Mendix 10+), then Pages$Snippet (older versions) - units, err := r.listUnitsByType("Forms$Snippet") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Pages$Snippet") - if err != nil { - return nil, err - } - } - - var result []*pages.Snippet - for _, u := range units { - snippet, err := r.parseSnippet(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse snippet %s: %w", u.ID, err) - } - result = append(result, snippet) - } - - return result, nil -} - -// GetProjectSecurity returns the project security configuration. -func (r *Reader) GetProjectSecurity() (*security.ProjectSecurity, error) { - units, err := r.listUnitsByType("Security$ProjectSecurity") - if err != nil { - return nil, err - } - - if len(units) == 0 { - return nil, fmt.Errorf("project security not found") - } - - return r.parseProjectSecurity(units[0].ID, units[0].ContainerID, units[0].Contents) -} - -// ListModuleSecurity returns all module security configurations. -func (r *Reader) ListModuleSecurity() ([]*security.ModuleSecurity, error) { - units, err := r.listUnitsByType("Security$ModuleSecurity") - if err != nil { - return nil, err - } - - var result []*security.ModuleSecurity - for _, u := range units { - ms, err := r.parseModuleSecurity(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse module security %s: %w", u.ID, err) - } - result = append(result, ms) - } - - return result, nil -} - -// ListConsumedODataServices returns all consumed OData services in the project. -func (r *Reader) ListConsumedODataServices() ([]*model.ConsumedODataService, error) { - units, err := r.listUnitsByType("Rest$ConsumedODataService") - if err != nil { - return nil, err - } - - var result []*model.ConsumedODataService - for _, u := range units { - svc, err := r.parseConsumedODataService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse consumed OData service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListPublishedODataServices returns all published OData services in the project. -func (r *Reader) ListPublishedODataServices() ([]*model.PublishedODataService, error) { - units, err := r.listUnitsByType("ODataPublish$PublishedODataService2") - if err != nil { - return nil, err - } - - var result []*model.PublishedODataService - for _, u := range units { - svc, err := r.parsePublishedODataService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse published OData service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListPublishedRestServices returns all published REST services in the project. -func (r *Reader) ListPublishedRestServices() ([]*model.PublishedRestService, error) { - units, err := r.listUnitsByType("Rest$PublishedRestService") - if err != nil { - return nil, err - } - - var result []*model.PublishedRestService - for _, u := range units { - svc, err := r.parsePublishedRestService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse published REST service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListDataTransformers returns all data transformers in the project. -func (r *Reader) ListDataTransformers() ([]*model.DataTransformer, error) { - units, err := r.listUnitsByType("DataTransformers$DataTransformer") - if err != nil { - return nil, err - } - - var result []*model.DataTransformer - for _, u := range units { - dt, err := r.parseDataTransformer(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse data transformer %s: %w", u.ID, err) - } - result = append(result, dt) - } - - return result, nil -} - -// ListConsumedRestServices returns all consumed REST services in the project. -func (r *Reader) ListConsumedRestServices() ([]*model.ConsumedRestService, error) { - units, err := r.listUnitsByType("Rest$ConsumedRestService") - if err != nil { - return nil, err - } - - var result []*model.ConsumedRestService - for _, u := range units { - svc, err := r.parseConsumedRestService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse consumed REST service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListWorkflows returns all workflows in the project. -func (r *Reader) ListWorkflows() ([]*workflows.Workflow, error) { - units, err := r.listUnitsByType("Workflows$Workflow") - if err != nil { - return nil, err - } - - var result []*workflows.Workflow - for _, u := range units { - wf, err := r.parseWorkflow(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse workflow %s: %w", u.ID, err) - } - result = append(result, wf) - } - - return result, nil -} - -// GetWorkflow retrieves a workflow by ID. -func (r *Reader) GetWorkflow(id model.ID) (*workflows.Workflow, error) { - wfs, err := r.ListWorkflows() - if err != nil { - return nil, err - } - - for _, wf := range wfs { - if wf.ID == id { - return wf, nil - } - } - - return nil, fmt.Errorf("workflow not found: %s", id) -} - -// ListBusinessEventServices returns all business event services in the project. -func (r *Reader) ListBusinessEventServices() ([]*model.BusinessEventService, error) { - units, err := r.listUnitsByType("BusinessEvents$BusinessEventService") - if err != nil { - return nil, err - } - - var result []*model.BusinessEventService - for _, u := range units { - svc, err := r.parseBusinessEventService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse business event service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListDatabaseConnections returns all database connections in the project. -func (r *Reader) ListDatabaseConnections() ([]*model.DatabaseConnection, error) { - units, err := r.listUnitsByType("DatabaseConnector$DatabaseConnection") - if err != nil { - return nil, err - } - - var result []*model.DatabaseConnection - for _, u := range units { - conn, err := r.parseDBConnection(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse database connection %s: %w", u.ID, err) - } - result = append(result, conn) - } - - return result, nil -} - -// GetProjectSettings returns the project settings. -func (r *Reader) GetProjectSettings() (*model.ProjectSettings, error) { - units, err := r.listUnitsByType("Settings$ProjectSettings") - if err != nil { - return nil, err - } - - if len(units) == 0 { - return nil, fmt.Errorf("project settings not found") - } - - return r.parseProjectSettings(units[0].ID, units[0].ContainerID, units[0].Contents) -} - -// GetModuleSecurity returns the module security for a given module ID. -func (r *Reader) GetModuleSecurity(moduleID model.ID) (*security.ModuleSecurity, error) { - allMS, err := r.ListModuleSecurity() - if err != nil { - return nil, err - } - - for _, ms := range allMS { - if ms.ContainerID == moduleID { - return ms, nil - } - } - - return nil, fmt.Errorf("module security not found for module: %s", moduleID) -} - -// ListImportMappings returns all import mapping documents in the project. -func (r *Reader) ListImportMappings() ([]*model.ImportMapping, error) { - units, err := r.listUnitsByType("ImportMappings$ImportMapping") - if err != nil { - return nil, err - } - - var result []*model.ImportMapping - for _, u := range units { - im, err := r.parseImportMapping(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse import mapping %s: %w", u.ID, err) - } - result = append(result, im) - } - return result, nil -} - -// GetImportMappingByQualifiedName retrieves an import mapping by its qualified name (Module.Name). -func (r *Reader) GetImportMappingByQualifiedName(moduleName, name string) (*model.ImportMapping, error) { - all, err := r.ListImportMappings() - if err != nil { - return nil, err - } - - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - - for _, im := range all { - if im.Name == name && moduleMap[im.ContainerID] == moduleName { - return im, nil - } - } - return nil, fmt.Errorf("import mapping %s.%s not found", moduleName, name) -} - -// ListExportMappings returns all export mapping documents in the project. -func (r *Reader) ListExportMappings() ([]*model.ExportMapping, error) { - units, err := r.listUnitsByType("ExportMappings$ExportMapping") - if err != nil { - return nil, err - } - - var result []*model.ExportMapping - for _, u := range units { - em, err := r.parseExportMapping(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse export mapping %s: %w", u.ID, err) - } - result = append(result, em) - } - return result, nil -} - -// GetExportMappingByQualifiedName retrieves an export mapping by its qualified name (Module.Name). -func (r *Reader) GetExportMappingByQualifiedName(moduleName, name string) (*model.ExportMapping, error) { - all, err := r.ListExportMappings() - if err != nil { - return nil, err - } - - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - - for _, em := range all { - if em.Name == name && moduleMap[em.ContainerID] == moduleName { - return em, nil - } - } - return nil, fmt.Errorf("export mapping %s.%s not found", moduleName, name) -} - -// buildContainerModuleNameMap builds a map from any container ID (including folders) -// to the enclosing module name, by walking the containment hierarchy. -// This handles documents nested inside folders within modules. -func (r *Reader) buildContainerModuleNameMap() (map[model.ID]string, error) { - modules, err := r.ListModules() - if err != nil { - return nil, err - } - - // Build module ID → name and module ID set - moduleNames := make(map[model.ID]string, len(modules)) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - // Build container → parent map from all units - units, err := r.ListUnits() - if err != nil { - return nil, err - } - parentOf := make(map[model.ID]model.ID, len(units)) - for _, u := range units { - parentOf[u.ID] = u.ContainerID - } - - // Walk up from any container ID to find the enclosing module name - result := make(map[model.ID]string) - var findModule func(id model.ID) string - findModule = func(id model.ID) string { - if cached, ok := result[id]; ok { - return cached - } - if name, ok := moduleNames[id]; ok { - result[id] = name - return name - } - parent, ok := parentOf[id] - if !ok || parent == id { - return "" - } - name := findModule(parent) - result[id] = name - return name - } - - // Pre-populate for all units so callers just do a single map lookup - for _, u := range units { - findModule(u.ContainerID) - } - - return result, nil -} - -// ListModuleSettings returns all Projects$ModuleSettings documents in the project. -func (r *Reader) ListModuleSettings() ([]*types.ModuleSettings, error) { - units, err := r.listUnitsByType("Projects$ModuleSettings") - if err != nil { - return nil, err - } - var result []*types.ModuleSettings - for _, u := range units { - ms, err := r.parseModuleSettings(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, err - } - result = append(result, ms) - } - return result, nil -} - -// GetModuleSettings returns the Projects$ModuleSettings for the given module ID. -func (r *Reader) GetModuleSettings(moduleID model.ID) (*types.ModuleSettings, error) { - units, err := r.listUnitsByType("Projects$ModuleSettings") - if err != nil { - return nil, err - } - for _, u := range units { - if u.ContainerID == string(moduleID) { - return r.parseModuleSettings(u.ID, u.ContainerID, u.Contents) - } - } - return nil, fmt.Errorf("module settings not found for module: %s", moduleID) -} - -func (r *Reader) parseModuleSettings(id, containerID string, contents []byte) (*types.ModuleSettings, error) { - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal module settings: %w", err) - } - - ms := &types.ModuleSettings{ - ID: model.ID(id), - ContainerID: model.ID(containerID), - ExportLevel: extractString(raw["ExportLevel"]), - ProtectedModuleType: extractString(raw["ProtectedModuleType"]), - Version: extractString(raw["Version"]), - BasedOnVersion: extractString(raw["BasedOnVersion"]), - ExtensionName: extractString(raw["ExtensionName"]), - SolutionIdentifier: extractString(raw["SolutionIdentifier"]), - } - if ms.ExportLevel == "" { - ms.ExportLevel = "Source" - } - if ms.ProtectedModuleType == "" { - ms.ProtectedModuleType = "AddOn" - } - if ms.Version == "" { - ms.Version = "1.0.0" - } - - if arr, ok := raw["JarDependencies"].(bson.A); ok { - for _, item := range arr { - if dep, ok := item.(map[string]any); ok { - jd := parseJarDependency(dep) - if jd != nil { - ms.JarDependencies = append(ms.JarDependencies, jd) - } - } - } - } - - return ms, nil -} - -func parseJarDependency(raw map[string]any) *types.JarDependency { - if raw["$Type"] == nil { - return nil - } - jd := &types.JarDependency{ - ID: model.ID(extractBsonID(raw["$ID"])), - GroupID: extractString(raw["GroupId"]), - ArtifactID: extractString(raw["ArtifactId"]), - Version: extractString(raw["Version"]), - IsIncluded: extractBool(raw["IsIncluded"], true), - } - if excArr, ok := raw["Exclusions"].(bson.A); ok { - for _, item := range excArr { - if excRaw, ok := item.(map[string]any); ok { - exc := parseJarDependencyExclusion(excRaw) - if exc != nil { - jd.Exclusions = append(jd.Exclusions, exc) - } - } - } - } - return jd -} - -func parseJarDependencyExclusion(raw map[string]any) *types.JarDependencyExclusion { - if raw["$Type"] == nil { - return nil - } - return &types.JarDependencyExclusion{ - ID: model.ID(extractBsonID(raw["$ID"])), - GroupID: extractString(raw["GroupId"]), - ArtifactID: extractString(raw["ArtifactId"]), - } -} - -// ListMenuDocuments returns all standalone Menus$MenuDocument documents. -// -// A menu document holds its entries in a Menus$MenuItemCollection rather than -// directly, but the entries themselves are ordinary Menus$MenuItem elements, so -// the recursive conversion reuses parseNavMenuItem. -func (r *Reader) ListMenuDocuments() ([]*types.MenuDocument, error) { - units, err := r.listUnitsByType("Menus$MenuDocument") - if err != nil { - return nil, err - } - - result := make([]*types.MenuDocument, 0, len(units)) - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - return nil, fmt.Errorf("failed to parse menu document %s: %w", u.ID, err) - } - md := &types.MenuDocument{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - Name: extractString(raw["Name"]), - Documentation: extractString(raw["Documentation"]), - ExportLevel: extractString(raw["ExportLevel"]), - } - if b, ok := raw["Excluded"].(bool); ok { - md.Excluded = b - } - if coll, ok := raw["ItemCollection"].(map[string]any); ok { - for _, item := range extractBsonArray(coll["Items"]) { - if m, ok := item.(map[string]any); ok { - if mi := parseNavMenuItem(m); mi != nil { - md.Items = append(md.Items, mi) - } - } - } - } - result = append(result, md) - } - return result, nil -} - -// GetMenuDocumentByQualifiedName finds a menu document by module + name. -func (r *Reader) GetMenuDocumentByQualifiedName(moduleName, name string) (*types.MenuDocument, error) { - all, err := r.ListMenuDocuments() - if err != nil { - return nil, err - } - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - for _, md := range all { - if md.Name == name && moduleMap[md.ContainerID] == moduleName { - return md, nil - } - } - return nil, fmt.Errorf("menu not found: %s.%s", moduleName, name) -} diff --git a/sdk/mpr/reader_types.go b/sdk/mpr/reader_types.go deleted file mode 100644 index 9cbbaf54d7..0000000000 --- a/sdk/mpr/reader_types.go +++ /dev/null @@ -1,451 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Reader methods for listing and querying model units. -package mpr - -import ( - "encoding/json" - "fmt" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// Type aliases for backward compatibility — these types are now defined in mdl/types. -type ( - JavaAction = types.JavaAction - JavaScriptAction = types.JavaScriptAction - NavigationDocument = types.NavigationDocument - NavigationProfile = types.NavigationProfile - NavHomePage = types.NavHomePage - NavRoleBasedHome = types.NavRoleBasedHome - NavMenuItem = types.NavMenuItem - NavOfflineEntity = types.NavOfflineEntity - JsonStructure = types.JsonStructure - JsonElement = types.JsonElement - ImageCollection = types.ImageCollection - Image = types.Image - FolderInfo = types.FolderInfo - UnitInfo = types.UnitInfo - RawUnit = types.RawUnit - ProjectVersion = types.ProjectVersion -) - -// ListJavaActions returns all Java actions in the project, including virtual System module actions. -func (r *Reader) ListJavaActions() ([]*types.JavaAction, error) { - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - var result []*types.JavaAction - for _, u := range units { - ja, err := r.parseJavaAction(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse java action %s: %w", u.ID, err) - } - result = append(result, ja) - } - - // Append virtual System module Java actions (not stored in the MPR database) - result = append(result, BuildSystemJavaActions()...) - - return result, nil -} - -// ListJavaScriptActions returns all JavaScript actions in the project. -func (r *Reader) ListJavaScriptActions() ([]*types.JavaScriptAction, error) { - units, err := r.listUnitsByType("JavaScriptActions$JavaScriptAction") - if err != nil { - return nil, err - } - - var result []*types.JavaScriptAction - for _, u := range units { - jsa, err := r.parseJavaScriptAction(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse javascript action %s: %w", u.ID, err) - } - result = append(result, jsa) - } - - return result, nil -} - -// ListBuildingBlocks returns all building blocks in the project. -func (r *Reader) ListBuildingBlocks() ([]*pages.BuildingBlock, error) { - // Try Pages$BuildingBlock first (current storage name), then Forms$BuildingBlock (older versions) - units, err := r.listUnitsByType("Pages$BuildingBlock") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Forms$BuildingBlock") - if err != nil { - return nil, err - } - } - - var result []*pages.BuildingBlock - for _, u := range units { - bb, err := r.parseBuildingBlock(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse building block %s: %w", u.ID, err) - } - result = append(result, bb) - } - - return result, nil -} - -// ListPageTemplates returns all page templates in the project. -func (r *Reader) ListPageTemplates() ([]*pages.PageTemplate, error) { - units, err := r.listUnitsByType("Forms$PageTemplate") - if err != nil { - return nil, err - } - - var result []*pages.PageTemplate - for _, u := range units { - pt, err := r.parsePageTemplate(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse page template %s: %w", u.ID, err) - } - result = append(result, pt) - } - - return result, nil -} - -// ListNavigationDocuments returns all navigation documents in the project. -func (r *Reader) ListNavigationDocuments() ([]*types.NavigationDocument, error) { - units, err := r.listUnitsByType("Navigation$NavigationDocument") - if err != nil { - return nil, err - } - - var result []*types.NavigationDocument - for _, u := range units { - nav, err := r.parseNavigationDocument(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse navigation document %s: %w", u.ID, err) - } - result = append(result, nav) - } - - return result, nil -} - -// GetNavigation returns the project's navigation document (singleton). -func (r *Reader) GetNavigation() (*types.NavigationDocument, error) { - docs, err := r.ListNavigationDocuments() - if err != nil { - return nil, err - } - if len(docs) == 0 { - return nil, fmt.Errorf("no navigation document found") - } - return docs[0], nil -} - -// ListImageCollections returns all image collections in the project. -func (r *Reader) ListImageCollections() ([]*types.ImageCollection, error) { - units, err := r.listUnitsByType("Images$ImageCollection") - if err != nil { - return nil, err - } - - var result []*types.ImageCollection - for _, u := range units { - ic, err := r.parseImageCollection(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse image collection %s: %w", u.ID, err) - } - result = append(result, ic) - } - - return result, nil -} - -// ListIconCollections returns all icon collections (CustomIcons$ -// CustomIconCollection) in the project — read-only, for SHOW / DESCRIBE ICON -// COLLECTION. Mirrors the modelsdk backend's reader. -func (r *Reader) ListIconCollections() ([]*types.IconCollection, error) { - units, err := r.listUnitsByType("CustomIcons$CustomIconCollection") - if err != nil { - return nil, err - } - var result []*types.IconCollection - for _, u := range units { - var doc bson.M - if err := bson.Unmarshal(u.Contents, &doc); err != nil { - return nil, fmt.Errorf("failed to parse icon collection %s: %w", u.ID, err) - } - ic := &types.IconCollection{ContainerID: model.ID(u.ContainerID)} - ic.ID = model.ID(u.ID) - ic.TypeName = "CustomIcons$CustomIconCollection" - ic.Name, _ = doc["Name"].(string) - ic.Prefix, _ = doc["Prefix"].(string) - ic.Documentation, _ = doc["Documentation"].(string) - ic.ExportLevel, _ = doc["ExportLevel"].(string) - if arr, ok := doc["Icons"].(bson.A); ok { - for _, el := range arr { - iconDoc, ok := el.(bson.M) - if !ok { - continue - } - item := types.IconItem{} - item.Name, _ = iconDoc["Name"].(string) - switch cc := iconDoc["CharacterCode"].(type) { - case int32: - item.CharacterCode = int(cc) - case int64: - item.CharacterCode = int(cc) - } - if tags, ok := iconDoc["Tags"].(bson.A); ok { - for _, t := range tags { - if s, ok := t.(string); ok { - item.Tags = append(item.Tags, s) - } - } - } - ic.Icons = append(ic.Icons, item) - } - } - result = append(result, ic) - } - return result, nil -} - -// ListXmlSchemas returns all XML schema documents in the project. -// -// Read directly from the raw unit rather than through a parser, because the two -// fields anything needs — Name and FilePath — are top-level strings and the -// element tree is not something mxcli reads. Verified against mxbuild 11.13.0 by -// planting a synthetic XmlSchemas$XmlSchema unit carrying exactly these keys: a -// mapping's `with xml schema` reference to it stopped being CE1613 "no longer -// exists" and became CE0292 "Please import an XSD file", which is mxbuild -// naming the document it found. -func (r *Reader) ListXmlSchemas() ([]*types.XmlSchema, error) { - units, err := r.listUnitsByType("XmlSchemas$XmlSchema") - if err != nil { - return nil, err - } - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - result := make([]*types.XmlSchema, 0, len(units)) - for _, u := range units { - var raw map[string]interface{} - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - return nil, fmt.Errorf("failed to parse XML schema %s: %w", u.ID, err) - } - xs := &types.XmlSchema{ - ContainerID: model.ID(u.ContainerID), - Module: moduleMap[model.ID(u.ContainerID)], - } - xs.ID = model.ID(u.ID) - xs.TypeName = "XmlSchemas$XmlSchema" - if v, ok := raw["Name"].(string); ok { - xs.Name = v - } - if v, ok := raw["Documentation"].(string); ok { - xs.Documentation = v - } - if v, ok := raw["FilePath"].(string); ok { - xs.FilePath = v - } - result = append(result, xs) - } - return result, nil -} - -// ListJsonStructures returns all JSON structures in the project. -func (r *Reader) ListJsonStructures() ([]*types.JsonStructure, error) { - units, err := r.listUnitsByType("JsonStructures$JsonStructure") - if err != nil { - return nil, err - } - - var result []*types.JsonStructure - for _, u := range units { - js, err := r.parseJsonStructure(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse JSON structure %s: %w", u.ID, err) - } - result = append(result, js) - } - - return result, nil -} - -// GetJsonStructureByQualifiedName retrieves a JSON structure by its qualified name (Module.Name). -// Resolves folder containment: the stored ContainerID may be a folder inside -// the module, not the module itself, so we map container IDs through the -// module hierarchy before matching on module name. -func (r *Reader) GetJsonStructureByQualifiedName(moduleName, name string) (*types.JsonStructure, error) { - all, err := r.ListJsonStructures() - if err != nil { - return nil, err - } - - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - - for _, js := range all { - if js.Name == name && moduleMap[js.ContainerID] == moduleName { - return js, nil - } - } - return nil, fmt.Errorf("JSON structure %s.%s not found", moduleName, name) -} - -// ListRawUnitsByType returns all raw units matching the given type prefix, -// including their BSON contents. This is useful for scanning BSON directly -// without full parsing. -func (r *Reader) ListRawUnitsByType(typePrefix string) ([]*types.RawUnit, error) { - units, err := r.listUnitsByType(typePrefix) - if err != nil { - return nil, err - } - - var result []*types.RawUnit - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - result = append(result, &types.RawUnit{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - Type: u.Type, - Contents: contents, - }) - } - return result, nil -} - -// ListUnits returns all units with their IDs and types. -func (r *Reader) ListUnits() ([]*types.UnitInfo, error) { - units, err := r.listUnitsByType("") - if err != nil { - return nil, err - } - - var result []*types.UnitInfo - for _, u := range units { - result = append(result, &types.UnitInfo{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - ContainmentName: u.ContainmentName, - Type: u.Type, - }) - } - - return result, nil -} - -// ListFolders returns all project folders with their names. -func (r *Reader) ListFolders() ([]*types.FolderInfo, error) { - units, err := r.listUnitsByType("Projects$Folder") - if err != nil { - return nil, err - } - - var result []*types.FolderInfo - for _, u := range units { - name := "" - if len(u.Contents) > 0 { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err == nil { - if n, ok := raw["Name"].(string); ok { - name = n - } - } - } - result = append(result, &types.FolderInfo{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - Name: name, - }) - } - - return result, nil -} - -// ExportJSON exports the entire model as JSON. -func (r *Reader) ExportJSON() ([]byte, error) { - modules, err := r.ListModules() - if err != nil { - modules = nil // Continue even if modules fail - } - - domainModels, err := r.ListDomainModels() - if err != nil { - domainModels = nil - } - - microflowsList, err := r.ListMicroflows() - if err != nil { - microflowsList = nil - } - - nanoflows, err := r.ListNanoflows() - if err != nil { - nanoflows = nil - } - - pagesList, err := r.ListPages() - if err != nil { - pagesList = nil - } - - layouts, err := r.ListLayouts() - if err != nil { - layouts = nil - } - - enumerations, err := r.ListEnumerations() - if err != nil { - enumerations = nil - } - - constants, err := r.ListConstants() - if err != nil { - constants = nil - } - - export := map[string]any{ - "modules": modules, - "domainModels": domainModels, - "microflows": microflowsList, - "nanoflows": nanoflows, - "pages": pagesList, - "layouts": layouts, - "enumerations": enumerations, - "constants": constants, - } - - return json.MarshalIndent(export, "", " ") -} - -// GetUnitTypes returns a count of units by type. -func (r *Reader) GetUnitTypes() (map[string]int, error) { - units, err := r.listUnitsByType("") - if err != nil { - return nil, err - } - - counts := make(map[string]int) - for _, u := range units { - counts[u.Type]++ - } - - return counts, nil -} diff --git a/sdk/mpr/reader_units.go b/sdk/mpr/reader_units.go deleted file mode 100644 index 6f7c455f78..0000000000 --- a/sdk/mpr/reader_units.go +++ /dev/null @@ -1,684 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Unit listing infrastructure for Reader. -package mpr - -import ( - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "go.mongodb.org/mongo-driver/bson" -) - -// resolveModuleName walks the container hierarchy upward until it finds a module. -// This is necessary because in MPR v2 projects, documents live inside folders, -// so a document's direct ContainerID is a folder, not the module. -func resolveModuleName(containerID string, moduleMap map[string]string, containerParent map[string]string) string { - current := containerID - for range 20 { - if name, ok := moduleMap[current]; ok { - return name - } - parent, ok := containerParent[current] - if !ok || parent == current { - break - } - current = parent - } - return "" -} - -// buildContainerParent builds a map of unit ID → parent container ID for hierarchy walking. -func (r *Reader) buildContainerParent() (map[string]string, error) { - units, err := r.ListUnits() - if err != nil { - return nil, err - } - containerParent := make(map[string]string, len(units)) - for _, u := range units { - containerParent[string(u.ID)] = string(u.ContainerID) - } - return containerParent, nil -} - -// rawUnit holds raw unit data from the database. -type rawUnit struct { - ID string - ContainerID string - ContainmentName string - Type string - Contents []byte -} - -// listUnitsByType returns all units of exactly the given storage type. An empty -// typeName returns every unit. -// -// The match is exact, and that is load-bearing rather than incidental: this used -// to be a prefix match, and `Forms$Page` is a prefix of `Forms$PageTemplate`, so -// ListPages swept in all 46 of Atlas_Web_Content's page templates. They then -// described as pages with an empty body — the template's content hangs off -// LayoutCall, which the page path does not read — so `show modules` reported 46 -// pages for a module with none, and anything comparing describe output judged a -// template unchanged without having looked at it. -// -// Mendix storage names nest this way in general (`Forms$Page` / -// `Forms$PageTemplate`), so a prefix match here is a trap for every future type, -// not a one-off. -func (r *Reader) listUnitsByType(typeName string) ([]rawUnit, error) { - if r.version == MPRVersionV2 { - return r.listUnitsByTypeV2(typeName) - } - return r.listUnitsByTypeV1(typeName) -} - -// listUnitsByTypeV1 handles MPR v1 format (contents in database). -func (r *Reader) listUnitsByTypeV1(typeName string) ([]rawUnit, error) { - rows, err := r.db.Query(` - SELECT UnitID, ContainerID, ContainmentName, Contents - FROM Unit - `) - if err != nil { - return nil, fmt.Errorf("failed to query units: %w", err) - } - defer rows.Close() - - var units []rawUnit - for rows.Next() { - var unitID, containerID []byte - var containmentName string - var contents []byte - - if err := rows.Scan(&unitID, &containerID, &containmentName, &contents); err != nil { - return nil, fmt.Errorf("failed to scan unit row: %w", err) - } - - unitType := getTypeFromContents(contents) - if typeName == "" || unitType == typeName { - units = append(units, rawUnit{ - ID: blobToUUID(unitID), - ContainerID: blobToUUID(containerID), - ContainmentName: containmentName, - Type: unitType, - Contents: contents, - }) - } - } - - return units, nil -} - -// listUnitsByTypeV2 handles MPR v2 format (contents in mprcontents folder). -// Uses caching to avoid reading every file for each query. -func (r *Reader) listUnitsByTypeV2(typeName string) ([]rawUnit, error) { - // Build cache if not valid - if !r.unitCacheValid { - if err := r.buildUnitCache(); err != nil { - return nil, err - } - } - - // Filter by type using cache, only read contents for matching units - var units []rawUnit - for _, cu := range r.unitCache { - if typeName == "" || cu.Type == typeName { - // Read contents from mprcontents folder - // Note: cu.ID is already in the correct swapped format from blobToUUID - contents, err := r.readMprContents(cu.ID) - if err != nil { - // Skip units with missing content files - continue - } - - units = append(units, rawUnit{ - ID: cu.ID, - ContainerID: cu.ContainerID, - ContainmentName: cu.ContainmentName, - Type: cu.Type, - Contents: contents, - }) - } - } - - return units, nil -} - -// buildUnitCache reads all unit metadata once and caches it. -func (r *Reader) buildUnitCache() error { - rows, err := r.db.Query(` - SELECT UnitID, ContainerID, ContainmentName - FROM Unit - `) - if err != nil { - return fmt.Errorf("failed to query units: %w", err) - } - defer rows.Close() - - r.unitCache = nil - for rows.Next() { - var unitID, containerID []byte - var containmentName string - - if err := rows.Scan(&unitID, &containerID, &containmentName); err != nil { - return fmt.Errorf("failed to scan unit row: %w", err) - } - - // Convert UnitID to UUID string - unitUUID := blobToUUID(unitID) - - // Read contents to get type (only done once during cache build) - contents, err := r.readMprContents(unitUUID) - if err != nil { - // Skip units with missing content files - continue - } - - typeName := getTypeFromContents(contents) - r.unitCache = append(r.unitCache, cachedUnit{ - ID: blobToUUID(unitID), - ContainerID: blobToUUID(containerID), - ContainmentName: containmentName, - Type: typeName, - }) - } - - r.unitCacheValid = true - return nil -} - -// InvalidateCache marks the unit cache as invalid. -// Should be called after any write operation. -func (r *Reader) InvalidateCache() { - r.unitCacheValid = false - r.nameIndex = nil - r.nameIndexBuilt = false -} - -// readMprContents reads content from the mprcontents folder for v2 format. -// The path is: mprcontents/XX/YY/UUID.mxunit where XX and YY are first two chars of UUID. -func (r *Reader) readMprContents(unitUUID string) ([]byte, error) { - if len(unitUUID) < 4 { - return nil, fmt.Errorf("invalid unit UUID: %s", unitUUID) - } - - // Build path: mprcontents/XX/YY/UUID.mxunit - // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - // First two chars are positions 0-1, next two are positions 2-3 - path := filepath.Join( - r.contentsDir, - unitUUID[0:2], - unitUUID[2:4], - unitUUID+".mxunit", - ) - - return os.ReadFile(path) -} - -// getTypeFromContents extracts the $Type field from BSON contents. -func getTypeFromContents(contents []byte) string { - if len(contents) == 0 { - return "" - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return "" - } - - if typeName, ok := raw["$Type"].(string); ok { - return typeName - } - return "" -} - -// nameIndexEntry records where a named unit lives. -type nameIndexEntry struct { - id string - moduleName string -} - -// nameHeader decodes only the unit's Name field. Decoding into this small -// struct is much cheaper than unmarshalling the whole document into -// map[string]any (no map allocation, far less reflection). -type nameHeader struct { - Name string `bson:"Name"` -} - -// buildUnitNameIndex parses every unit's name once and indexes units by -// "$Type\x00QualifiedName", so per-name lookups are O(1) instead of re-reading -// and re-parsing every unit on each call. Idempotent; invalidated by -// InvalidateCache after writes. -func (r *Reader) buildUnitNameIndex() error { - if r.nameIndexBuilt { - return nil - } - units, err := r.listUnitsByType("") - if err != nil { - return err - } - modules, err := r.ListModules() - if err != nil { - return err - } - moduleMap := make(map[string]string, len(modules)) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - containerParent, err := r.buildContainerParent() - if err != nil { - return err - } - - idx := make(map[string]nameIndexEntry, len(units)) - for _, u := range units { - var h nameHeader - if err := bson.Unmarshal(u.Contents, &h); err != nil || h.Name == "" { - continue - } - moduleName := resolveModuleName(u.ContainerID, moduleMap, containerParent) - qn := h.Name - if moduleName != "" { - qn = moduleName + "." + h.Name - } - idx[u.Type+"\x00"+qn] = nameIndexEntry{id: u.ID, moduleName: moduleName} - } - r.nameIndex = idx - r.nameIndexBuilt = true - return nil -} - -// lookupUnitByName resolves a (type, qualified name) to its unit via the name -// index. Returns (nil, "", nil) when not found. -func (r *Reader) lookupUnitByName(typePrefix, qualifiedName string) (*rawUnit, string, error) { - if err := r.buildUnitNameIndex(); err != nil { - return nil, "", err - } - e, ok := r.nameIndex[typePrefix+"\x00"+qualifiedName] - if !ok { - return nil, "", nil - } - u, err := r.getUnitByID(e.id) - if err != nil { - return nil, "", err - } - return u, e.moduleName, nil -} - -// GetRawMicroflowByName returns the raw BSON contents for a microflow by qualified name. -// Used for debugging to compare serialized data. -func (r *Reader) GetRawMicroflowByName(qualifiedName string) ([]byte, error) { - u, _, err := r.lookupUnitByName("Microflows$Microflow", qualifiedName) - if err != nil { - return nil, err - } - if u == nil { - return nil, fmt.Errorf("microflow not found: %s", qualifiedName) - } - return u.Contents, nil -} - -// RawUnitInfo contains information about a raw unit for BSON debugging. -type RawUnitInfo struct { - ID string - QualifiedName string - Type string - ModuleName string - Contents []byte -} - -// GetRawUnitByName returns the raw BSON contents for a unit by qualified name. -// Supported types: page, entity, microflow, nanoflow, enumeration, association, snippet, constant. -// Used for debugging BSON serialization issues. -func (r *Reader) GetRawUnitByName(objectType, qualifiedName string) (*RawUnitInfo, error) { - var typePrefix string - switch strings.ToLower(objectType) { - case "page": - typePrefix = "Forms$Page" - case "entity": - typePrefix = "DomainModels$Entity" - case "association": - typePrefix = "DomainModels$Association" - case "microflow": - typePrefix = "Microflows$Microflow" - case "nanoflow": - typePrefix = "Microflows$Nanoflow" - case "enumeration": - typePrefix = "Enumerations$Enumeration" - case "snippet": - typePrefix = "Forms$Snippet" - case "layout": - typePrefix = "Forms$Layout" - case "constant": - typePrefix = "Constants$Constant" - case "workflow": - typePrefix = "Workflows$Workflow" - case "imagecollection": - typePrefix = "Images$ImageCollection" - case "javaaction": - typePrefix = "JavaActions$JavaAction" - case "javascriptaction": - typePrefix = "JavaScriptActions$JavaScriptAction" - default: - return nil, fmt.Errorf("unsupported object type: %s", objectType) - } - - // For entities and associations, we need to search within domain models - switch strings.ToLower(objectType) { - case "entity": - return r.getRawEntityByName(qualifiedName) - case "association": - return r.getRawAssociationByName(qualifiedName) - } - - u, moduleName, err := r.lookupUnitByName(typePrefix, qualifiedName) - if err != nil { - return nil, err - } - if u == nil { - return nil, fmt.Errorf("%s not found: %s", objectType, qualifiedName) - } - return &RawUnitInfo{ - ID: u.ID, - QualifiedName: qualifiedName, - Type: u.Type, - ModuleName: moduleName, - Contents: u.Contents, - }, nil -} - -// getRawEntityByName finds an entity within domain models. -func (r *Reader) getRawEntityByName(qualifiedName string) (*RawUnitInfo, error) { - // Split qualified name - parts := strings.Split(qualifiedName, ".") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid entity name: %s (expected Module.Entity)", qualifiedName) - } - targetModule := parts[0] - targetEntity := parts[1] - - // Get domain models - units, err := r.listUnitsByType("DomainModels$DomainModel") - if err != nil { - return nil, err - } - - // Build module name map - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleMap := make(map[string]string) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - - for _, u := range units { - moduleName := moduleMap[u.ContainerID] - if moduleName != targetModule { - continue - } - - // Parse domain model to find entity. - // Unmarshal into bson.D so nested documents remain bson.D (not map[string]interface{}). - var rawD bson.D - if err := bson.Unmarshal(u.Contents, &rawD); err != nil { - continue - } - - var entitiesVal any - for _, field := range rawD { - if field.Key == "Entities" { - entitiesVal = field.Value - break - } - } - - entities, ok := entitiesVal.(bson.A) - if !ok { - continue - } - - // Skip version marker (first element is int32 array type indicator) - for i := 1; i < len(entities); i++ { - entity, ok := entities[i].(bson.D) - if !ok { - continue - } - - for _, field := range entity { - if field.Key == "Name" { - if name, ok := field.Value.(string); ok && name == targetEntity { - // Found the entity - serialize it back to BSON - entityBytes, err := bson.Marshal(entity) - if err != nil { - return nil, err - } - return &RawUnitInfo{ - ID: u.ID, - QualifiedName: qualifiedName, - Type: "DomainModels$Entity", - ModuleName: moduleName, - Contents: entityBytes, - }, nil - } - } - } - } - } - - return nil, fmt.Errorf("entity not found: %s", qualifiedName) -} - -// getRawAssociationByName finds an association within domain models. -func (r *Reader) getRawAssociationByName(qualifiedName string) (*RawUnitInfo, error) { - parts := strings.Split(qualifiedName, ".") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid association name: %s (expected Module.AssociationName)", qualifiedName) - } - targetModule := parts[0] - targetAssoc := parts[1] - - units, err := r.listUnitsByType("DomainModels$DomainModel") - if err != nil { - return nil, err - } - - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleMap := make(map[string]string) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - - for _, u := range units { - moduleName := moduleMap[u.ContainerID] - if moduleName != targetModule { - continue - } - - // Unmarshal into bson.D so nested documents remain bson.D (not map[string]interface{}). - var rawD bson.D - if err := bson.Unmarshal(u.Contents, &rawD); err != nil { - continue - } - - var assocsVal any - for _, field := range rawD { - if field.Key == "Associations" { - assocsVal = field.Value - break - } - } - - assocs, ok := assocsVal.(bson.A) - if !ok { - continue - } - - // Skip version marker (first element is int32 array type indicator) - for i := 1; i < len(assocs); i++ { - assoc, ok := assocs[i].(bson.D) - if !ok { - continue - } - - for _, field := range assoc { - if field.Key == "Name" { - if name, ok := field.Value.(string); ok && name == targetAssoc { - assocBytes, err := bson.Marshal(assoc) - if err != nil { - return nil, err - } - return &RawUnitInfo{ - ID: u.ID, - QualifiedName: qualifiedName, - Type: "DomainModels$Association", - ModuleName: moduleName, - Contents: assocBytes, - }, nil - } - } - } - } - } - - return nil, fmt.Errorf("association not found: %s", qualifiedName) -} - -// ListRawUnits returns all units of a given type for BSON debugging. -func (r *Reader) ListRawUnits(objectType string) ([]*RawUnitInfo, error) { - var typePrefix string - switch strings.ToLower(objectType) { - case "page": - typePrefix = "Forms$Page" - case "microflow": - typePrefix = "Microflows$Microflow" - case "nanoflow": - typePrefix = "Microflows$Nanoflow" - case "enumeration": - typePrefix = "Enumerations$Enumeration" - case "snippet": - typePrefix = "Forms$Snippet" - case "layout": - typePrefix = "Forms$Layout" - case "workflow": - typePrefix = "Workflows$Workflow" - case "imagecollection": - typePrefix = "Images$ImageCollection" - case "": - typePrefix = "" - default: - return nil, fmt.Errorf("unsupported object type: %s", objectType) - } - - units, err := r.listUnitsByType(typePrefix) - if err != nil { - return nil, err - } - - // Build module name map and container hierarchy for MPR v2 folder support. - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleMap := make(map[string]string) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - containerParent, err := r.buildContainerParent() - if err != nil { - return nil, err - } - - var result []*RawUnitInfo - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - moduleName := resolveModuleName(u.ContainerID, moduleMap, containerParent) - fullName := name - if moduleName != "" { - fullName = moduleName + "." + name - } - - result = append(result, &RawUnitInfo{ - ID: u.ID, - QualifiedName: fullName, - Type: u.Type, - ModuleName: moduleName, - Contents: u.Contents, - }) - } - - return result, nil -} - -// getUnitByID fetches a single rawUnit by its UUID string without loading all units. -// Returns (nil, nil) when the ID is not found. -// V1: direct SQLite BLOB lookup — O(1). V2: cache lookup + single file read — O(cache size). -func (r *Reader) getUnitByID(id string) (*rawUnit, error) { - if r.version == MPRVersionV2 { - return r.getUnitByIDV2(id) - } - return r.getUnitByIDV1(id) -} - -func (r *Reader) getUnitByIDV1(id string) (*rawUnit, error) { - blob := types.UUIDToBlob(id) - if blob == nil { - return nil, fmt.Errorf("invalid unit ID: %s", id) - } - row := r.db.QueryRow( - "SELECT UnitID, ContainerID, ContainmentName, Contents FROM Unit WHERE UnitID = ?", - blob, - ) - var unitID, containerID []byte - var containmentName string - var contents []byte - if err := row.Scan(&unitID, &containerID, &containmentName, &contents); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to query unit %s: %w", id, err) - } - return &rawUnit{ - ID: blobToUUID(unitID), - ContainerID: blobToUUID(containerID), - ContainmentName: containmentName, - Type: getTypeFromContents(contents), - Contents: contents, - }, nil -} - -func (r *Reader) getUnitByIDV2(id string) (*rawUnit, error) { - if !r.unitCacheValid { - if err := r.buildUnitCache(); err != nil { - return nil, err - } - } - for _, cu := range r.unitCache { - if cu.ID == id { - contents, err := r.readMprContents(id) - if err != nil { - return nil, err - } - return &rawUnit{ - ID: cu.ID, - ContainerID: cu.ContainerID, - ContainmentName: cu.ContainmentName, - Type: cu.Type, - Contents: contents, - }, nil - } - } - return nil, nil -} diff --git a/sdk/mpr/reader_units_type_test.go b/sdk/mpr/reader_units_type_test.go deleted file mode 100644 index 681d646b4d..0000000000 --- a/sdk/mpr/reader_units_type_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// TestListUnitsByType_MatchesExactly is the regression guard for the page / -// page-template conflation. -// -// listUnitsByType used to match on a type *prefix*, and Mendix storage names -// nest: `Forms$Page` is a prefix of `Forms$PageTemplate`. So ListPages returned -// both, `show modules` reported the fixture's Atlas_Web_Content as having 46 -// pages when it has none, and every one of those templates described as a page -// with an empty body — the template's content hangs off LayoutCall, which the -// page path never reads. Anything comparing describe output therefore judged a -// template unchanged without having looked inside it. -// -// The assertion is deliberately about the *pair*: a test that only counted -// Forms$Page would pass against the prefix match too, because the miscount was -// caused by the other type being swept in. -func TestListUnitsByType_MatchesExactly(t *testing.T) { - r, err := Open(copyProject(t, "../../testdata/expr-checker", "minimal.mpr")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - - pages, err := r.listUnitsByType("Forms$Page") - if err != nil { - t.Fatalf("listUnitsByType(Forms$Page): %v", err) - } - templates, err := r.listUnitsByType("Forms$PageTemplate") - if err != nil { - t.Fatalf("listUnitsByType(Forms$PageTemplate): %v", err) - } - - if len(pages) == 0 || len(templates) == 0 { - t.Fatalf("fixture should hold both types; got %d pages, %d templates", - len(pages), len(templates)) - } - - // Neither query may return a unit of the other type. - for _, u := range pages { - if u.Type != "Forms$Page" { - t.Fatalf("querying Forms$Page returned a %s — the match is by prefix, not exact", u.Type) - } - } - for _, u := range templates { - if u.Type != "Forms$PageTemplate" { - t.Fatalf("querying Forms$PageTemplate returned a %s", u.Type) - } - } - - // And the page query must not be the union of the two. - all, err := r.listUnitsByType("") - if err != nil { - t.Fatalf("listUnitsByType(\"\"): %v", err) - } - if len(all) <= len(pages)+len(templates) { - t.Fatalf("the empty type should return every unit; got %d, with %d pages + %d templates", - len(all), len(pages), len(templates)) - } -} - -// TestListPages_ExcludesPageTemplates checks the symptom the user actually sees, -// one layer up from the cause. -func TestListPages_ExcludesPageTemplates(t *testing.T) { - r, err := Open(copyProject(t, "../../testdata/expr-checker", "minimal.mpr")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - - pageUnits, err := r.listUnitsByType("Forms$Page") - if err != nil { - t.Fatalf("listUnitsByType: %v", err) - } - pages, err := r.ListPages() - if err != nil { - t.Fatalf("ListPages: %v", err) - } - if len(pages) != len(pageUnits) { - t.Errorf("ListPages returned %d pages for %d Forms$Page units — page templates are being counted as pages", - len(pages), len(pageUnits)) - } - - templates, err := r.ListPageTemplates() - if err != nil { - t.Fatalf("ListPageTemplates: %v", err) - } - if len(templates) == 0 { - t.Fatal("page templates must still be readable under their own type") - } - byName := make(map[string]bool, len(pages)) - for _, p := range pages { - byName[p.Name] = true - } - for _, tpl := range templates { - if byName[tpl.Name] { - t.Errorf("%q is reported as both a page and a page template", tpl.Name) - } - } -} diff --git a/sdk/mpr/reader_widgets.go b/sdk/mpr/reader_widgets.go deleted file mode 100644 index 4b06051c8f..0000000000 --- a/sdk/mpr/reader_widgets.go +++ /dev/null @@ -1,747 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Widget template functionality for Reader. -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// RawCustomWidgetType holds the raw BSON data for a CustomWidgetType -// extracted from an existing widget in the project. -type RawCustomWidgetType struct { - WidgetID string // e.g., "com.mendix.widget.web.combobox.Combobox" - RawType bson.D // The full Type field as bson.D - RawObject bson.D // The full Object field as bson.D (WidgetObject with all properties) - UnitID string // ID of the unit where this was found - UnitName string // Name of the page/snippet (for identification) - WidgetName string // Name of the widget (from Name field) -} - -// FindCustomWidgetType searches for an existing CustomWidget with the given -// widgetID and returns its full Type definition as raw BSON. This can be used -// as a template for creating new widgets of the same type. -func (r *Reader) FindCustomWidgetType(widgetID string) (*RawCustomWidgetType, error) { - // Search through all pages for a CustomWidget with the matching widgetID - units, err := r.listUnitsByType("Forms$Page") - if err != nil { - return nil, err - } - - // Also check snippets - snippetUnits, err := r.listUnitsByType("Forms$Snippet") - if err == nil { - units = append(units, snippetUnits...) - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - // Quick check if this unit might contain the widget - if !containsWidgetID(contents, widgetID) { - continue - } - - // Parse and extract the widget type and object - rawType, rawObject := extractWidgetTypeAndObject(contents, widgetID) - if rawType != nil { - return &RawCustomWidgetType{ - WidgetID: widgetID, - RawType: rawType, - RawObject: rawObject, - UnitID: u.ID, - }, nil - } - } - - return nil, nil // Not found -} - -// FindAllCustomWidgetTypes searches for ALL CustomWidgets with the given -// widgetID and returns their full Type/Object definitions as raw BSON. -// This allows identification of different configurations of the same widget type. -func (r *Reader) FindAllCustomWidgetTypes(widgetID string) ([]*RawCustomWidgetType, error) { - var results []*RawCustomWidgetType - - // Search through all pages - units, err := r.listUnitsByType("Forms$Page") - if err != nil { - return nil, err - } - - // Also check snippets - snippetUnits, err := r.listUnitsByType("Forms$Snippet") - if err == nil { - units = append(units, snippetUnits...) - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - // Quick check if this unit might contain the widget - if !containsWidgetID(contents, widgetID) { - continue - } - - // Get the unit name for identification - unitName := extractUnitName(contents) - - // Parse and extract ALL widgets of this type from this unit - var doc bson.D - if err := bson.Unmarshal(contents, &doc); err != nil { - continue - } - - widgets := findAllCustomWidgets(doc, widgetID) - for _, w := range widgets { - results = append(results, &RawCustomWidgetType{ - WidgetID: widgetID, - RawType: w.rawType, - RawObject: w.rawObject, - UnitID: u.ID, - UnitName: unitName, - WidgetName: w.name, - }) - } - } - - return results, nil -} - -// extractUnitName extracts the Name field from a BSON document. -func extractUnitName(contents []byte) string { - var doc bson.D - if err := bson.Unmarshal(contents, &doc); err != nil { - return "" - } - for _, elem := range doc { - if elem.Key == "Name" { - if name, ok := elem.Value.(string); ok { - return name - } - } - } - return "" -} - -// widgetInfo holds extracted widget data. -type widgetInfo struct { - rawType bson.D - rawObject bson.D - name string -} - -// findAllCustomWidgets recursively searches for ALL CustomWidgets with the given widgetID. -func findAllCustomWidgets(doc bson.D, widgetID string) []widgetInfo { - var results []widgetInfo - - // Check if this document is a CustomWidget with matching widgetID - isCustomWidget := false - var typeDoc, objectDoc bson.D - var widgetName string - - for _, elem := range doc { - if elem.Key == "$Type" && elem.Value == "CustomWidgets$CustomWidget" { - isCustomWidget = true - } - if elem.Key == "Type" { - if t, ok := elem.Value.(bson.D); ok { - typeDoc = t - } - } - if elem.Key == "Object" { - if o, ok := elem.Value.(bson.D); ok { - objectDoc = o - } - } - if elem.Key == "Name" { - if n, ok := elem.Value.(string); ok { - widgetName = n - } - } - } - - // If this is a CustomWidget with matching widgetID, add to results - if isCustomWidget && typeDoc != nil && matchesWidgetID(typeDoc, widgetID) { - results = append(results, widgetInfo{ - rawType: typeDoc, - rawObject: objectDoc, - name: widgetName, - }) - } - - // Recursively search nested documents - for _, elem := range doc { - switch v := elem.Value.(type) { - case bson.D: - results = append(results, findAllCustomWidgets(v, widgetID)...) - case bson.A: - results = append(results, findAllCustomWidgetsInArray(v, widgetID)...) - } - } - - return results -} - -// findAllCustomWidgetsInArray searches an array for CustomWidgets. -func findAllCustomWidgetsInArray(arr bson.A, widgetID string) []widgetInfo { - var results []widgetInfo - for _, item := range arr { - switch v := item.(type) { - case bson.D: - results = append(results, findAllCustomWidgets(v, widgetID)...) - case bson.A: - results = append(results, findAllCustomWidgetsInArray(v, widgetID)...) - } - } - return results -} - -// GetPropertyValue extracts a property value from a RawObject by property key. -func (r *RawCustomWidgetType) GetPropertyValue(propertyKey string) string { - if r.RawObject == nil { - return "" - } - for _, elem := range r.RawObject { - if elem.Key == "Properties" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if prop, ok := item.(bson.D); ok { - propKey := getPropertyKey(prop) - if propKey == propertyKey { - return getPrimitiveValue(prop) - } - } - } - } - } - } - return "" -} - -// getPropertyKey extracts the property key from a WidgetProperty. -func getPropertyKey(prop bson.D) string { - for _, elem := range prop { - if elem.Key == "TypePointer" { - // We can't easily map TypePointer to PropertyKey without the Type - // So let's look for it differently - } - } - // Check Value for the property type info - for _, elem := range prop { - if elem.Key == "Value" { - if val, ok := elem.Value.(bson.D); ok { - for _, ve := range val { - if ve.Key == "$Type" { - // The type hints at what property this is - return ve.Value.(string) - } - } - } - } - } - return "" -} - -// getPrimitiveValue extracts the PrimitiveValue from a WidgetProperty. -func getPrimitiveValue(prop bson.D) string { - for _, elem := range prop { - if elem.Key == "Value" { - if val, ok := elem.Value.(bson.D); ok { - for _, ve := range val { - if ve.Key == "PrimitiveValue" { - if pv, ok := ve.Value.(string); ok { - return pv - } - } - } - } - } - } - return "" -} - -// GetAllPrimitiveValues returns all non-empty PrimitiveValue fields from the RawObject. -func (r *RawCustomWidgetType) GetAllPrimitiveValues() []string { - if r.RawObject == nil { - return nil - } - var values []string - for _, elem := range r.RawObject { - if elem.Key == "Properties" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if prop, ok := item.(bson.D); ok { - if pv := getPrimitiveValue(prop); pv != "" { - values = append(values, pv) - } - } - } - } - } - } - return values -} - -// containsWidgetID does a quick string check to see if the BSON might contain the widget. -func containsWidgetID(contents []byte, widgetID string) bool { - return strings.Contains(string(contents), widgetID) -} - -// extractWidgetTypeAndObject parses BSON and extracts both the CustomWidgetType and WidgetObject -// for the given widgetID. This allows cloning the complete widget with all its property values. -func extractWidgetTypeAndObject(contents []byte, widgetID string) (bson.D, bson.D) { - var doc bson.D - if err := bson.Unmarshal(contents, &doc); err != nil { - return nil, nil - } - - // Recursively search for CustomWidget with matching widgetID - return findCustomWidget(doc, widgetID) -} - -// findCustomWidget recursively searches for a CustomWidget with the given widgetID -// and returns both its Type and Object fields. -func findCustomWidget(doc bson.D, widgetID string) (bson.D, bson.D) { - // Check if this document is a CustomWidget with matching widgetID - isCustomWidget := false - var typeDoc, objectDoc bson.D - - for _, elem := range doc { - if elem.Key == "$Type" && elem.Value == "CustomWidgets$CustomWidget" { - isCustomWidget = true - } - if elem.Key == "Type" { - if t, ok := elem.Value.(bson.D); ok { - typeDoc = t - } - } - if elem.Key == "Object" { - if o, ok := elem.Value.(bson.D); ok { - objectDoc = o - } - } - } - - // If this is a CustomWidget with matching widgetID, return its Type and Object - if isCustomWidget && typeDoc != nil && matchesWidgetID(typeDoc, widgetID) { - return typeDoc, objectDoc - } - - // Recursively search nested documents - for _, elem := range doc { - switch v := elem.Value.(type) { - case bson.D: - if t, o := findCustomWidget(v, widgetID); t != nil { - return t, o - } - case bson.A: - if t, o := findCustomWidgetInArray(v, widgetID); t != nil { - return t, o - } - } - } - - return nil, nil -} - -// findCustomWidgetInArray searches an array for a CustomWidget. -func findCustomWidgetInArray(arr bson.A, widgetID string) (bson.D, bson.D) { - for _, item := range arr { - switch v := item.(type) { - case bson.D: - if t, o := findCustomWidget(v, widgetID); t != nil { - return t, o - } - case bson.A: - if t, o := findCustomWidgetInArray(v, widgetID); t != nil { - return t, o - } - } - } - return nil, nil -} - -// matchesWidgetID checks if a BSON document is a CustomWidgetType with the given widgetID. -func matchesWidgetID(doc bson.D, widgetID string) bool { - hasCorrectType := false - hasCorrectWidgetID := false - - for _, elem := range doc { - if elem.Key == "$Type" && elem.Value == "CustomWidgets$CustomWidgetType" { - hasCorrectType = true - } - if elem.Key == "WidgetId" && elem.Value == widgetID { - hasCorrectWidgetID = true - } - } - - return hasCorrectType && hasCorrectWidgetID -} - -// IDMapping tracks the mapping from old IDs to new IDs during cloning. -type IDMapping struct { - OldToNewID map[string]string // Maps old ID -> new ID for all elements - PropertyTypeIDs map[string]pages.PropertyTypeIDEntry // Maps PropertyKey -> PropertyTypeID/ValueTypeID - ObjectTypeID string // The cloned ObjectType ID -} - -// CloneWidgetType creates a deep copy of the widget type with all IDs regenerated. -// It returns a mapping from old PropertyType keys to new PropertyType IDs and ValueType IDs, -// as well as the ObjectType ID which is needed for the WidgetObject's TypePointer. -func CloneWidgetType(rawType bson.D) (cloned bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - mapping := &IDMapping{ - OldToNewID: make(map[string]string), - PropertyTypeIDs: make(map[string]pages.PropertyTypeIDEntry), - } - cloned = cloneDocWithNewIDs(rawType, mapping) - return cloned, mapping.PropertyTypeIDs, mapping.ObjectTypeID -} - -// CloneCustomWidgetType is an alias for CloneWidgetType for clarity. -func CloneCustomWidgetType(rawType bson.D) (cloned bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - return CloneWidgetType(rawType) -} - -// CloneWidgetObject creates a deep copy of a WidgetObject with all IDs regenerated. -// The idMapping is used to update TypePointers to reference the new IDs from the cloned Type. -func CloneWidgetObject(rawObject bson.D, idMapping map[string]string) bson.D { - if rawObject == nil { - return nil - } - return cloneObjectWithNewIDs(rawObject, idMapping) -} - -// CloneCustomWidget clones both the Type and Object of a CustomWidget. -// Returns the cloned Type, cloned Object, PropertyType IDs map, and ObjectType ID. -func CloneCustomWidget(rawType, rawObject bson.D) (clonedType, clonedObject bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - mapping := &IDMapping{ - OldToNewID: make(map[string]string), - PropertyTypeIDs: make(map[string]pages.PropertyTypeIDEntry), - } - - // Clone the Type first to build the ID mapping - clonedType = cloneDocWithNewIDs(rawType, mapping) - - // Clone the Object using the ID mapping to update TypePointers - if rawObject != nil { - clonedObject = cloneObjectWithNewIDs(rawObject, mapping.OldToNewID) - } - - return clonedType, clonedObject, mapping.PropertyTypeIDs, mapping.ObjectTypeID -} - -// ExtractPropertyTypeIDs extracts PropertyType IDs from a widget type WITHOUT regenerating IDs. -// This is used when creating new widget instances that reference an EXISTING widget type in the project. -// The TypePointers in the new instance must use the ORIGINAL IDs from the project's widget type. -func ExtractPropertyTypeIDs(rawType bson.D) (propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - propertyTypeIDs = make(map[string]pages.PropertyTypeIDEntry) - extractPropertyTypeIDsFromDoc(rawType, propertyTypeIDs, &objectTypeID) - return propertyTypeIDs, objectTypeID -} - -// extractPropertyTypeIDsFromDoc recursively extracts PropertyType/ValueType IDs without regenerating them. -func extractPropertyTypeIDsFromDoc(doc bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID *string) { - var currentPropertyKey string - var currentID string - var currentValueTypeID string - var currentDefaultValue string - var currentValueType string - var currentObjectTypeID string - var currentNestedPropertyIDs map[string]pages.PropertyTypeIDEntry - var docType string - - // First pass: collect all values from this document - for _, elem := range doc { - switch elem.Key { - case "$Type": - if t, ok := elem.Value.(string); ok { - docType = t - } - case "$ID": - if binID, ok := elem.Value.(primitive.Binary); ok { - currentID = blobToUUID(binID.Data) - } - case "PropertyKey": - if key, ok := elem.Value.(string); ok { - currentPropertyKey = key - } - case "ValueType": - if nested, ok := elem.Value.(bson.D); ok { - currentNestedPropertyIDs = make(map[string]pages.PropertyTypeIDEntry) - extractValueTypeInfo(nested, ¤tValueTypeID, ¤tDefaultValue, ¤tValueType, ¤tObjectTypeID, currentNestedPropertyIDs) - } - } - } - - // After collecting values, determine what type this is and record IDs - isPropertyType := docType == "CustomWidgets$WidgetPropertyType" - isObjectType := docType == "CustomWidgets$WidgetObjectType" - - if isObjectType && currentID != "" { - *objectTypeID = currentID - } - - // Record PropertyType entry - if isPropertyType && currentPropertyKey != "" { - propertyTypeIDs[currentPropertyKey] = pages.PropertyTypeIDEntry{ - PropertyTypeID: currentID, // Use the ID we collected - ValueTypeID: currentValueTypeID, - DefaultValue: currentDefaultValue, - ValueType: currentValueType, - ObjectTypeID: currentObjectTypeID, - NestedPropertyIDs: currentNestedPropertyIDs, - } - } - - // Second pass: recurse into nested documents and arrays - for _, elem := range doc { - if elem.Key == "ValueType" { - continue // Already processed - } - if nested, ok := elem.Value.(bson.D); ok { - extractPropertyTypeIDsFromDoc(nested, propertyTypeIDs, objectTypeID) - } - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if nested, ok := item.(bson.D); ok { - extractPropertyTypeIDsFromDoc(nested, propertyTypeIDs, objectTypeID) - } - } - } - } -} - -// extractValueTypeInfo extracts ValueType ID, default value, value type, and nested ObjectType info. -func extractValueTypeInfo(doc bson.D, valueTypeID, defaultValue, valueType *string, objectTypeID *string, nestedPropertyIDs map[string]pages.PropertyTypeIDEntry) { - for _, elem := range doc { - if elem.Key == "$ID" { - if binID, ok := elem.Value.(primitive.Binary); ok { - *valueTypeID = blobToUUID(binID.Data) - } - } - if elem.Key == "DefaultValue" { - if dv, ok := elem.Value.(string); ok { - *defaultValue = dv - } - } - if elem.Key == "Type" { - if vt, ok := elem.Value.(string); ok { - *valueType = vt - } - } - if elem.Key == "ObjectType" { - if nested, ok := elem.Value.(bson.D); ok { - extractObjectTypeInfo(nested, objectTypeID, nestedPropertyIDs) - } - } - } -} - -// extractObjectTypeInfo extracts ObjectType ID and its nested PropertyType IDs. -func extractObjectTypeInfo(doc bson.D, objectTypeID *string, nestedPropertyIDs map[string]pages.PropertyTypeIDEntry) { - var dummyObjectTypeID string - for _, elem := range doc { - if elem.Key == "$ID" { - if binID, ok := elem.Value.(primitive.Binary); ok { - *objectTypeID = blobToUUID(binID.Data) - } - } - if elem.Key == "PropertyTypes" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if propType, ok := item.(bson.D); ok { - extractPropertyTypeIDsFromDoc(propType, nestedPropertyIDs, &dummyObjectTypeID) - } - } - } - } - } -} - -// cloneDocWithNewIDs recursively clones a BSON document with regenerated IDs. -// It builds an ID mapping and tracks PropertyType/ValueType IDs. -func cloneDocWithNewIDs(doc bson.D, mapping *IDMapping) bson.D { - result := make(bson.D, 0, len(doc)) - - // First pass: check if this is a PropertyType or ObjectType and extract its key and old ID - var currentPropertyKey string - var currentPropertyTypeID string - var currentValueTypeID string - var oldID string - isPropertyType := false - isObjectType := false - isValueType := false - - for _, elem := range doc { - if elem.Key == "$Type" { - switch elem.Value { - case "CustomWidgets$WidgetPropertyType": - isPropertyType = true - case "CustomWidgets$WidgetObjectType": - isObjectType = true - case "CustomWidgets$WidgetValueType": - isValueType = true - } - } - if elem.Key == "PropertyKey" { - if key, ok := elem.Value.(string); ok { - currentPropertyKey = key - } - } - if elem.Key == "$ID" { - if binID, ok := elem.Value.(primitive.Binary); ok { - oldID = blobToUUID(binID.Data) - } - } - } - - // Clone each element - for _, elem := range doc { - newElem := bson.E{Key: elem.Key} - - if elem.Key == "$ID" { - // Generate new ID and record the mapping - newID := generateUUID() - newElem.Value = idToBsonBinary(newID) - - // Store the old -> new ID mapping - if oldID != "" { - mapping.OldToNewID[oldID] = newID - } - - // Track PropertyType and ValueType IDs - if isPropertyType { - currentPropertyTypeID = newID - } - if isValueType { - currentValueTypeID = newID - } - // Track ObjectType ID for WidgetObject reference - if isObjectType { - mapping.ObjectTypeID = newID - } - } else { - // Clone the value - switch v := elem.Value.(type) { - case bson.D: - // Recursively clone nested document - clonedNested := cloneDocWithNewIDs(v, mapping) - newElem.Value = clonedNested - - // If this is a ValueType, extract its new ID - if elem.Key == "ValueType" { - for _, e := range clonedNested { - if e.Key == "$ID" { - if binID, ok := e.Value.(primitive.Binary); ok { - currentValueTypeID = blobToUUID(binID.Data) - } - break - } - } - } - case bson.A: - newElem.Value = cloneArrayWithNewIDs(v, mapping) - default: - newElem.Value = v - } - } - - result = append(result, newElem) - } - - // Record PropertyType IDs - if isPropertyType && currentPropertyKey != "" { - mapping.PropertyTypeIDs[currentPropertyKey] = pages.PropertyTypeIDEntry{ - PropertyTypeID: currentPropertyTypeID, - ValueTypeID: currentValueTypeID, - } - } - - return result -} - -// cloneArrayWithNewIDs recursively clones a BSON array with regenerated IDs. -func cloneArrayWithNewIDs(arr bson.A, mapping *IDMapping) bson.A { - result := make(bson.A, len(arr)) - for i, item := range arr { - switch v := item.(type) { - case bson.D: - result[i] = cloneDocWithNewIDs(v, mapping) - case bson.A: - result[i] = cloneArrayWithNewIDs(v, mapping) - default: - result[i] = v - } - } - return result -} - -// cloneObjectWithNewIDs clones a WidgetObject with new IDs, updating TypePointers -// to reference the new IDs from the cloned Type. -func cloneObjectWithNewIDs(doc bson.D, idMapping map[string]string) bson.D { - result := make(bson.D, 0, len(doc)) - - for _, elem := range doc { - newElem := bson.E{Key: elem.Key} - - if elem.Key == "$ID" { - // Generate new ID for the object itself - newID := generateUUID() - newElem.Value = idToBsonBinary(newID) - } else if elem.Key == "TypePointer" { - // Update TypePointer to reference the new ID from the cloned Type - if binID, ok := elem.Value.(primitive.Binary); ok { - oldID := blobToUUID(binID.Data) - if newID, found := idMapping[oldID]; found { - newElem.Value = idToBsonBinary(newID) - } else { - // Keep the original if not found in mapping - newElem.Value = elem.Value - } - } else { - newElem.Value = elem.Value - } - } else { - // Clone the value - switch v := elem.Value.(type) { - case bson.D: - newElem.Value = cloneObjectWithNewIDs(v, idMapping) - case bson.A: - newElem.Value = cloneObjectArrayWithNewIDs(v, idMapping) - default: - newElem.Value = v - } - } - - result = append(result, newElem) - } - - return result -} - -// cloneObjectArrayWithNewIDs clones an array within a WidgetObject. -func cloneObjectArrayWithNewIDs(arr bson.A, idMapping map[string]string) bson.A { - result := make(bson.A, len(arr)) - for i, item := range arr { - switch v := item.(type) { - case bson.D: - result[i] = cloneObjectWithNewIDs(v, idMapping) - case bson.A: - result[i] = cloneObjectArrayWithNewIDs(v, idMapping) - default: - result[i] = v - } - } - return result -} diff --git a/sdk/mpr/reader_xmlschema_test.go b/sdk/mpr/reader_xmlschema_test.go deleted file mode 100644 index 759f676e95..0000000000 --- a/sdk/mpr/reader_xmlschema_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// ListXmlSchemas reads the two fields a mapping's `with xml schema` reference -// needs — the document's Name and its owning module — from XmlSchemas$XmlSchema -// units (ako/mxcli#259). -// -// The type string and the Name key are not guesses. They were established -// against mxbuild 11.13.0 by planting a synthetic unit carrying exactly the keys -// this test writes: a mapping referring to it stopped being -// -// [error] [CE1613] "The selected XML schema 'XGap.Probe_Xsd' no longer exists." -// -// and became -// -// [error] [CE0292] "Please import an XSD file." at XML schema 'XGap.Probe_Xsd' -// -// — mxbuild naming, by module and name, the document it had just found. (The -// second error is expected: the synthetic schema carries no XSD contents.) The -// same string is what modelsdk/gen/xmlschemas registers with the codec and what -// modelsdk/gen/mappings/refs.go names as the reference target. -func TestListXmlSchemasReadsNameAndModule(t *testing.T) { - writer, _ := newTestWriterV1(t, unitTableSchemaV1) - - const moduleID = "22222222-2222-2222-2222-222222222222" - writeUnit(t, writer, moduleID, "", "Modules", "Projects$ModuleImpl", bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "Name", Value: "XGap"}, - }) - writeUnit(t, writer, "33333333-3333-3333-3333-333333333333", moduleID, "Documents", - "XmlSchemas$XmlSchema", bson.D{ - {Key: "$Type", Value: "XmlSchemas$XmlSchema"}, - {Key: "Documentation", Value: "orders"}, - {Key: "Entries", Value: bson.A{int32(2)}}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "FilePath", Value: "orders.xsd"}, - {Key: "Name", Value: "Orders_Xsd"}, - }) - - got, err := writer.reader.ListXmlSchemas() - if err != nil { - t.Fatalf("ListXmlSchemas: %v", err) - } - if len(got) != 1 { - t.Fatalf("got %d schemas, want 1", len(got)) - } - if got[0].Name != "Orders_Xsd" { - t.Errorf("Name = %q, want Orders_Xsd", got[0].Name) - } - // The module is what makes the reference check module-aware; without it, - // `A.Orders_Xsd` would resolve against `B.Orders_Xsd`. - if got[0].Module != "XGap" { - t.Errorf("Module = %q, want XGap", got[0].Module) - } - if got[0].FilePath != "orders.xsd" { - t.Errorf("FilePath = %q, want orders.xsd", got[0].FilePath) - } -} - -// TestListXmlSchemasIgnoresOtherDocuments is the control: the type filter has to -// be doing the work, not the fact that the fixture holds only one document. -func TestListXmlSchemasIgnoresOtherDocuments(t *testing.T) { - writer, _ := newTestWriterV1(t, unitTableSchemaV1) - - const moduleID = "22222222-2222-2222-2222-222222222222" - writeUnit(t, writer, moduleID, "", "Modules", "Projects$ModuleImpl", bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "Name", Value: "XGap"}, - }) - writeUnit(t, writer, "44444444-4444-4444-4444-444444444444", moduleID, "Documents", - "JsonStructures$JsonStructure", bson.D{ - {Key: "$Type", Value: "JsonStructures$JsonStructure"}, - {Key: "Name", Value: "JSON_Orders"}, - }) - - got, err := writer.reader.ListXmlSchemas() - if err != nil { - t.Fatalf("ListXmlSchemas: %v", err) - } - if len(got) != 0 { - t.Fatalf("got %d schemas, want 0: %+v", len(got), got) - } -} - -const unitTableSchemaV1 = ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) -` - -func writeUnit(t *testing.T, w *Writer, unitID, containerID, containment, unitType string, doc bson.D) { - t.Helper() - contents, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal %s: %v", unitType, err) - } - if err := w.insertUnit(unitID, containerID, containment, unitType, contents); err != nil { - t.Fatalf("insertUnit %s: %v", unitType, err) - } -} diff --git a/sdk/mpr/regularexpressions.go b/sdk/mpr/regularexpressions.go deleted file mode 100644 index a4fcc87075..0000000000 --- a/sdk/mpr/regularexpressions.go +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "go.mongodb.org/mongo-driver/bson" - - regex "github.com/mendixlabs/mxcli/mdl/regularexpressions" - "github.com/mendixlabs/mxcli/model" -) - -// Regular expressions for the legacy engine. The document shape lives in -// mdl/regularexpressions so both engines write exactly the same bytes. - -// ListRegularExpressions reads every regular expression in the project. -func (r *Reader) ListRegularExpressions() ([]*model.RegularExpression, error) { - units, err := r.ListRawUnitsByType(regex.TypeName) - if err != nil { - return nil, err - } - out := make([]*model.RegularExpression, 0, len(units)) - for _, u := range units { - var doc bson.M - if err := bson.Unmarshal(u.Contents, &doc); err != nil { - return nil, fmt.Errorf("unmarshal regular expression %s: %w", u.ID, err) - } - out = append(out, regex.Parse(doc, model.ID(u.ID), model.ID(u.ContainerID))) - } - return out, nil -} - -// CreateRegularExpression inserts a new regular expression document. -func (w *Writer) CreateRegularExpression(re *model.RegularExpression) error { - if re == nil { - return fmt.Errorf("CreateRegularExpression: nil regular expression") - } - if re.ID == "" { - re.ID = model.ID(generateUUID()) - } - contents, err := regex.Serialize(re) - if err != nil { - return err - } - return w.insertUnit(string(re.ID), string(re.ContainerID), "Documents", regex.TypeName, contents) -} - -// UpdateRegularExpression rewrites an existing regular expression in place. -func (w *Writer) UpdateRegularExpression(re *model.RegularExpression) error { - if re == nil { - return fmt.Errorf("UpdateRegularExpression: nil regular expression") - } - contents, err := regex.Serialize(re) - if err != nil { - return err - } - return w.UpdateRawUnit(string(re.ID), contents) -} - -// DeleteRegularExpression removes a regular expression by ID. -func (w *Writer) DeleteRegularExpression(id string) error { - return w.deleteUnit(id) -} diff --git a/sdk/mpr/roundtrip_test.go b/sdk/mpr/roundtrip_test.go deleted file mode 100644 index 8d361d78cd..0000000000 --- a/sdk/mpr/roundtrip_test.go +++ /dev/null @@ -1,600 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "os" - "path/filepath" - "strings" - "testing" - - bsondebug "github.com/mendixlabs/mxcli/cmd/mxcli/bson" - "go.mongodb.org/mongo-driver/bson" -) - -// testReader creates a minimal Reader for roundtrip tests (no database needed). -func testReader() *Reader { - return &Reader{version: MPRVersionV1} -} - -// testWriter creates a minimal Writer for roundtrip tests (no database needed). -func testWriter() *Writer { - return &Writer{reader: testReader()} -} - -// toNDSL unmarshals raw BSON bytes and renders as Normalized DSL text. -func toNDSL(t *testing.T, data []byte) string { - t.Helper() - var doc bson.D - if err := bson.Unmarshal(data, &doc); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - return bsondebug.Render(doc, 0) -} - -// roundtripPage: baseline → parse → serialize → parse → serialize → compare two serializations. -// Verifies serialization idempotency. Original baseline is preserved as ground truth. -func roundtripPage(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - // First pass: baseline → parse → serialize - page1, err := r.parsePage("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parsePage (pass 1) failed: %v", err) - } - serialized1, err := w.serializePage(page1) - if err != nil { - t.Fatalf("serializePage (pass 1) failed: %v", err) - } - - // Second pass: serialized → parse → serialize - page2, err := r.parsePage("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parsePage (pass 2) failed: %v", err) - } - serialized2, err := w.serializePage(page2) - if err != nil { - t.Fatalf("serializePage (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for page %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - page1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripMicroflow: baseline → parse → serialize → parse → serialize → compare two serializations. -func roundtripMicroflow(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - // First pass - mf1, err := r.parseMicroflow("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseMicroflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeMicroflow(mf1) - if err != nil { - t.Fatalf("serializeMicroflow (pass 1) failed: %v", err) - } - - // Second pass - mf2, err := r.parseMicroflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseMicroflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeMicroflow(mf2) - if err != nil { - t.Fatalf("serializeMicroflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for microflow %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - mf1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripNanoflow: baseline → parse → serialize → parse → serialize → compare two serializations. -func roundtripNanoflow(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - // First pass - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseNanoflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow (pass 1) failed: %v", err) - } - - // Second pass - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for nanoflow %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - nf1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripSnippet: double roundtrip idempotency test. -func roundtripSnippet(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - snippet1, err := r.parseSnippet("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseSnippet (pass 1) failed: %v", err) - } - serialized1, err := w.serializeSnippet(snippet1) - if err != nil { - t.Fatalf("serializeSnippet (pass 1) failed: %v", err) - } - - snippet2, err := r.parseSnippet("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseSnippet (pass 2) failed: %v", err) - } - serialized2, err := w.serializeSnippet(snippet2) - if err != nil { - t.Fatalf("serializeSnippet (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for snippet %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - snippet1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripEnumeration: double roundtrip idempotency test. -func roundtripEnumeration(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - enum1, err := r.parseEnumeration("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseEnumeration (pass 1) failed: %v", err) - } - serialized1, err := w.serializeEnumeration(enum1) - if err != nil { - t.Fatalf("serializeEnumeration (pass 1) failed: %v", err) - } - - enum2, err := r.parseEnumeration("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseEnumeration (pass 2) failed: %v", err) - } - serialized2, err := w.serializeEnumeration(enum2) - if err != nil { - t.Fatalf("serializeEnumeration (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for enumeration %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - enum1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// TestRoundtrip_Pages runs roundtrip tests on all page baselines in testdata/. -func TestRoundtrip_Pages(t *testing.T) { - runRoundtripDir(t, "testdata/pages", roundtripPage) -} - -// TestRoundtrip_Microflows runs roundtrip tests on all microflow baselines. -func TestRoundtrip_Microflows(t *testing.T) { - runRoundtripDir(t, "testdata/microflows", roundtripMicroflow) -} - -// TestRoundtrip_Nanoflows runs roundtrip tests on all nanoflow baselines. -func TestRoundtrip_Nanoflows(t *testing.T) { - runRoundtripDir(t, "testdata/nanoflows", roundtripNanoflow) -} - -// TestRoundtrip_Nanoflow_Synthetic tests parse→serialize→parse idempotency -// using programmatically constructed BSON (no .mxunit baseline needed). -func TestRoundtrip_Nanoflow_Synthetic(t *testing.T) { - r := testReader() - w := testWriter() - - tests := []struct { - name string - doc bson.D - }{ - { - name: "minimal_void", - doc: bson.D{ - {Key: "$ID", Value: "nf-test-1"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3)}}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "Name", Value: "NF_Minimal"}, - }, - }, - { - name: "with_return_type", - doc: bson.D{ - {Key: "$ID", Value: "nf-test-2"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3)}}, - {Key: "Documentation", Value: "A nanoflow that returns a string"}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: true}, - {Key: "MicroflowReturnType", Value: bson.D{ - {Key: "$ID", Value: "rt-1"}, - {Key: "$Type", Value: "Datatypes$StringType"}, - }}, - {Key: "Name", Value: "NF_WithReturn"}, - }, - }, - { - name: "with_parameters", - doc: bson.D{ - {Key: "$ID", Value: "nf-test-3"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3), "role-1", "role-2"}}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "Name", Value: "NF_WithParams"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "param-1"}, - {Key: "$Type", Value: "Microflows$MicroflowParameter"}, - {Key: "Name", Value: "Input"}, - {Key: "Documentation", Value: ""}, - {Key: "HasWidgetUsages", Value: false}, - {Key: "RelativeMiddlePoint", Value: bson.D{ - {Key: "$ID", Value: "rmp-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectRelativeMiddlePoint"}, - {Key: "X", Value: int32(0)}, - {Key: "Y", Value: int32(0)}, - }}, - {Key: "Size", Value: bson.D{ - {Key: "$ID", Value: "sz-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectSize"}, - {Key: "Width", Value: int32(30)}, - {Key: "Height", Value: int32(30)}, - }}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: "vt-1"}, - {Key: "$Type", Value: "Datatypes$StringType"}, - }}, - }, - }}, - }}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseline, err := bson.Marshal(tt.doc) - if err != nil { - t.Fatalf("failed to marshal synthetic BSON: %v", err) - } - - // First pass: parse → serialize - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baseline) - if err != nil { - t.Fatalf("parseNanoflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow (pass 1) failed: %v", err) - } - - // Second pass: serialized → parse → serialize - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent:\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } - - // Verify basic fields survived - if expectedName, ok := tt.doc.Map()["Name"].(string); ok { - if nf1.Name != expectedName { - t.Errorf("Name mismatch: got %q, want %q", nf1.Name, expectedName) - } - } - }) - } -} - -// TestRoundtrip_Nanoflow_WithActivities tests parse→serialize→parse idempotency -// for a nanoflow with ObjectCollection containing activities and flows. -func TestRoundtrip_Nanoflow_WithActivities(t *testing.T) { - r := testReader() - w := testWriter() - - doc := bson.D{ - {Key: "$ID", Value: "nf-act-1"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3), "role-admin", "role-user"}}, - {Key: "Documentation", Value: "Nanoflow with activities"}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "sf-1"}, - {Key: "$Type", Value: "Microflows$SequenceFlow"}, - {Key: "OriginConnectionIndex", Value: int32(0)}, - {Key: "DestinationConnectionIndex", Value: int32(0)}, - {Key: "OriginBezierVector", Value: bson.D{ - {Key: "$ID", Value: "bv-1"}, - {Key: "$Type", Value: "Microflows$BezierVector"}, - {Key: "X", Value: 0.0}, - {Key: "Y", Value: 0.0}, - }}, - {Key: "DestinationBezierVector", Value: bson.D{ - {Key: "$ID", Value: "bv-2"}, - {Key: "$Type", Value: "Microflows$BezierVector"}, - {Key: "X", Value: 0.0}, - {Key: "Y", Value: 0.0}, - }}, - }, - }}, - {Key: "MarkAsUsed", Value: true}, - {Key: "MicroflowReturnType", Value: bson.D{ - {Key: "$ID", Value: "rt-act"}, - {Key: "$Type", Value: "Datatypes$IntegerType"}, - }}, - {Key: "Name", Value: "NF_WithActivities"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-act"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "start-1"}, - {Key: "$Type", Value: "Microflows$StartEvent"}, - {Key: "RelativeMiddlePoint", Value: bson.D{ - {Key: "$ID", Value: "rmp-s"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectRelativeMiddlePoint"}, - {Key: "X", Value: int32(100)}, - {Key: "Y", Value: int32(100)}, - }}, - {Key: "Size", Value: bson.D{ - {Key: "$ID", Value: "sz-s"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectSize"}, - {Key: "Width", Value: int32(20)}, - {Key: "Height", Value: int32(20)}, - }}, - }, - bson.D{ - {Key: "$ID", Value: "end-1"}, - {Key: "$Type", Value: "Microflows$EndEvent"}, - {Key: "RelativeMiddlePoint", Value: bson.D{ - {Key: "$ID", Value: "rmp-e"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectRelativeMiddlePoint"}, - {Key: "X", Value: int32(400)}, - {Key: "Y", Value: int32(100)}, - }}, - {Key: "Size", Value: bson.D{ - {Key: "$ID", Value: "sz-e"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectSize"}, - {Key: "Width", Value: int32(20)}, - {Key: "Height", Value: int32(20)}, - }}, - {Key: "ReturnValue", Value: ""}, - }, - }}, - }}, - } - - baseline, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal synthetic BSON: %v", err) - } - - // First pass - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baseline) - if err != nil { - t.Fatalf("parseNanoflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow (pass 1) failed: %v", err) - } - - // Second pass - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent:\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } - - // Verify AllowedModuleRoles survived - if len(nf1.AllowedModuleRoles) != 2 { - t.Errorf("Expected 2 AllowedModuleRoles, got %d", len(nf1.AllowedModuleRoles)) - } - - // Verify ObjectCollection survived - if nf1.ObjectCollection == nil { - t.Error("Expected ObjectCollection to be parsed") - } - - // Verify name survived - if nf1.Name != "NF_WithActivities" { - t.Errorf("Name mismatch: got %q", nf1.Name) - } -} - -// TestRoundtrip_Nanoflow_EmptyObjectCollection tests a nanoflow with an empty ObjectCollection. -func TestRoundtrip_Nanoflow_EmptyObjectCollection(t *testing.T) { - r := testReader() - w := testWriter() - - doc := bson.D{ - {Key: "$ID", Value: "nf-empty-oc"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3)}}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "Name", Value: "NF_EmptyOC"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-empty"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{int32(3)}}, - }}, - } - - baseline, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baseline) - if err != nil { - t.Fatalf("parseNanoflow failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow failed: %v", err) - } - - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent:\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s", ndsl1, ndsl2) - } -} - -// TestRoundtrip_Snippets runs roundtrip tests on all snippet baselines. -func TestRoundtrip_Snippets(t *testing.T) { - runRoundtripDir(t, "testdata/snippets", roundtripSnippet) -} - -// TestRoundtrip_Enumerations runs roundtrip tests on all enumeration baselines. -func TestRoundtrip_Enumerations(t *testing.T) { - runRoundtripDir(t, "testdata/enumerations", roundtripEnumeration) -} - -// runRoundtripDir loads all .mxunit files from a directory and runs the given roundtrip function. -func runRoundtripDir(t *testing.T, dir string, fn func(*testing.T, []byte)) { - t.Helper() - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - t.Skipf("no baseline directory: %s", dir) - return - } - t.Fatalf("failed to read directory %s: %v", dir, err) - } - - count := 0 - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".mxunit") { - continue - } - count++ - name := strings.TrimSuffix(entry.Name(), ".mxunit") - t.Run(name, func(t *testing.T) { - data, err := os.ReadFile(filepath.Join(dir, entry.Name())) - if err != nil { - t.Fatalf("failed to read baseline: %v", err) - } - fn(t, data) - }) - } - if count == 0 { - t.Skipf("no .mxunit baselines in %s", dir) - } -} - -// ndslDiff returns a simple line-by-line diff of two NDSL strings. -func ndslDiff(a, b string) string { - linesA := strings.Split(a, "\n") - linesB := strings.Split(b, "\n") - - var diffs []string - maxLen := len(linesA) - if len(linesB) > maxLen { - maxLen = len(linesB) - } - - for i := 0; i < maxLen; i++ { - la, lb := "", "" - if i < len(linesA) { - la = linesA[i] - } - if i < len(linesB) { - lb = linesB[i] - } - if la != lb { - diffs = append(diffs, "- "+la) - diffs = append(diffs, "+ "+lb) - } - } - return strings.Join(diffs, "\n") -} diff --git a/sdk/mpr/scheduledevents.go b/sdk/mpr/scheduledevents.go deleted file mode 100644 index 21aaa1fb01..0000000000 --- a/sdk/mpr/scheduledevents.go +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - sched "github.com/mendixlabs/mxcli/mdl/scheduledevents" - "github.com/mendixlabs/mxcli/model" -) - -// Scheduled events (ScheduledEvents$ScheduledEvent) for the legacy engine. -// -// The document shape lives in mdl/scheduledevents so both engines write exactly -// the same bytes — the schedule child has eight variants that differ in which -// fields they carry, and two copies of that dispatch would eventually disagree. -// -// The legacy READER for scheduled events is parseScheduledEvent (in -// parser_enumeration.go), which predates this and covers only the flat legacy -// fields; the write path here round-trips through the shared codec. - -// CreateScheduledEvent inserts a new scheduled event document. -func (w *Writer) CreateScheduledEvent(ev *model.ScheduledEvent) error { - if ev == nil { - return fmt.Errorf("CreateScheduledEvent: nil event") - } - if ev.ID == "" { - ev.ID = model.ID(generateUUID()) - } - contents, err := sched.Serialize(ev) - if err != nil { - return err - } - return w.insertUnit(string(ev.ID), string(ev.ContainerID), "Documents", sched.TypeName, contents) -} - -// UpdateScheduledEvent rewrites an existing scheduled event in place. -func (w *Writer) UpdateScheduledEvent(ev *model.ScheduledEvent) error { - if ev == nil { - return fmt.Errorf("UpdateScheduledEvent: nil event") - } - contents, err := sched.Serialize(ev) - if err != nil { - return err - } - return w.UpdateRawUnit(string(ev.ID), contents) -} - -// DeleteScheduledEvent removes a scheduled event by ID. -func (w *Writer) DeleteScheduledEvent(id string) error { - return w.deleteUnit(id) -} diff --git a/sdk/mpr/showpage_roundtrip_test.go b/sdk/mpr/showpage_roundtrip_test.go deleted file mode 100644 index 607669af81..0000000000 --- a/sdk/mpr/showpage_roundtrip_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "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" -) - -// TestShowPageAction_Roundtrip verifies that a ShowPageAction with parameters -// survives BSON serialization/deserialization. -func TestShowPageAction_Roundtrip(t *testing.T) { - // Build a ShowPageAction with parameter mappings - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - PageParameterMappings: []*microflows.PageParameterMapping{ - { - BaseElement: model.BaseElement{ID: "test-mapping-id"}, - Parameter: "Sales.Product_NewEdit.Product", - Argument: "$Product", - }, - }, - } - - // Serialize to BSON using the writer - doc := serializeMicroflowAction(action) - - // Marshal to bytes (simulates writing to MPR) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - // Unmarshal back to map (simulates reading from MPR) - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - // Parse using the parser - parsed := parseShowPageAction(raw) - - // Verify page name - if parsed.PageName != "Sales.Product_NewEdit" { - t.Errorf("PageName = %q, want %q", parsed.PageName, "Sales.Product_NewEdit") - } - - // Verify parameter mappings - if len(parsed.PageParameterMappings) != 1 { - t.Fatalf("PageParameterMappings count = %d, want 1", len(parsed.PageParameterMappings)) - } - pm := parsed.PageParameterMappings[0] - if pm.Parameter != "Sales.Product_NewEdit.Product" { - t.Errorf("Parameter = %q, want %q", pm.Parameter, "Sales.Product_NewEdit.Product") - } - if pm.Argument != "$Product" { - t.Errorf("Argument = %q, want %q", pm.Argument, "$Product") - } -} - -func TestShowPageAction_WritesValidPageParameterMapping(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - PageParameterMappings: []*microflows.PageParameterMapping{ - { - BaseElement: model.BaseElement{ID: "test-mapping-id"}, - Parameter: "Sales.Product_NewEdit.Product", - Argument: "$Product", - }, - }, - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - formSettings := toMap(raw["FormSettings"]) - if formSettings == nil { - t.Fatal("FormSettings missing") - } - - mappings, ok := formSettings["ParameterMappings"].(primitive.A) - if !ok { - t.Fatalf("ParameterMappings type = %T, want primitive.A", formSettings["ParameterMappings"]) - } - if len(mappings) != 2 { - t.Fatalf("ParameterMappings length = %d, want marker plus one mapping", len(mappings)) - } - if marker, ok := mappings[0].(int32); !ok || marker != 2 { - t.Fatalf("ParameterMappings marker = %#v, want int32(2)", mappings[0]) - } - - mapping := toMap(mappings[1]) - if mapping == nil { - t.Fatal("PageParameterMapping missing") - } - variable := toMap(mapping["Variable"]) - if variable == nil { - t.Fatal("Variable is nil; Studio Pro rejects null page parameter mapping variables") - } - if got := extractString(variable["$Type"]); got != "Forms$PageVariable" { - t.Fatalf("Variable $Type = %q, want Forms$PageVariable", got) - } -} - -// TestShowPageAction_RoundtripNoParams verifies that a ShowPageAction without parameters -// survives BSON serialization/deserialization. -func TestShowPageAction_RoundtripNoParams(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Customer_Overview", - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - parsed := parseShowPageAction(raw) - - if parsed.PageName != "Sales.Customer_Overview" { - t.Errorf("PageName = %q, want %q", parsed.PageName, "Sales.Customer_Overview") - } - if len(parsed.PageParameterMappings) != 0 { - t.Errorf("PageParameterMappings count = %d, want 0", len(parsed.PageParameterMappings)) - } -} - -// TestShowPageAction_TitleOverride_IsNull replaces an earlier test that asserted the -// opposite. That test reasoned by analogy — "same class of bug as -// FormSettings.ParameterMappings.Variable — issue #295" — but #295 was about -// Forms$PageVariable, a different field, and the conclusion was generalised to -// TitleOverride without ever being observed. -// -// The evidence runs the other way. Studio Pro writes TitleOverride null: a scan of one -// project found 58 correct popups (Studio Pro / marketplace) with null against 10 -// broken ones (mxcli) with an empty template, and this repo's own -// .claude/skills/debug-bson.md documents `{Key: "TitleOverride", Value: nil}` as the -// correct Forms$FormSettings shape. An empty Microflows$TextTemplate is not the -// absence of an override — it overrides the title with the empty string, so every such -// popup rendered with a blank caption and only the close button (#812). -func TestShowPageAction_TitleOverride_IsNull(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - formSettings := toMap(raw["FormSettings"]) - if formSettings == nil { - t.Fatal("FormSettings missing") - } - - raw2, ok := formSettings["TitleOverride"] - if !ok { - t.Fatal("TitleOverride key missing entirely; Studio Pro writes it as an explicit null") - } - if raw2 != nil { - t.Fatalf("TitleOverride = %#v, want nil — an empty template overrides the page "+ - "title with the empty string, blanking the popup caption (#812)", raw2) - } - - // ...and when the action DOES override the title, the authored text must survive. - // Before #812 the empty template was written either way, so this half was silently - // dropped: OverridePageTitle was set by the builder and read by nothing. - withTitle := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - OverridePageTitle: &model.Text{Translations: map[string]string{"en_US": "Edit Product"}}, - } - data2, err := bson.Marshal(serializeMicroflowAction(withTitle)) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - var rawDoc2 map[string]any - if err := bson.Unmarshal(data2, &rawDoc2); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - to := toMap(toMap(rawDoc2["FormSettings"])["TitleOverride"]) - if to == nil { - t.Fatal("an explicitly authored title override was dropped (#812)") - } - if got := extractString(to["$Type"]); got != "Microflows$TextTemplate" { - t.Fatalf("TitleOverride.$Type = %q, want %q", got, "Microflows$TextTemplate") - } -} - -// TestShowPageAction_RoundtripMultipleParams verifies multiple parameter mappings survive roundtrip. -func TestShowPageAction_RoundtripMultipleParams(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Order_Detail", - PageParameterMappings: []*microflows.PageParameterMapping{ - { - BaseElement: model.BaseElement{ID: "mapping-1"}, - Parameter: "Sales.Order_Detail.Order", - Argument: "$Order", - }, - { - BaseElement: model.BaseElement{ID: "mapping-2"}, - Parameter: "Sales.Order_Detail.Customer", - Argument: "$Customer", - }, - }, - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - parsed := parseShowPageAction(raw) - - if parsed.PageName != "Sales.Order_Detail" { - t.Errorf("PageName = %q, want %q", parsed.PageName, "Sales.Order_Detail") - } - if len(parsed.PageParameterMappings) != 2 { - t.Fatalf("PageParameterMappings count = %d, want 2", len(parsed.PageParameterMappings)) - } - if parsed.PageParameterMappings[0].Argument != "$Order" { - t.Errorf("first Argument = %q, want %q", parsed.PageParameterMappings[0].Argument, "$Order") - } - if parsed.PageParameterMappings[1].Argument != "$Customer" { - t.Errorf("second Argument = %q, want %q", parsed.PageParameterMappings[1].Argument, "$Customer") - } -} diff --git a/sdk/mpr/system_java_actions.go b/sdk/mpr/system_java_actions.go deleted file mode 100644 index e15ad6fefd..0000000000 --- a/sdk/mpr/system_java_actions.go +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -// The System module's built-in Java actions now live in modelsdk/meta, beside -// the virtual System module's entities and associations. This package keeps the -// two names it exported so its own callers are unaffected, and delegates — two -// copies of a hand-maintained platform list is exactly how the two readers -// would come to disagree about what the System module contains. - -import ( - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/modelsdk/meta" - "github.com/mendixlabs/mxcli/sdk/javaactions" -) - -// BuildSystemJavaActions returns lightweight types.JavaAction entries for the System module. -func BuildSystemJavaActions() []*types.JavaAction { return meta.BuildSystemJavaActions() } - -// BuildSystemJavaActionsFull returns fully-typed javaactions.JavaAction entries for the System module. -func BuildSystemJavaActionsFull() []*javaactions.JavaAction { - return meta.BuildSystemJavaActionsFull() -} diff --git a/sdk/mpr/system_module.go b/sdk/mpr/system_module.go deleted file mode 100644 index ac9903191c..0000000000 --- a/sdk/mpr/system_module.go +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// System module constants — deterministic IDs for the virtual System module. -const ( - SystemModuleID = "00000000-0000-0000-0000-000000000001" - SystemDomainModelID = "00000000-0000-0000-0000-000000000002" -) - -// systemAttrDef defines an attribute in a System entity. -type systemAttrDef struct { - Name string - Type string // "String", "Integer", "Decimal", "Boolean", "DateTime", "Enumeration", "Long", "Binary", "HashedString", "AutoNumber" - Length int // for String type - EnumQN string // for Enumeration type, qualified name -} - -// systemAssocDef defines an association between System entities. -type systemAssocDef struct { - Name string - Parent string // parent entity name (without module prefix) - Child string // child entity name (without module prefix) - Type string // "Reference" or "ReferenceSet" - Owner string // "Default" or "Both" -} - -// systemEntityDef defines a System entity with name, persistability, and attributes. -type systemEntityDef struct { - Name string - Persistable bool - Generalization string // e.g. "System.FileDocument", "System.Error" - Attributes []systemAttrDef -} - -// systemEntities lists all entities in the System module. -// Extracted from Mendix Studio Pro 11.6.4 via DummySystem module. -var systemEntities = []systemEntityDef{ - {Name: "UserRole", Persistable: true, Attributes: []systemAttrDef{ - {Name: "ModelGUID", Type: "String"}, - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - }}, - {Name: "User", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Password", Type: "HashedString"}, - {Name: "LastLogin", Type: "DateTime"}, - {Name: "Blocked", Type: "Boolean"}, - {Name: "BlockedSince", Type: "DateTime"}, - {Name: "Active", Type: "Boolean"}, - {Name: "FailedLogins", Type: "Integer"}, - {Name: "WebServiceUser", Type: "Boolean"}, - {Name: "IsAnonymous", Type: "Boolean"}, - }}, - {Name: "FileDocument", Persistable: true, Attributes: []systemAttrDef{ - {Name: "FileID", Type: "AutoNumber"}, - {Name: "Name", Type: "String"}, - {Name: "DeleteAfterDownload", Type: "Boolean"}, - {Name: "Contents", Type: "Binary"}, - {Name: "HasContents", Type: "Boolean"}, - {Name: "Size", Type: "Long"}, - }}, - {Name: "Image", Persistable: true, Generalization: "System.FileDocument", Attributes: []systemAttrDef{ - {Name: "PublicThumbnailPath", Type: "String"}, - {Name: "EnableCaching", Type: "Boolean"}, - }}, - {Name: "XASInstance", Persistable: true, Attributes: []systemAttrDef{ - {Name: "XASId", Type: "String"}, - {Name: "LastUpdate", Type: "DateTime"}, - {Name: "AllowedNumberOfConcurrentUsers", Type: "Integer"}, - {Name: "PartnerName", Type: "String"}, - {Name: "CustomerName", Type: "String"}, - }}, - {Name: "Session", Persistable: true, Attributes: []systemAttrDef{ - {Name: "SessionId", Type: "String"}, - {Name: "CSRFToken", Type: "String"}, - {Name: "LastActive", Type: "DateTime"}, - }}, - {Name: "ScheduledEventInformation", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Status", Type: "Enumeration", EnumQN: "System.EventStatus"}, - }}, - {Name: "Language", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Code", Type: "String"}, - {Name: "Description", Type: "String"}, - }}, - {Name: "TimeZone", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Code", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "RawOffset", Type: "Integer"}, - }}, - {Name: "Error", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ErrorType", Type: "String"}, - {Name: "Message", Type: "String"}, - {Name: "Stacktrace", Type: "String"}, - }}, - {Name: "SoapFault", Persistable: true, Generalization: "System.Error", Attributes: []systemAttrDef{ - {Name: "Code", Type: "String"}, - {Name: "Reason", Type: "String"}, - {Name: "Node", Type: "String"}, - {Name: "Role", Type: "String"}, - {Name: "Detail", Type: "String"}, - }}, - {Name: "TokenInformation", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Token", Type: "HashedString"}, - {Name: "ExpiryDate", Type: "DateTime"}, - {Name: "UserAgent", Type: "String"}, - }}, - {Name: "HttpMessage", Persistable: false, Attributes: []systemAttrDef{ - {Name: "HttpVersion", Type: "String"}, - {Name: "Content", Type: "String"}, - }}, - {Name: "HttpHeader", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Key", Type: "String"}, - {Name: "Value", Type: "String"}, - }}, - {Name: "UserReportInfo", Persistable: true, Attributes: []systemAttrDef{ - {Name: "UserType", Type: "Enumeration", EnumQN: "System.UserType"}, - {Name: "Hash", Type: "String"}, - }}, - {Name: "HttpRequest", Persistable: true, Generalization: "System.HttpMessage", Attributes: []systemAttrDef{ - {Name: "Uri", Type: "String"}, - }}, - {Name: "HttpResponse", Persistable: true, Generalization: "System.HttpMessage", Attributes: []systemAttrDef{ - {Name: "StatusCode", Type: "Integer"}, - {Name: "ReasonPhrase", Type: "String"}, - }}, - {Name: "Paging", Persistable: false, Attributes: []systemAttrDef{ - {Name: "PageNumber", Type: "Long"}, - {Name: "IsSortable", Type: "Boolean"}, - {Name: "SortAttribute", Type: "String"}, - {Name: "SortAscending", Type: "Boolean"}, - {Name: "HasMoreData", Type: "Boolean"}, - }}, - {Name: "SynchronizationError", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Reason", Type: "String"}, - {Name: "ObjectId", Type: "String"}, - {Name: "ObjectType", Type: "String"}, - {Name: "ObjectContent", Type: "String"}, - }}, - {Name: "SynchronizationErrorFile", Persistable: true, Generalization: "System.FileDocument"}, - {Name: "ProcessedQueueTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Sequence", Type: "Long"}, - {Name: "Status", Type: "Enumeration", EnumQN: "System.QueueTaskStatus"}, - {Name: "QueueId", Type: "String"}, - {Name: "QueueName", Type: "String"}, - {Name: "ContextType", Type: "Enumeration", EnumQN: "System.ContextType"}, - {Name: "ContextData", Type: "String"}, - {Name: "MicroflowName", Type: "String"}, - {Name: "UserActionName", Type: "String"}, - {Name: "Arguments", Type: "String"}, - {Name: "XASId", Type: "String"}, - {Name: "ThreadId", Type: "Long"}, - {Name: "Created", Type: "DateTime"}, - {Name: "StartAt", Type: "DateTime"}, - {Name: "Started", Type: "DateTime"}, - {Name: "Finished", Type: "DateTime"}, - {Name: "Duration", Type: "Long"}, - {Name: "Retried", Type: "Long"}, - {Name: "ErrorMessage", Type: "String"}, - {Name: "ScheduledEventName", Type: "String"}, - }}, - {Name: "QueuedTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Sequence", Type: "AutoNumber"}, - {Name: "Status", Type: "Enumeration", EnumQN: "System.QueueTaskStatus"}, - {Name: "QueueId", Type: "String"}, - {Name: "QueueName", Type: "String"}, - {Name: "ContextType", Type: "Enumeration", EnumQN: "System.ContextType"}, - {Name: "ContextData", Type: "String"}, - {Name: "MicroflowName", Type: "String"}, - {Name: "UserActionName", Type: "String"}, - {Name: "Arguments", Type: "String"}, - {Name: "XASId", Type: "String"}, - {Name: "ThreadId", Type: "Long"}, - {Name: "Created", Type: "DateTime"}, - {Name: "StartAt", Type: "DateTime"}, - {Name: "Started", Type: "DateTime"}, - {Name: "Retried", Type: "Long"}, - {Name: "Retry", Type: "String"}, - {Name: "ScheduledEventName", Type: "String"}, - }}, - {Name: "WorkflowDefinition", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Title", Type: "String"}, - {Name: "IsObsolete", Type: "Boolean"}, - {Name: "IsLocked", Type: "Boolean"}, - }}, - {Name: "WorkflowUserTaskDefinition", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "IsObsolete", Type: "Boolean"}, - }}, - {Name: "Workflow", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "CanBeRestarted", Type: "Boolean"}, - {Name: "CanBeContinued", Type: "Boolean"}, - {Name: "CanApplyJumpTo", Type: "Boolean"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowState"}, - {Name: "Reason", Type: "String"}, - }}, - {Name: "WorkflowUserTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Outcome", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskState"}, - {Name: "CompletionType", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskCompletionType"}, - }}, - {Name: "TaskQueueToken", Persistable: true, Attributes: []systemAttrDef{ - {Name: "QueueName", Type: "String"}, - {Name: "XASId", Type: "String"}, - {Name: "ValidUntil", Type: "DateTime"}, - }}, - {Name: "ODataResponse", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Count", Type: "Long"}, - }}, - {Name: "WorkflowJumpToDetails", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Error", Type: "String"}, - }}, - {Name: "WorkflowCurrentActivity", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Action", Type: "Enumeration", EnumQN: "System.WorkflowCurrentActivityAction"}, - }}, - {Name: "WorkflowActivityDetails", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ActivityId", Type: "String"}, - {Name: "ActivityCaption", Type: "String"}, - {Name: "ActivityType", Type: "Enumeration", EnumQN: "System.WorkflowActivityType"}, - {Name: "ExistsInCurrentVersion", Type: "Boolean"}, - }}, - {Name: "WorkflowUserTaskOutcome", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Outcome", Type: "String"}, - {Name: "Time", Type: "DateTime"}, - }}, - {Name: "WorkflowRecord", Persistable: false, Attributes: []systemAttrDef{ - {Name: "WorkflowKey", Type: "String"}, - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowState"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Reason", Type: "String"}, - }}, - {Name: "WorkflowActivityRecord", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ModelGUID", Type: "String"}, - {Name: "ActivityKey", Type: "String"}, - {Name: "PreviousActivityKey", Type: "String"}, - {Name: "ActivityType", Type: "Enumeration", EnumQN: "System.WorkflowActivityType"}, - {Name: "Caption", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowActivityExecutionState"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Outcome", Type: "String"}, - {Name: "MicroflowName", Type: "String"}, - {Name: "TaskName", Type: "String"}, - {Name: "TaskDescription", Type: "String"}, - {Name: "TaskDueDate", Type: "DateTime"}, - {Name: "TaskCompletionType", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskCompletionType"}, - {Name: "TaskRequiredUsers", Type: "Integer"}, - {Name: "TaskKey", Type: "String"}, - {Name: "Reason", Type: "String"}, - }}, - {Name: "WorkflowEvent", Persistable: false, Attributes: []systemAttrDef{ - {Name: "EventTime", Type: "DateTime"}, - {Name: "EventType", Type: "Enumeration", EnumQN: "System.WorkflowEventType"}, - }}, - {Name: "ConsumedODataConfiguration", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ServiceUrl", Type: "String"}, - {Name: "ProxyConfiguration", Type: "Enumeration", EnumQN: "System.ProxyConfiguration"}, - {Name: "ProxyHost", Type: "String"}, - {Name: "ProxyPort", Type: "Integer"}, - {Name: "ProxyUsername", Type: "String"}, - {Name: "ProxyPassword", Type: "String"}, - }}, - {Name: "WorkflowEndedUserTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Outcome", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskState"}, - {Name: "CompletionType", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskCompletionType"}, - {Name: "UserTaskKey", Type: "String"}, - }}, - {Name: "WorkflowEndedUserTaskOutcome", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Outcome", Type: "String"}, - {Name: "Time", Type: "DateTime"}, - }}, - {Name: "WorkflowGroup", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - }}, -} - -// systemAssociations lists all associations in the System module. -// Extracted from Mendix Studio Pro 11.6.4 via DummySystem module. -var systemAssociations = []systemAssocDef{ - {Name: "grantableRoles", Parent: "UserRole", Child: "UserRole", Type: "ReferenceSet", Owner: "Default"}, - {Name: "UserRoles", Parent: "User", Child: "UserRole", Type: "ReferenceSet", Owner: "Default"}, - {Name: "Session_User", Parent: "Session", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "User_Language", Parent: "User", Child: "Language", Type: "Reference", Owner: "Default"}, - {Name: "User_TimeZone", Parent: "User", Child: "TimeZone", Type: "Reference", Owner: "Default"}, - {Name: "TokenInformation_User", Parent: "TokenInformation", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "HttpHeaders", Parent: "HttpHeader", Child: "HttpMessage", Type: "Reference", Owner: "Default"}, - {Name: "UserReportInfo_User", Parent: "UserReportInfo", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "ScheduledEventInformation_XASInstance", Parent: "ScheduledEventInformation", Child: "XASInstance", Type: "Reference", Owner: "Default"}, - {Name: "SynchronizationErrorFile_SynchronizationError", Parent: "SynchronizationErrorFile", Child: "SynchronizationError", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTaskDefinition_WorkflowDefinition", Parent: "WorkflowUserTaskDefinition", Child: "WorkflowDefinition", Type: "Reference", Owner: "Default"}, - {Name: "Workflow_WorkflowDefinition", Parent: "Workflow", Child: "WorkflowDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTask_TargetUsers", Parent: "WorkflowUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowUserTask_Assignees", Parent: "WorkflowUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowUserTask_Workflow", Parent: "WorkflowUserTask", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTask_WorkflowUserTaskDefinition", Parent: "WorkflowUserTask", Child: "WorkflowUserTaskDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowJumpToDetails_Workflow", Parent: "WorkflowJumpToDetails", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowJumpToDetails_CurrentActivities", Parent: "WorkflowJumpToDetails", Child: "WorkflowCurrentActivity", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowCurrentActivity_ActivityDetails", Parent: "WorkflowCurrentActivity", Child: "WorkflowActivityDetails", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowCurrentActivity_ApplicableTargets", Parent: "WorkflowCurrentActivity", Child: "WorkflowActivityDetails", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowCurrentActivity_JumpToTarget", Parent: "WorkflowCurrentActivity", Child: "WorkflowActivityDetails", Type: "Reference", Owner: "Default"}, - {Name: "Workflow_ParentWorkflow", Parent: "Workflow", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTaskOutcome_WorkflowUserTask", Parent: "WorkflowUserTaskOutcome", Child: "WorkflowUserTask", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTaskOutcome_User", Parent: "WorkflowUserTaskOutcome", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowRecord_Workflow", Parent: "WorkflowRecord", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowRecord_Owner", Parent: "WorkflowRecord", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowRecord_WorkflowDefinition", Parent: "WorkflowRecord", Child: "WorkflowDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_PreviousActivity", Parent: "WorkflowActivityRecord", Child: "WorkflowActivityRecord", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_Actor", Parent: "WorkflowActivityRecord", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_SubWorkflow", Parent: "WorkflowActivityRecord", Child: "WorkflowRecord", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_UserTask", Parent: "WorkflowActivityRecord", Child: "WorkflowUserTask", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_WorkflowUserTaskDefinition", Parent: "WorkflowActivityRecord", Child: "WorkflowUserTaskDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEvent_Initiator", Parent: "WorkflowEvent", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_TaskTargetedUsers", Parent: "WorkflowActivityRecord", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowActivityRecord_TaskAssignedUsers", Parent: "WorkflowActivityRecord", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "HttpHeader_ConsumedODataConfiguration", Parent: "HttpHeader", Child: "ConsumedODataConfiguration", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_Assignees", Parent: "WorkflowEndedUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_TargetUsers", Parent: "WorkflowEndedUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_WorkflowUserTaskDefinition", Parent: "WorkflowEndedUserTask", Child: "WorkflowUserTaskDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_Workflow", Parent: "WorkflowEndedUserTask", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTaskOutcome_User", Parent: "WorkflowEndedUserTaskOutcome", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTaskOutcome_WorkflowEndedUserTask", Parent: "WorkflowEndedUserTaskOutcome", Child: "WorkflowEndedUserTask", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowGroup_User", Parent: "WorkflowGroup", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowUserTask_TargetGroups", Parent: "WorkflowUserTask", Child: "WorkflowGroup", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_TargetGroups", Parent: "WorkflowEndedUserTask", Child: "WorkflowGroup", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowActivityRecord_TaskTargetedGroups", Parent: "WorkflowActivityRecord", Child: "WorkflowGroup", Type: "ReferenceSet", Owner: "Default"}, -} - -// BuildSystemDomainModel returns a virtual DomainModel for the System module. -func BuildSystemDomainModel() *domainmodel.DomainModel { - dm := &domainmodel.DomainModel{ - ContainerID: model.ID(SystemModuleID), - } - dm.ID = model.ID(SystemDomainModelID) - dm.TypeName = "DomainModels$DomainModel" - - // Build entity name -> ID map for association resolution - entityIDMap := make(map[string]model.ID, len(systemEntities)) - - for _, def := range systemEntities { - entityID := GenerateDeterministicID("System." + def.Name) - entity := &domainmodel.Entity{ - ContainerID: model.ID(SystemDomainModelID), - Name: def.Name, - Persistable: def.Persistable, - } - entity.ID = model.ID(entityID) - entityIDMap[def.Name] = entity.ID - - if def.Generalization != "" { - genID := GenerateDeterministicID("System." + def.Generalization + ".gen") - gen := domainmodel.GeneralizationBase{} - gen.ID = model.ID(genID) - gen.GeneralizationID = model.ID(GenerateDeterministicID(def.Generalization)) - entity.Generalization = gen - entity.GeneralizationRef = def.Generalization - } - - // Add attributes - for _, attrDef := range def.Attributes { - attrID := GenerateDeterministicID("System." + def.Name + "." + attrDef.Name) - attr := &domainmodel.Attribute{ - ContainerID: entity.ID, - Name: attrDef.Name, - } - attr.ID = model.ID(attrID) - - switch attrDef.Type { - case "String": - attr.Type = &domainmodel.StringAttributeType{Length: attrDef.Length} - case "Integer": - attr.Type = &domainmodel.IntegerAttributeType{} - case "Long": - attr.Type = &domainmodel.LongAttributeType{} - case "Decimal": - attr.Type = &domainmodel.DecimalAttributeType{} - case "Boolean": - attr.Type = &domainmodel.BooleanAttributeType{} - case "DateTime": - attr.Type = &domainmodel.DateTimeAttributeType{} - case "Enumeration": - attr.Type = &domainmodel.EnumerationAttributeType{ - EnumerationRef: attrDef.EnumQN, - } - case "AutoNumber": - attr.Type = &domainmodel.AutoNumberAttributeType{} - case "Binary": - attr.Type = &domainmodel.BinaryAttributeType{} - case "HashedString": - attr.Type = &domainmodel.HashedStringAttributeType{} - } - - entity.Attributes = append(entity.Attributes, attr) - } - - dm.Entities = append(dm.Entities, entity) - } - - // Add associations - for _, def := range systemAssociations { - assocID := GenerateDeterministicID("System." + def.Name) - assoc := &domainmodel.Association{ - ContainerID: model.ID(SystemDomainModelID), - Name: def.Name, - ParentID: entityIDMap[def.Parent], - ChildID: entityIDMap[def.Child], - Type: domainmodel.AssociationType(def.Type), - Owner: domainmodel.AssociationOwner(def.Owner), - } - assoc.ID = model.ID(assocID) - dm.Associations = append(dm.Associations, assoc) - } - - return dm -} - -// BuildSystemModule returns a virtual Module for the System module. -func BuildSystemModule() *model.Module { - m := &model.Module{ - Name: "System", - } - m.ID = model.ID(SystemModuleID) - return m -} diff --git a/sdk/mpr/testdata/enumerations/PictureQuality.mxunit b/sdk/mpr/testdata/enumerations/PictureQuality.mxunit deleted file mode 100644 index d9fa89f20f81fbd238d9b21513068d4b9efdcb13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1685 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 GE6OB0Uf|-LT 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

abp&R}1^v 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%0ADW<&fbxcppLFr%43{+&sl?Z`C9*x#}3alwOiLd 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 diff --git a/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson b/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson deleted file mode 100644 index 57106943856de763c8377854b9f22025d7961294..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13869 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 EKOrFxQvd(} diff --git a/sdk/mpr/text_language_test.go b/sdk/mpr/text_language_test.go deleted file mode 100644 index 1f95738804..0000000000 --- a/sdk/mpr/text_language_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// mendixlabs/mxcli#970, stage 2. The executor fixed the sites that CREATE a -// model.Text; these are the writer leaves that build a Texts$Text straight from -// a bare Go string, where the model never carried a language at all. A widget -// label is the reachable case: pages.TextBox.Label is a string, so -// serializeLabelTemplate is the only thing that can choose its LanguageCode. -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// languageCodesIn walks serialized BSON and collects every LanguageCode written. -func languageCodesIn(v any) []string { - var out []string - switch t := v.(type) { - case bson.D: - for _, e := range t { - if e.Key == "LanguageCode" { - if s, ok := e.Value.(string); ok { - out = append(out, s) - } - continue - } - out = append(out, languageCodesIn(e.Value)...) - } - case bson.A: - for _, e := range t { - out = append(out, languageCodesIn(e)...) - } - case []any: - for _, e := range t { - out = append(out, languageCodesIn(e)...) - } - } - return out -} - -func TestLabelTemplateUsesAuthoringLanguage(t *testing.T) { - orig := model.AuthoringLanguage() - t.Cleanup(func() { model.SetAuthoringLanguage(orig) }) - - for _, tc := range []struct{ set, want string }{ - {"nl_NL", "nl_NL"}, - {"en_US", "en_US"}, // the common case must not regress - {"", "en_US"}, // unset falls back to the pre-fix behaviour - } { - t.Run(tc.want+"/"+tc.set, func(t *testing.T) { - model.SetAuthoringLanguage(tc.set) - got := languageCodesIn(serializeLabelTemplate("Opslaan")) - if len(got) == 0 { - t.Fatal("no LanguageCode written for a label") - } - for _, code := range got { - if code != tc.want { - t.Errorf("label LanguageCode = %q, want %q (mendixlabs/mxcli#970)", code, tc.want) - } - } - }) - } -} diff --git a/sdk/mpr/utils.go b/sdk/mpr/utils.go deleted file mode 100644 index 17609ac99c..0000000000 --- a/sdk/mpr/utils.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "go.mongodb.org/mongo-driver/bson/primitive" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" -) - -// GenerateID generates a new unique ID for model elements. -func GenerateID() string { - return types.GenerateID() -} - -// GenerateDeterministicID generates a stable UUID from a seed string. -func GenerateDeterministicID(seed string) string { - return types.GenerateDeterministicID(seed) -} - -// BlobToUUID converts a binary ID blob to a UUID string. -func BlobToUUID(data []byte) string { - return types.BlobToUUID(data) -} - -// IDToBsonBinary converts a UUID string to a BSON binary value. -// For invalid or empty UUIDs (e.g. test placeholders), falls back to generating -// a random ID to maintain backward compatibility with existing serialization paths. -// For strict validation, use bsonutil.IDToBsonBinaryErr. -func IDToBsonBinary(id string) primitive.Binary { - return idToBsonBinary(id) -} - -// BsonBinaryToID converts a BSON binary value to a UUID string. -func BsonBinaryToID(bin primitive.Binary) string { - return bsonutil.BsonBinaryToID(bin) -} - -// ValidateID checks if an ID is valid. -func ValidateID(id string) bool { - return types.ValidateID(id) -} diff --git a/sdk/mpr/version/version.go b/sdk/mpr/version/version.go deleted file mode 100644 index 6e8d36df32..0000000000 --- a/sdk/mpr/version/version.go +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package version provides Mendix project version detection and handling. -package version - -import ( - "database/sql" - "fmt" - "strconv" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/versions" -) - -// ProjectVersion is an alias for types.ProjectVersion. -// All version comparison methods (IsAtLeast, IsAtLeastFull, String, IsMPRv2) -// are defined on types.ProjectVersion directly. -type ProjectVersion = types.ProjectVersion - -// DefaultVersion returns the default version (11.6.0) used when detection fails. -func DefaultVersion() *ProjectVersion { - return &ProjectVersion{ - ProductVersion: "11.6.0", - BuildVersion: "11.6.0", - FormatVersion: 2, - MajorVersion: 11, - MinorVersion: 6, - PatchVersion: 0, - } -} - -// DetectFromDB reads version information from the MPR database. -func DetectFromDB(db *sql.DB) (*ProjectVersion, error) { - var formatVersion int - var productVersion, buildVersion, schemaHash string - - // Try the old schema first (with _FormatVersion) - row := db.QueryRow("SELECT _FormatVersion, _ProductVersion, _BuildVersion, _SchemaHash FROM _MetaData LIMIT 1") - err := row.Scan(&formatVersion, &productVersion, &buildVersion, &schemaHash) - if err != nil { - if err == sql.ErrNoRows { - // Return default if no metadata found - return DefaultVersion(), nil - } - // Try new schema without _FormatVersion (Mendix 11.6.2+) - row = db.QueryRow("SELECT _ProductVersion, _BuildVersion, _SchemaHash FROM _MetaData LIMIT 1") - err = row.Scan(&productVersion, &buildVersion, &schemaHash) - if err != nil { - if err == sql.ErrNoRows { - return DefaultVersion(), nil - } - return nil, fmt.Errorf("failed to read version metadata: %w", err) - } - // Default format version to 2 for newer schemas - formatVersion = 2 - } - - pv := &ProjectVersion{ - ProductVersion: productVersion, - BuildVersion: buildVersion, - FormatVersion: formatVersion, - SchemaHash: schemaHash, - } - - // Parse version components - pv.MajorVersion, pv.MinorVersion, pv.PatchVersion = parseVersion(productVersion) - - return pv, nil -} - -// parseVersion extracts major, minor, patch from a version string like "10.18.0" -func parseVersion(version string) (major, minor, patch int) { - parts := strings.Split(version, ".") - if len(parts) >= 1 { - major, _ = strconv.Atoi(parts[0]) - } - if len(parts) >= 2 { - minor, _ = strconv.Atoi(parts[1]) - } - if len(parts) >= 3 { - patch, _ = strconv.Atoi(parts[2]) - } - return -} - -// SupportedVersionRange defines the range of Mendix versions supported for read/write. -var SupportedVersionRange = struct { - MinMajor int - MaxMajor int -}{ - MinMajor: 9, - MaxMajor: 11, -} - -// IsSupported returns true if pv is within the supported range for writing. -func IsSupported(pv *ProjectVersion) bool { - return pv.MajorVersion >= SupportedVersionRange.MinMajor && - pv.MajorVersion <= SupportedVersionRange.MaxMajor -} - -// SupportsFeature checks if a specific feature is available in the given version. -// It first checks the YAML-based version registry, falling back to the -// hardcoded featureVersions map for features not yet in the registry. -func SupportsFeature(pv *ProjectVersion, feature Feature) bool { - // Try the YAML registry first via the feature-to-registry mapping. - if mapping, ok := featureRegistry[feature]; ok { - reg, err := versions.Load() - if err == nil { - sv := versions.SemVer{Major: pv.MajorVersion, Minor: pv.MinorVersion, Patch: pv.PatchVersion} - return reg.IsAvailable(mapping.Area, mapping.Name, sv) - } - } - - // Fallback to hardcoded map. - minVersion, ok := featureVersions[feature] - if !ok { - return false - } - return pv.IsAtLeast(minVersion.Major, minVersion.Minor) -} - -// Feature represents a Mendix feature that may or may not be available. -type Feature string - -// Known features with version requirements -const ( - FeatureViewEntities Feature = "ViewEntities" - FeatureAssociationStorage Feature = "AssociationStorageFormat" - FeatureMPRv2 Feature = "MPRv2Format" - FeatureBusinessEvents Feature = "BusinessEvents" - FeatureWorkflows Feature = "Workflows" - FeaturePortableApp Feature = "PortableApp" -) - -// registryMapping maps a Feature constant to its area.name in the YAML registry. -type registryMapping struct { - Area string - Name string -} - -// featureRegistry maps Feature constants to their YAML registry keys. -var featureRegistry = map[Feature]registryMapping{ - FeatureViewEntities: {Area: "domain_model", Name: "view_entities"}, - FeatureAssociationStorage: {Area: "mpr_format", Name: "association_storage"}, - FeatureMPRv2: {Area: "mpr_format", Name: "mpr_v2"}, - FeatureBusinessEvents: {Area: "integration", Name: "business_events"}, - FeatureWorkflows: {Area: "workflows", Name: "basic"}, - FeaturePortableApp: {Area: "mpr_format", Name: "portable_app"}, -} - -// MinVersion represents a minimum version requirement. -type MinVersion struct { - Major int - Minor int -} - -// featureVersions maps features to their minimum required versions. -// This is the fallback when the YAML registry is unavailable. -var featureVersions = map[Feature]MinVersion{ - FeatureViewEntities: {Major: 10, Minor: 18}, - FeatureAssociationStorage: {Major: 11, Minor: 0}, - FeatureMPRv2: {Major: 10, Minor: 18}, - FeatureBusinessEvents: {Major: 10, Minor: 0}, - FeatureWorkflows: {Major: 9, Minor: 0}, - FeaturePortableApp: {Major: 11, Minor: 6}, -} diff --git a/sdk/mpr/workflow_agent_test.go b/sdk/mpr/workflow_agent_test.go deleted file mode 100644 index 6e27dd8dac..0000000000 --- a/sdk/mpr/workflow_agent_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" -) - -// The legacy engine is being retired, but it must not turn an AI agent task into -// a plain call microflow on the way through: same document shape, agent $Type. -func TestWorkflowAgentTask_LegacySerializeAndParse(t *testing.T) { - doc := serializeCallMicroflowTask(&workflows.CallMicroflowTask{IsAgent: true, Microflow: "M.InvokeAgent"}) - if got := getBSONField(doc, "$Type"); got != "Workflows$AIAgentTaskActivity" { - t.Errorf("$Type = %v, want Workflows$AIAgentTaskActivity", got) - } - plain := serializeCallMicroflowTask(&workflows.CallMicroflowTask{Microflow: "M.Plain"}) - if got := getBSONField(plain, "$Type"); got != "Workflows$CallMicroflowTask" { - t.Errorf("plain $Type = %v", got) - } - - act := parseWorkflowActivity(map[string]any{ - "$Type": "Workflows$AIAgentTaskActivity", - "Name": "aiAgentTask1", - "Microflow": "M.InvokeAgent", - }) - cm, ok := act.(*workflows.CallMicroflowTask) - if !ok || !cm.IsAgent || cm.Microflow != "M.InvokeAgent" { - t.Errorf("parsed = %#v", act) - } -} diff --git a/sdk/mpr/workflow_endpath_serialize_test.go b/sdk/mpr/workflow_endpath_serialize_test.go deleted file mode 100644 index 5c742bf245..0000000000 --- a/sdk/mpr/workflow_endpath_serialize_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -// The legacy serializer's activity switch returned nil for both end-of-path -// markers, so a path built with one lost it on write — the runtime then skips -// the path's contents (ako/view-entity-examples §6). -func TestSerializeEndOfPathMarkers(t *testing.T) { - for want, act := range map[string]workflows.WorkflowActivity{ - "Workflows$EndOfParallelSplitPathActivity": &workflows.EndOfParallelSplitPathActivity{}, - "Workflows$EndOfBoundaryEventPathActivity": &workflows.EndOfBoundaryEventPathActivity{}, - } { - doc := serializeWorkflowActivity(act) - if doc == nil { - t.Errorf("%s serialized to nil — the marker would be dropped", want) - continue - } - got := "" - for _, e := range doc { - if e.Key == "$Type" { - got, _ = e.Value.(string) - } - } - if got != want { - t.Errorf("$Type = %q, want %q", got, want) - } - } -} - -var _ = bson.D{} diff --git a/sdk/mpr/workflow_handlers_test.go b/sdk/mpr/workflow_handlers_test.go deleted file mode 100644 index 79b6b72c71..0000000000 --- a/sdk/mpr/workflow_handlers_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "reflect" - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -// The handler shape is ako/TestApp's workflow.Workflow1 (Studio Pro 11.14.0): -// OnWorkflowEvent is a marker-2 list, each handler's EventTypes a marker-1 list -// of strings, and the microflow sits in a nested MicroflowEventHandler. -func TestSerializeWorkflowEventHandlers_Shape(t *testing.T) { - arr := serializeWorkflowEventHandlers([]*workflows.WorkflowEventHandler{{ - Description: "Task audit", - EventTypes: []string{"UserTaskStarted", "UserTaskEnded"}, - Microflow: "M.ACT_Audit", - }}) - if len(arr) != 2 || arr[0] != int32(2) { - t.Fatalf("OnWorkflowEvent = %v, want marker 2 and one handler", arr) - } - h, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("handler is %T", arr[1]) - } - var keys []string - for _, e := range h { - keys = append(keys, e.Key) - } - if want := []string{"$ID", "$Type", "Description", "Documentation", "EventTypes", "MicroflowEventHandler"}; !reflect.DeepEqual(keys, want) { - t.Errorf("handler keys = %v, want %v", keys, want) - } - if got := getBSONField(h, "$Type"); got != "Workflows$WorkflowEventHandler" { - t.Errorf("$Type = %v", got) - } - if got := getBSONField(h, "EventTypes"); !reflect.DeepEqual(got, bson.A{int32(1), "UserTaskStarted", "UserTaskEnded"}) { - t.Errorf("EventTypes = %v", got) - } - mh, ok := getBSONField(h, "MicroflowEventHandler").(bson.D) - if !ok || getBSONField(mh, "$Type") != "Workflows$MicroflowEventHandler" || getBSONField(mh, "Microflow") != "M.ACT_Audit" { - t.Errorf("MicroflowEventHandler = %v", mh) - } -} - -func TestSerializeWorkflowEventHandlers_NoneIsTheBareMarker(t *testing.T) { - if got := serializeWorkflowEventHandlers(nil); !reflect.DeepEqual(got, bson.A{int32(2)}) { - t.Errorf("OnWorkflowEvent = %v, want [2]", got) - } -} - -func TestSerializeOnCreatedEvent(t *testing.T) { - none := serializeOnCreatedEvent("") - if getBSONField(none, "$Type") != "Workflows$NoEvent" || getBSONField(none, "Microflow") != nil { - t.Errorf("no microflow = %v, want a bare NoEvent", none) - } - ev := serializeOnCreatedEvent("M.ACT_Assign") - if getBSONField(ev, "$Type") != "Workflows$MicroflowBasedEvent" || getBSONField(ev, "Microflow") != "M.ACT_Assign" { - t.Errorf("microflow = %v", ev) - } -} - -// The parser read OnCreatedEvent as a string, which a stored document never is, -// so every on-created microflow read back as none — and a describe of it lost -// it. Round-tripped through real BSON bytes, as the reader sees them. -func TestParseWorkflow_OnCreatedAndHandlersRoundTrip(t *testing.T) { - doc := bson.D{ - {Key: "$Type", Value: "Workflows$Workflow"}, - {Key: "OnWorkflowEvent", Value: serializeWorkflowEventHandlers([]*workflows.WorkflowEventHandler{ - {Description: "OnAnyEvent", Documentation: "kept", EventTypes: []string{"WorkflowCompleted"}, Microflow: "M.ACT_Log"}, - })}, - {Key: "Task", Value: bson.D{ - {Key: "$Type", Value: "Workflows$SingleUserTaskActivity"}, - {Key: "Name", Value: "userTask1"}, - {Key: "OnCreatedEvent", Value: serializeOnCreatedEvent("M.ACT_Assign")}, - }}, - } - data, err := bson.Marshal(doc) - if err != nil { - t.Fatal(err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatal(err) - } - - handlers := parseWorkflowEventHandlers(raw["OnWorkflowEvent"]) - if len(handlers) != 1 { - t.Fatalf("handlers = %d, want 1", len(handlers)) - } - h := handlers[0] - if h.Description != "OnAnyEvent" || h.Documentation != "kept" || h.Microflow != "M.ACT_Log" || - !reflect.DeepEqual(h.EventTypes, []string{"WorkflowCompleted"}) { - t.Errorf("handler = %+v", h) - } - - task := parseUserTask(toMap(raw["Task"])) - if task.OnCreated != "M.ACT_Assign" { - t.Errorf("OnCreated = %q, want M.ACT_Assign", task.OnCreated) - } -} diff --git a/sdk/mpr/workflow_parse_test.go b/sdk/mpr/workflow_parse_test.go deleted file mode 100644 index c5eb7cb6f0..0000000000 --- a/sdk/mpr/workflow_parse_test.go +++ /dev/null @@ -1,388 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "os" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -// loadWorkflowBSON loads a workflow BSON fixture from testdata/workflows/.bson. -func loadWorkflowBSON(t *testing.T, name string) map[string]any { - t.Helper() - data, err := os.ReadFile(filepath.Join("testdata", "workflows", name+".bson")) - if err != nil { - t.Fatalf("load fixture %s: %v", name, err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal fixture %s: %v", name, err) - } - return raw -} - -// workflowActivities returns the activity slice (skipping the array marker) from a flow map. -func workflowActivities(t *testing.T, flowRaw map[string]any) []map[string]any { - t.Helper() - arr, ok := flowRaw["Activities"].(bson.A) - if !ok { - t.Fatalf("Activities is not bson.A, got %T", flowRaw["Activities"]) - } - var acts []map[string]any - for _, item := range arr[1:] { // skip marker at index 0 - m := toMap(item) - if m != nil { - acts = append(acts, m) - } - } - return acts -} - -func TestParseWorkflowParameter_FromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - paramRaw := toMap(raw["Parameter"]) - if paramRaw == nil { - t.Fatal("fixture has no Parameter") - } - - param := parseWorkflowParameter(paramRaw) - if param == nil { - t.Fatal("parseWorkflowParameter returned nil") - } - if param.EntityRef != "WorkflowBaseline.Entity" { - t.Errorf("EntityRef = %q, want %q", param.EntityRef, "WorkflowBaseline.Entity") - } -} - -func TestParseWorkflowFlow_FromFixture_ActivityCount(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - if flowRaw == nil { - t.Fatal("fixture has no Flow") - } - - flow := parseWorkflowFlow(flowRaw) - if flow == nil { - t.Fatal("parseWorkflowFlow returned nil") - } - // Fixture has: Start, SingleUserTask, MultiUserTask, CallMicroflow, ParallelSplit, ExclusiveSplit, End - if len(flow.Activities) != 7 { - t.Errorf("len(Activities) = %d, want 7", len(flow.Activities)) - } -} - -func TestParseWorkflowActivity_FromFixture_StartIsFirst(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - activity := parseWorkflowActivity(acts[0]) - if _, ok := activity.(*workflows.StartWorkflowActivity); !ok { - t.Errorf("activities[0] = %T, want *workflows.StartWorkflowActivity", activity) - } -} - -func TestParseWorkflowActivity_FromFixture_UserTask(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[1] is SingleUserTaskActivity in fixture - activity := parseWorkflowActivity(acts[1]) - userTask, ok := activity.(*workflows.UserTask) - if !ok { - t.Fatalf("activities[1] = %T, want *workflows.UserTask", activity) - } - if userTask.Name != "userTask1" { - t.Errorf("Name = %q, want %q", userTask.Name, "userTask1") - } -} - -func TestParseWorkflowActivity_FromFixture_CallMicroflow(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[3] is CallMicroflowTask in fixture - activity := parseWorkflowActivity(acts[3]) - callMf, ok := activity.(*workflows.CallMicroflowTask) - if !ok { - t.Fatalf("activities[3] = %T, want *workflows.CallMicroflowTask", activity) - } - if callMf.Microflow != "WorkflowBaseline.Microflow" { - t.Errorf("Microflow = %q, want %q", callMf.Microflow, "WorkflowBaseline.Microflow") - } -} - -func TestParseWorkflowActivity_FromFixture_EndIsLast(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - last := parseWorkflowActivity(acts[len(acts)-1]) - if _, ok := last.(*workflows.EndWorkflowActivity); !ok { - t.Errorf("last activity = %T, want *workflows.EndWorkflowActivity", last) - } -} - -func TestParseUserTaskOutcome_FromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[1] is SingleUserTaskActivity with one outcome - outcomesRaw := acts[1]["Outcomes"] - arr, ok := outcomesRaw.(bson.A) - if !ok || len(arr) < 2 { - t.Fatalf("expected Outcomes array with marker+1 element, got %T len=%d", outcomesRaw, len(arr)) - } - outcomeMap := toMap(arr[1]) // skip marker - if outcomeMap == nil { - t.Fatal("outcome element is nil") - } - - outcome := parseUserTaskOutcome(outcomeMap) - if outcome == nil { - t.Fatal("parseUserTaskOutcome returned nil") - } - if outcome.Value != "Outcome" { - t.Errorf("Value = %q, want %q", outcome.Value, "Outcome") - } -} - -func TestParseParameterMappings_FromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[3] is CallMicroflowTask with 1 parameter mapping - mappingsRaw := acts[3]["ParameterMappings"] - mappings := parseParameterMappings(mappingsRaw) - if len(mappings) != 1 { - t.Fatalf("len(mappings) = %d, want 1", len(mappings)) - } -} - -func TestParseWorkflowFlow_FromFixture_SubWorkflow(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Sub_Workflow") - flowRaw := toMap(raw["Flow"]) - if flowRaw == nil { - t.Fatal("Sub_Workflow fixture has no Flow") - } - - flow := parseWorkflowFlow(flowRaw) - if flow == nil { - t.Fatal("parseWorkflowFlow returned nil") - } - if len(flow.Activities) != 2 { - t.Errorf("Sub_Workflow has %d activities, want exactly 2 (Start+End)", len(flow.Activities)) - } -} - -func TestParseWorkflowParameter_Nil(t *testing.T) { - param := parseWorkflowParameter(nil) - if param != nil { - t.Errorf("parseWorkflowParameter(nil) = %v, want nil", param) - } -} - -func TestParseWorkflowActivity_UnknownType(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SomeUnknownFutureActivity", - "$ID": "abc123", - "Name": "mystery", - } - activity := parseWorkflowActivity(raw) - generic, ok := activity.(*workflows.GenericWorkflowActivity) - if !ok { - t.Fatalf("unknown type = %T, want *workflows.GenericWorkflowActivity", activity) - } - if generic.Name != "mystery" { - t.Errorf("Name = %q, want %q", generic.Name, "mystery") - } -} - -func TestParseUserTask_UserTargeting_XPath(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-001", - "Name": "reviewTask", - "Caption": "Review Request", - "UserTargeting": map[string]any{ - "$Type": "Workflows$XPathUserTargeting", - "$ID": "tgt-001", - "XPathConstraint": "[System.UserRoles = '[%UserRole_Manager%]']", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - xpathSource, ok := task.UserSource.(*workflows.XPathBasedUserSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.XPathBasedUserSource", task.UserSource) - } - if xpathSource.XPath != "[System.UserRoles = '[%UserRole_Manager%]']" { - t.Errorf("XPath = %q, want %q", xpathSource.XPath, "[System.UserRoles = '[%UserRole_Manager%]']") - } -} - -func TestParseUserTask_UserTargeting_Microflow(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-002", - "Name": "approvalTask", - "Caption": "Approval", - "UserTargeting": map[string]any{ - "$Type": "Workflows$MicroflowUserTargeting", - "$ID": "tgt-002", - "Microflow": "MyModule.GetTargetUsers", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - mfSource, ok := task.UserSource.(*workflows.MicroflowBasedUserSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.MicroflowBasedUserSource", task.UserSource) - } - if mfSource.Microflow != "MyModule.GetTargetUsers" { - t.Errorf("Microflow = %q, want %q", mfSource.Microflow, "MyModule.GetTargetUsers") - } -} - -func TestParseUserTask_UserTargeting_NoTargeting(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-003", - "Name": "simpleTask", - "Caption": "Simple", - "UserTargeting": map[string]any{ - "$Type": "Workflows$NoUserTargeting", - "$ID": "tgt-003", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - if _, ok := task.UserSource.(*workflows.NoUserSource); !ok { - t.Errorf("UserSource = %T, want *workflows.NoUserSource", task.UserSource) - } -} - -func TestParseUserTask_UserTargeting_GroupMicroflow(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-005", - "Name": "groupTask", - "Caption": "Group Review", - "UserTargeting": map[string]any{ - "$Type": "Workflows$MicroflowGroupTargeting", - "$ID": "tgt-005", - "Microflow": "MyModule.GetTargetGroups", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - groupSource, ok := task.UserSource.(*workflows.MicroflowGroupSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.MicroflowGroupSource", task.UserSource) - } - if groupSource.Microflow != "MyModule.GetTargetGroups" { - t.Errorf("Microflow = %q, want %q", groupSource.Microflow, "MyModule.GetTargetGroups") - } -} - -func TestParseUserTask_UserTargeting_GroupXPath(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-006", - "Name": "groupXPathTask", - "Caption": "Group XPath Review", - "UserTargeting": map[string]any{ - "$Type": "Workflows$XPathGroupTargeting", - "$ID": "tgt-006", - "XPathConstraint": "[GroupType = 'Reviewers']", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - groupSource, ok := task.UserSource.(*workflows.XPathGroupSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.XPathGroupSource", task.UserSource) - } - if groupSource.XPath != "[GroupType = 'Reviewers']" { - t.Errorf("XPath = %q, want %q", groupSource.XPath, "[GroupType = 'Reviewers']") - } -} - -func TestParseUserTask_LegacyUserSource_StillWorks(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-004", - "Name": "legacyTask", - "Caption": "Legacy", - "UserSource": map[string]any{ - "$Type": "Workflows$MicroflowBasedUserSource", - "$ID": "src-001", - "Microflow": "OldModule.OldMicroflow", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - mfSource, ok := task.UserSource.(*workflows.MicroflowBasedUserSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.MicroflowBasedUserSource", task.UserSource) - } - if mfSource.Microflow != "OldModule.OldMicroflow" { - t.Errorf("Microflow = %q, want %q", mfSource.Microflow, "OldModule.OldMicroflow") - } -} - -func TestParseBoundaryEvents_EmptyArray(t *testing.T) { - // nil input - events := parseBoundaryEvents(nil) - if len(events) != 0 { - t.Errorf("parseBoundaryEvents(nil) len = %d, want 0", len(events)) - } - // marker-only array (bson.A with just the int32 marker) - events = parseBoundaryEvents(bson.A{int32(2)}) - if len(events) != 0 { - t.Errorf("parseBoundaryEvents(marker-only) len = %d, want 0", len(events)) - } -} - -func TestParseBoundaryEvents_TimerEvent(t *testing.T) { - eventMap := map[string]any{ - "$Type": "Workflows$InterruptingTimerBoundaryEvent", - "$ID": "be-001", - "Caption": "Timeout", - "FirstExecutionTime": "PT1H", - } - events := parseBoundaryEvents(bson.A{int32(2), eventMap}) - if len(events) != 1 { - t.Fatalf("len(events) = %d, want 1", len(events)) - } - ev := events[0] - if ev.EventType != "InterruptingTimer" { - t.Errorf("EventType = %q, want %q", ev.EventType, "InterruptingTimer") - } - if ev.TimerDelay != "PT1H" { - t.Errorf("TimerDelay = %q, want %q", ev.TimerDelay, "PT1H") - } - if ev.Caption != "Timeout" { - t.Errorf("Caption = %q, want %q", ev.Caption, "Timeout") - } -} diff --git a/sdk/mpr/workflow_write_test.go b/sdk/mpr/workflow_write_test.go deleted file mode 100644 index f167639906..0000000000 --- a/sdk/mpr/workflow_write_test.go +++ /dev/null @@ -1,404 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -func getBSONField(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -func assertArrayMarker(t *testing.T, doc bson.D, field string, wantMarker int32) { - t.Helper() - arr, ok := getBSONField(doc, field).(bson.A) - if !ok { - t.Fatalf("%s is not bson.A", field) - } - if len(arr) == 0 { - t.Fatalf("%s is empty", field) - } - marker, ok := arr[0].(int32) - if !ok { - t.Fatalf("%s[0] is %T, want int32", field, arr[0]) - } - if marker != wantMarker { - t.Errorf("%s[0] = %d, want %d", field, marker, wantMarker) - } -} - -// --- Array marker tests: verify correct int32 markers prevent CE errors --- - -func TestSerializeWorkflowFlow_ActivitiesMarker(t *testing.T) { - flow := &workflows.Flow{ - BaseElement: model.BaseElement{ID: "flow-1"}, - Activities: []workflows.WorkflowActivity{ - &workflows.StartWorkflowActivity{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "start-1"}, - Name: "Start", - }, - }, - }, - } - doc := serializeWorkflowFlow(flow) - assertArrayMarker(t, doc, "Activities", int32(3)) -} - -func TestSerializeWorkflowFlow_EmptyActivities(t *testing.T) { - flow := &workflows.Flow{BaseElement: model.BaseElement{ID: "flow-empty"}} - doc := serializeWorkflowFlow(flow) - assertArrayMarker(t, doc, "Activities", int32(3)) - arr := getBSONField(doc, "Activities").(bson.A) - if len(arr) != 1 { - t.Errorf("empty Activities length = %d, want 1 (marker only)", len(arr)) - } -} - -func TestSerializeUserTask_OutcomesMarker(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-1"}, - Name: "ReviewTask", - }, - Outcomes: []*workflows.UserTaskOutcome{ - {BaseElement: model.BaseElement{ID: "out-1"}, Value: "Approve"}, - }, - } - doc := serializeUserTask(task) - assertArrayMarker(t, doc, "Outcomes", int32(3)) -} - -func TestSerializeUserTask_BoundaryEventsMarker(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-2"}, - Name: "Task", - }, - } - doc := serializeUserTask(task) - assertArrayMarker(t, doc, "BoundaryEvents", int32(2)) -} - -func TestSerializeCallMicroflowTask_ParameterMappingsMarker(t *testing.T) { - task := &workflows.CallMicroflowTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "cmt-1"}, - Name: "CallMF", - }, - Microflow: "MyModule.DoSomething", - ParameterMappings: []*workflows.ParameterMapping{ - {BaseElement: model.BaseElement{ID: "pm-1"}, Parameter: "InputParam", Expression: "$WorkflowContext"}, - }, - } - doc := serializeCallMicroflowTask(task) - assertArrayMarker(t, doc, "ParameterMappings", int32(2)) -} - -func TestSerializeUserTaskOutcome_ValueField(t *testing.T) { - outcome := &workflows.UserTaskOutcome{ - BaseElement: model.BaseElement{ID: "uto-1"}, - Value: "Approve", - } - doc := serializeUserTaskOutcome(outcome) - - if getBSONField(doc, "Value") != "Approve" { - t.Errorf("Value = %v, want %q", getBSONField(doc, "Value"), "Approve") - } - if getBSONField(doc, "Caption") != nil { - t.Error("UserTaskOutcome must not have 'Caption' key") - } - if getBSONField(doc, "Name") != nil { - t.Error("UserTaskOutcome must not have 'Name' key") - } -} - -func TestSerializeWorkflowParameter_EntityAsString(t *testing.T) { - param := &workflows.WorkflowParameter{ - BaseElement: model.BaseElement{ID: "param-1"}, - EntityRef: "MyModule.Customer", - } - doc := serializeWorkflowParameter(param) - - entity, ok := getBSONField(doc, "Entity").(string) - if !ok { - t.Fatalf("Entity is %T, want string", getBSONField(doc, "Entity")) - } - if entity != "MyModule.Customer" { - t.Errorf("Entity = %q, want %q", entity, "MyModule.Customer") - } -} - -// --- P0 bug regression tests --- - -func TestSerializeUserTask_AutoAssignSingleTargetUserDefaultsFalse(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-auto"}, - Name: "Task", - }, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "AutoAssignSingleTargetUser") - if val != false { - t.Errorf("AutoAssignSingleTargetUser = %v, want false", val) - } -} - -func TestSerializeUserTask_DueDateUsedFromStruct(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-due"}, - Name: "Task", - }, - DueDate: "addDays([%CurrentDateTime%], 7)", - } - doc := serializeUserTask(task) - val, _ := getBSONField(doc, "DueDate").(string) - if val != "addDays([%CurrentDateTime%], 7)" { - t.Errorf("DueDate = %q, want %q", val, "addDays([%CurrentDateTime%], 7)") - } -} - -func TestSerializeBoundaryEvents_NonInterruptingTimerHasRecurrenceNull(t *testing.T) { - events := []*workflows.BoundaryEvent{ - { - BaseElement: model.BaseElement{ID: "be-1"}, - EventType: "NonInterruptingTimer", - TimerDelay: "addDays([%CurrentDateTime%], 1)", - }, - } - arr := serializeBoundaryEvents(events) - // arr[0] is int32(2) marker, arr[1] is the event doc - if len(arr) < 2 { - t.Fatal("expected 2 elements in boundary events array") - } - doc, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("arr[1] is %T, want bson.D", arr[1]) - } - // Recurrence must exist with nil value - found := false - for _, e := range doc { - if e.Key == "Recurrence" { - found = true - if e.Value != nil { - t.Errorf("Recurrence = %v, want nil", e.Value) - } - } - } - if !found { - t.Error("Recurrence field missing from NonInterruptingTimerBoundaryEvent") - } -} - -// --- P1: Multi-User Task missing fields --- - -func TestSerializeMultiUserTask_AwaitAllUsersPresentAndFalse(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "mut-1"}, - Name: "MultiTask", - }, - IsMulti: true, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "AwaitAllUsers") - if val == nil { - t.Error("AwaitAllUsers field missing from MultiUserTaskActivity") - return - } - if val != false { - t.Errorf("AwaitAllUsers = %v, want false", val) - } -} - -func TestSerializeMultiUserTask_TargetUserInputPresent(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "mut-2"}, - Name: "MultiTask", - }, - IsMulti: true, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "TargetUserInput") - if val == nil { - t.Error("TargetUserInput field missing from MultiUserTaskActivity") - return - } - tui, ok := val.(bson.D) - if !ok { - t.Fatalf("TargetUserInput is %T, want bson.D", val) - } - typeVal, _ := getBSONField(tui, "$Type").(string) - if typeVal != "Workflows$AllUserInput" { - t.Errorf("TargetUserInput.$Type = %q, want %q", typeVal, "Workflows$AllUserInput") - } -} - -func TestSerializeMultiUserTask_CompletionCriteriaPresent(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "mut-3"}, - Name: "MultiTask", - }, - IsMulti: true, - Outcomes: []*workflows.UserTaskOutcome{ - {BaseElement: model.BaseElement{ID: "out-a"}, Value: "Approve"}, - }, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "CompletionCriteria") - if val == nil { - t.Error("CompletionCriteria field missing from MultiUserTaskActivity") - return - } - cc, ok := val.(bson.D) - if !ok { - t.Fatalf("CompletionCriteria is %T, want bson.D", val) - } - typeVal, _ := getBSONField(cc, "$Type").(string) - if typeVal != "Workflows$ConsensusCompletionCriteria" { - t.Errorf("CompletionCriteria.$Type = %q, want %q", typeVal, "Workflows$ConsensusCompletionCriteria") - } - // FallbackOutcomePointer must be a UUID binary - ptr := getBSONField(cc, "FallbackOutcomePointer") - if ptr == nil { - t.Error("CompletionCriteria.FallbackOutcomePointer missing") - } -} - -func TestSerializeSingleUserTask_NoMultiFields(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "sut-1"}, - Name: "SingleTask", - }, - IsMulti: false, - } - doc := serializeUserTask(task) - if getBSONField(doc, "AwaitAllUsers") != nil { - t.Error("SingleUserTask must not have AwaitAllUsers field") - } - if getBSONField(doc, "CompletionCriteria") != nil { - t.Error("SingleUserTask must not have CompletionCriteria field") - } - if getBSONField(doc, "TargetUserInput") != nil { - t.Error("SingleUserTask must not have TargetUserInput field") - } -} - -// --- P2: CallWorkflowActivity must not emit ParameterExpression --- - -func TestSerializeCallWorkflowActivity_NoParameterExpressionField(t *testing.T) { - act := &workflows.CallWorkflowActivity{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "cwa-1"}, - Name: "callWorkflow1", - }, - Workflow: "MyModule.SubFlow", - ParameterExpression: "$WorkflowContext", - } - doc := serializeCallWorkflowActivity(act) - for _, e := range doc { - if e.Key == "ParameterExpression" { - t.Error("CallWorkflowActivity must not emit ParameterExpression field (not in Studio Pro BSON)") - return - } - } -} - -// --- Fixture-based roundtrip: parse real BSON → serialize → verify markers preserved --- - -func TestSerializeWorkflowFlow_RoundtripFromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - if flowRaw == nil { - t.Fatal("fixture has no Flow") - } - - // Parse real workflow from fixture - flow := parseWorkflowFlow(flowRaw) - if flow == nil { - t.Fatal("parseWorkflowFlow returned nil") - } - - // Serialize back to BSON - doc := serializeWorkflowFlow(flow) - - // Verify array markers survive the roundtrip - assertArrayMarker(t, doc, "Activities", int32(3)) - - // Re-marshal and re-parse to verify full roundtrip - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var reparsedRaw map[string]any - if err := bson.Unmarshal(data, &reparsedRaw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - reparsed := parseWorkflowFlow(reparsedRaw) - if reparsed == nil { - t.Fatal("re-parse returned nil") - } - if len(reparsed.Activities) != len(flow.Activities) { - t.Errorf("roundtrip Activities count = %d, want %d", len(reparsed.Activities), len(flow.Activities)) - } - // Verify first activity type is preserved - if _, ok := reparsed.Activities[0].(*workflows.StartWorkflowActivity); !ok { - t.Errorf("roundtrip Activities[0] = %T, want *workflows.StartWorkflowActivity", reparsed.Activities[0]) - } - // Verify last activity type is preserved - last := reparsed.Activities[len(reparsed.Activities)-1] - if _, ok := last.(*workflows.EndWorkflowActivity); !ok { - t.Errorf("roundtrip last activity = %T, want *workflows.EndWorkflowActivity", last) - } -} - -// TestRenameCallMicroflowTypeBSON verifies the version-gated $Type rewrite in the -// legacy engine (FINDINGS #39): a CallMicroflowTask nested inside a Flow's -// activities array is renamed to CallMicroflowActivity only when useActivity is set. -func TestRenameCallMicroflowTypeBSON(t *testing.T) { - build := func() bson.D { - return bson.D{ - {Key: "$Type", Value: "Workflows$Workflow"}, - {Key: "Flow", Value: bson.D{ - {Key: "$Type", Value: "Workflows$Flow"}, - {Key: "Activities", Value: bson.A{ - int32(3), - bson.D{{Key: "$Type", Value: "Workflows$CallMicroflowTask"}, {Key: "Name", Value: "Call"}}, - bson.D{{Key: "$Type", Value: "Workflows$EndWorkflowActivity"}}, - }}, - }}, - } - } - typeOfActivity := func(d bson.D) string { - flow := d[1].Value.(bson.D) - acts := flow[1].Value.(bson.A) - return acts[1].(bson.D)[0].Value.(string) - } - - off := build() - renameCallMicroflowTypeBSON(off, false) - if got := typeOfActivity(off); got != "Workflows$CallMicroflowTask" { - t.Errorf("pre-11.9: activity $Type = %q, want Workflows$CallMicroflowTask", got) - } - - on := build() - renameCallMicroflowTypeBSON(on, true) - if got := typeOfActivity(on); got != "Workflows$CallMicroflowActivity" { - t.Errorf("11.9+: activity $Type = %q, want Workflows$CallMicroflowActivity", got) - } -} diff --git a/sdk/mpr/writer_agenteditor_agent.go b/sdk/mpr/writer_agenteditor_agent.go deleted file mode 100644 index d74d121158..0000000000 --- a/sdk/mpr/writer_agenteditor_agent.go +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Agent documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorAgent writes an Agent document. -func (w *Writer) CreateAgentEditorAgent(a *agenteditor.Agent) error { - if a == nil { - return fmt.Errorf("agent is nil") - } - if a.Name == "" { - return fmt.Errorf("agent name is required") - } - if a.ContainerID == "" { - return fmt.Errorf("agent container ID is required") - } - if a.ID == "" { - a.ID = model.ID(generateUUID()) - } - - // Ensure tool/KB entries have stable IDs. - for i := range a.Tools { - if a.Tools[i].ID == "" { - a.Tools[i].ID = generateUUID() - } - } - for i := range a.KBTools { - if a.KBTools[i].ID == "" { - a.KBTools[i].ID = generateUUID() - } - } - - contentsJSON, err := encodeAgentContents(a) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(a.ID), - ContainerID: string(a.ContainerID), - Name: a.Name, - Documentation: a.Documentation, - Excluded: a.Excluded, - ExportLevel: a.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeAgent, - ReadableTypeName: agenteditor.ReadableAgent, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorAgent replaces an existing Agent document, preserving its UUID. -// Tool and KB entries without IDs get fresh stable IDs assigned. -func (w *Writer) UpdateAgentEditorAgent(a *agenteditor.Agent) error { - if a == nil { - return fmt.Errorf("agent is nil") - } - - for i := range a.Tools { - if a.Tools[i].ID == "" { - a.Tools[i].ID = generateUUID() - } - } - for i := range a.KBTools { - if a.KBTools[i].ID == "" { - a.KBTools[i].ID = generateUUID() - } - } - - contentsJSON, err := encodeAgentContents(a) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(a.ID), - ContainerID: string(a.ContainerID), - Name: a.Name, - Documentation: a.Documentation, - Excluded: a.Excluded, - ExportLevel: a.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeAgent, - ReadableTypeName: agenteditor.ReadableAgent, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorAgent removes an Agent by ID. -func (w *Writer) DeleteAgentEditorAgent(id string) error { - return w.deleteUnit(id) -} - -func encodeAgentContents(a *agenteditor.Agent) (string, error) { - // Build the JSON shape matching what the agent editor extension produces. - // Optional fields are omitted when empty/nil (omitempty). - type toolEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - ToolType string `json:"toolType"` - Document *agenteditor.DocRef `json:"document,omitempty"` - } - type kbToolEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - ToolType string `json:"toolType"` - Document *agenteditor.DocRef `json:"document,omitempty"` - CollectionIdentifier string `json:"collectionIdentifier,omitempty"` - MaxResults int `json:"maxResults,omitempty"` - } - type contentsShape struct { - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - UserPrompt string `json:"userPrompt"` - UsageType string `json:"usageType"` - Variables []agenteditor.AgentVar `json:"variables"` - Tools []toolEntry `json:"tools"` - KnowledgebaseTools []kbToolEntry `json:"knowledgebaseTools"` - Model *agenteditor.DocRef `json:"model,omitempty"` - Entity *agenteditor.DocRef `json:"entity,omitempty"` - MaxTokens *int `json:"maxTokens,omitempty"` - ToolChoice string `json:"toolChoice,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"topP,omitempty"` - } - - // Convert typed slices (ensure non-nil so JSON emits [] not null). - tools := make([]toolEntry, 0, len(a.Tools)) - for _, t := range a.Tools { - tools = append(tools, toolEntry{ - ID: t.ID, - Name: t.Name, - Description: t.Description, - Enabled: t.Enabled, - ToolType: t.ToolType, - Document: t.Document, - }) - } - kbTools := make([]kbToolEntry, 0, len(a.KBTools)) - for _, kb := range a.KBTools { - kbTools = append(kbTools, kbToolEntry{ - ID: kb.ID, - Name: kb.Name, - Description: kb.Description, - Enabled: kb.Enabled, - ToolType: kb.ToolType, - Document: kb.Document, - CollectionIdentifier: kb.CollectionIdentifier, - MaxResults: kb.MaxResults, - }) - } - - vars := a.Variables - if vars == nil { - vars = []agenteditor.AgentVar{} - } - - payload := contentsShape{ - Description: a.Description, - SystemPrompt: a.SystemPrompt, - UserPrompt: a.UserPrompt, - UsageType: a.UsageType, - Variables: vars, - Tools: tools, - KnowledgebaseTools: kbTools, - Model: a.Model, - Entity: a.Entity, - MaxTokens: a.MaxTokens, - ToolChoice: a.ToolChoice, - Temperature: a.Temperature, - TopP: a.TopP, - } - - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_agenteditor_kb.go b/sdk/mpr/writer_agenteditor_kb.go deleted file mode 100644 index f57f53b436..0000000000 --- a/sdk/mpr/writer_agenteditor_kb.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Knowledge Base documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorKnowledgeBase writes a Knowledge Base document. -func (w *Writer) CreateAgentEditorKnowledgeBase(k *agenteditor.KnowledgeBase) error { - if k == nil { - return fmt.Errorf("knowledge base is nil") - } - if k.Name == "" { - return fmt.Errorf("knowledge base name is required") - } - if k.ContainerID == "" { - return fmt.Errorf("knowledge base container ID is required") - } - if k.Provider == "" { - k.Provider = "MxCloudGenAI" - } - if k.ID == "" { - k.ID = model.ID(generateUUID()) - } - - contentsJSON, err := encodeKnowledgeBaseContents(k) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(k.ID), - ContainerID: string(k.ContainerID), - Name: k.Name, - Documentation: k.Documentation, - Excluded: k.Excluded, - ExportLevel: k.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeKnowledgeBase, - ReadableTypeName: agenteditor.ReadableKnowledgeBase, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorKnowledgeBase replaces an existing Knowledge Base document, preserving its UUID. -func (w *Writer) UpdateAgentEditorKnowledgeBase(k *agenteditor.KnowledgeBase) error { - if k == nil { - return fmt.Errorf("knowledge base is nil") - } - if k.Provider == "" { - k.Provider = "MxCloudGenAI" - } - - contentsJSON, err := encodeKnowledgeBaseContents(k) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(k.ID), - ContainerID: string(k.ContainerID), - Name: k.Name, - Documentation: k.Documentation, - Excluded: k.Excluded, - ExportLevel: k.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeKnowledgeBase, - ReadableTypeName: agenteditor.ReadableKnowledgeBase, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorKnowledgeBase removes a Knowledge Base by ID. -func (w *Writer) DeleteAgentEditorKnowledgeBase(id string) error { - return w.deleteUnit(id) -} - -func encodeKnowledgeBaseContents(k *agenteditor.KnowledgeBase) (string, error) { - type providerFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ModelDisplayName string `json:"modelDisplayName"` - ModelName string `json:"modelName"` - Key *agenteditor.ConstantRef `json:"key,omitempty"` - } - type contentsShape struct { - Name string `json:"name"` - Provider string `json:"provider"` - ProviderFields providerFields `json:"providerFields"` - } - payload := contentsShape{ - Name: "", - Provider: k.Provider, - ProviderFields: providerFields{ - Environment: k.Environment, - DeepLinkURL: k.DeepLinkURL, - KeyID: k.KeyID, - KeyName: k.KeyName, - ModelDisplayName: k.ModelDisplayName, - ModelName: k.ModelName, - Key: k.Key, - }, - } - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_agenteditor_mcpservice.go b/sdk/mpr/writer_agenteditor_mcpservice.go deleted file mode 100644 index 0207155453..0000000000 --- a/sdk/mpr/writer_agenteditor_mcpservice.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Consumed MCP Service documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorConsumedMCPService writes a Consumed MCP Service document. -func (w *Writer) CreateAgentEditorConsumedMCPService(c *agenteditor.ConsumedMCPService) error { - if c == nil { - return fmt.Errorf("consumed MCP service is nil") - } - if c.Name == "" { - return fmt.Errorf("consumed MCP service name is required") - } - if c.ContainerID == "" { - return fmt.Errorf("consumed MCP service container ID is required") - } - if c.ID == "" { - c.ID = model.ID(generateUUID()) - } - - contentsJSON, err := encodeConsumedMCPServiceContents(c) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(c.ID), - ContainerID: string(c.ContainerID), - Name: c.Name, - Documentation: c.Documentation, - Excluded: c.Excluded, - ExportLevel: c.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeConsumedMCPService, - ReadableTypeName: agenteditor.ReadableConsumedMCPService, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorConsumedMCPService replaces an existing Consumed MCP Service document, preserving its UUID. -func (w *Writer) UpdateAgentEditorConsumedMCPService(c *agenteditor.ConsumedMCPService) error { - if c == nil { - return fmt.Errorf("consumed MCP service is nil") - } - - contentsJSON, err := encodeConsumedMCPServiceContents(c) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(c.ID), - ContainerID: string(c.ContainerID), - Name: c.Name, - Documentation: c.Documentation, - Excluded: c.Excluded, - ExportLevel: c.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeConsumedMCPService, - ReadableTypeName: agenteditor.ReadableConsumedMCPService, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorConsumedMCPService removes a Consumed MCP Service by ID. -func (w *Writer) DeleteAgentEditorConsumedMCPService(id string) error { - return w.deleteUnit(id) -} - -func encodeConsumedMCPServiceContents(c *agenteditor.ConsumedMCPService) (string, error) { - type contentsShape struct { - ProtocolVersion string `json:"protocolVersion"` - Documentation string `json:"documentation"` - Version string `json:"version"` - ConnectionTimeoutSeconds int `json:"connectionTimeoutSeconds"` - } - payload := contentsShape{ - ProtocolVersion: c.ProtocolVersion, - Documentation: c.InnerDocumentation, - Version: c.Version, - ConnectionTimeoutSeconds: c.ConnectionTimeoutSeconds, - } - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_agenteditor_model.go b/sdk/mpr/writer_agenteditor_model.go deleted file mode 100644 index bf571a853e..0000000000 --- a/sdk/mpr/writer_agenteditor_model.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Model documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorModel writes a Model document to the project. The -// Model.ContainerID must be set (the module/folder to place it in). -// The Model.ID is auto-generated if empty. -// -// The Contents JSON shape mirrors what Studio Pro's agent-editor -// extension produces — see PROPOSAL_agent_document_support.md. -func (w *Writer) CreateAgentEditorModel(m *agenteditor.Model) error { - if m == nil { - return fmt.Errorf("model is nil") - } - if m.Name == "" { - return fmt.Errorf("model name is required") - } - if m.ContainerID == "" { - return fmt.Errorf("model container ID is required") - } - if m.Provider == "" { - // Only one provider is currently supported by the agent editor. - m.Provider = "MxCloudGenAI" - } - if m.ID == "" { - m.ID = model.ID(generateUUID()) - } - - contentsJSON, err := encodeAgentEditorModelContents(m) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(m.ID), - ContainerID: string(m.ContainerID), - Name: m.Name, - Documentation: m.Documentation, - Excluded: m.Excluded, - ExportLevel: m.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeModel, - ReadableTypeName: agenteditor.ReadableModel, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorModel replaces an existing Model document, preserving its UUID. -func (w *Writer) UpdateAgentEditorModel(m *agenteditor.Model) error { - if m == nil { - return fmt.Errorf("model is nil") - } - if m.Provider == "" { - m.Provider = "MxCloudGenAI" - } - - contentsJSON, err := encodeAgentEditorModelContents(m) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(m.ID), - ContainerID: string(m.ContainerID), - Name: m.Name, - Documentation: m.Documentation, - Excluded: m.Excluded, - ExportLevel: m.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeModel, - ReadableTypeName: agenteditor.ReadableModel, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorModel removes a Model document by ID. -func (w *Writer) DeleteAgentEditorModel(id string) error { - return w.deleteUnit(id) -} - -// encodeAgentEditorModelContents produces the JSON payload stored in -// the Contents field of a Model CustomBlobDocument. -func encodeAgentEditorModelContents(m *agenteditor.Model) (string, error) { - // Provider-specific fields are nested under providerFields. Keys are - // emitted in the same order Studio Pro uses. - type providerFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ResourceName string `json:"resourceName"` - Key *agenteditor.ConstantRef `json:"key,omitempty"` - } - type contentsShape struct { - Type string `json:"type"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Provider string `json:"provider"` - ProviderFields providerFields `json:"providerFields"` - } - - payload := contentsShape{ - Type: m.Type, - Name: m.InnerName, - DisplayName: m.DisplayName, - Provider: m.Provider, - ProviderFields: providerFields{ - Environment: m.Environment, - DeepLinkURL: m.DeepLinkURL, - KeyID: m.KeyID, - KeyName: m.KeyName, - ResourceName: m.ResourceName, - Key: m.Key, - }, - } - - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_businessevents.go b/sdk/mpr/writer_businessevents.go deleted file mode 100644 index 7a46df0a4d..0000000000 --- a/sdk/mpr/writer_businessevents.go +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateBusinessEventService creates a new business event service document. -func (w *Writer) CreateBusinessEventService(svc *model.BusinessEventService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "BusinessEvents$BusinessEventService" - - contents, err := w.serializeBusinessEventService(svc) - if err != nil { - return fmt.Errorf("failed to serialize business event service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "BusinessEvents$BusinessEventService", contents) -} - -// UpdateBusinessEventService updates an existing business event service. -func (w *Writer) UpdateBusinessEventService(svc *model.BusinessEventService) error { - contents, err := w.serializeBusinessEventService(svc) - if err != nil { - return fmt.Errorf("failed to serialize business event service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeleteBusinessEventService deletes a business event service by ID. -func (w *Writer) DeleteBusinessEventService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeBusinessEventService converts a BusinessEventService to BSON bytes. -func (w *Writer) serializeBusinessEventService(svc *model.BusinessEventService) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "BusinessEvents$BusinessEventService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Excluded", Value: svc.Excluded}, - {Key: "ExportLevel", Value: svc.ExportLevel}, - } - - // Serialize Definition - if svc.Definition != nil { - doc = append(doc, bson.E{Key: "Definition", Value: serializeBusinessEventDefinition(svc.Definition)}) - } else { - doc = append(doc, bson.E{Key: "Definition", Value: nil}) - } - - // Serialize OperationImplementations - opImpls := bson.A{int32(2)} // versioned array prefix - for _, op := range svc.OperationImplementations { - opImpls = append(opImpls, serializeServiceOperation(op)) - } - doc = append(doc, bson.E{Key: "OperationImplementations", Value: opImpls}) - - // SourceApi is null for service definitions - doc = append(doc, bson.E{Key: "SourceApi", Value: nil}) - - return marshalUnitIDFirst(doc) -} - -func serializeBusinessEventDefinition(def *model.BusinessEventDefinition) bson.D { - id := string(def.ID) - if id == "" { - id = generateUUID() - } - - // Serialize Channels - channels := bson.A{int32(2)} // versioned array prefix - for _, ch := range def.Channels { - channels = append(channels, serializeBusinessEventChannel(ch)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$BusinessEventDefinition"}, - {Key: "ServiceName", Value: def.ServiceName}, - {Key: "EventNamePrefix", Value: def.EventNamePrefix}, - {Key: "Description", Value: def.Description}, - {Key: "Summary", Value: def.Summary}, - {Key: "Channels", Value: channels}, - } -} - -func serializeBusinessEventChannel(ch *model.BusinessEventChannel) bson.D { - id := string(ch.ID) - if id == "" { - id = generateUUID() - } - - // Serialize Messages - messages := bson.A{int32(2)} // versioned array prefix - for _, msg := range ch.Messages { - messages = append(messages, serializeBusinessEventMessage(msg)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$Channel"}, - {Key: "ChannelName", Value: ch.ChannelName}, - {Key: "Description", Value: ch.Description}, - {Key: "Messages", Value: messages}, - } -} - -func serializeBusinessEventMessage(msg *model.BusinessEventMessage) bson.D { - id := string(msg.ID) - if id == "" { - id = generateUUID() - } - - // Serialize Attributes - attrs := bson.A{int32(2)} // versioned array prefix - for _, attr := range msg.Attributes { - attrs = append(attrs, serializeBusinessEventAttribute(attr)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$Message"}, - {Key: "MessageName", Value: msg.MessageName}, - {Key: "Description", Value: msg.Description}, - {Key: "CanPublish", Value: msg.CanPublish}, - {Key: "CanSubscribe", Value: msg.CanSubscribe}, - {Key: "Attributes", Value: attrs}, - } -} - -func serializeBusinessEventAttribute(attr *model.BusinessEventAttribute) bson.D { - id := string(attr.ID) - if id == "" { - id = generateUUID() - } - - // Convert attribute type to BSON format: "Long" → {"$Type": "DomainModels$LongAttributeType", "$ID": ...} - attrTypeDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: attributeTypeToBsonType(attr.AttributeType)}, - } - // Date and DateTime both use DateTimeAttributeType; distinguish via LocalizeDate - if attr.AttributeType == "DateTime" { - attrTypeDoc = append(attrTypeDoc, bson.E{Key: "LocalizeDate", Value: true}) - } else if attr.AttributeType == "Date" { - attrTypeDoc = append(attrTypeDoc, bson.E{Key: "LocalizeDate", Value: false}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$MessageAttribute"}, - {Key: "AttributeName", Value: attr.AttributeName}, - {Key: "Description", Value: attr.Description}, - {Key: "AttributeType", Value: attrTypeDoc}, - } -} - -// attributeTypeToBsonType converts a simple type name to a BSON $Type string. -func attributeTypeToBsonType(typeName string) string { - switch typeName { - case "Long": - return "DomainModels$LongAttributeType" - case "String": - return "DomainModels$StringAttributeType" - case "Integer": - return "DomainModels$IntegerAttributeType" - case "Boolean": - return "DomainModels$BooleanAttributeType" - case "DateTime", "Date": - return "DomainModels$DateTimeAttributeType" - case "Decimal": - return "DomainModels$DecimalAttributeType" - case "AutoNumber": - return "DomainModels$AutoNumberAttributeType" - case "Binary": - return "DomainModels$BinaryAttributeType" - default: - return "DomainModels$StringAttributeType" - } -} - -func serializeServiceOperation(op *model.ServiceOperation) bson.D { - id := string(op.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$ServiceOperation"}, - {Key: "MessageName", Value: op.MessageName}, - {Key: "Operation", Value: op.Operation}, - {Key: "Entity", Value: op.Entity}, - {Key: "Microflow", Value: op.Microflow}, - } -} diff --git a/sdk/mpr/writer_commit_rename_test.go b/sdk/mpr/writer_commit_rename_test.go deleted file mode 100644 index 3a5e18a405..0000000000 --- a/sdk/mpr/writer_commit_rename_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "os" - "path/filepath" - "strings" - "testing" - - _ "modernc.org/sqlite" -) - -// upstream #954, legacy engine. This copy of WriteTransaction has no callers -// today — the legacy write path is Writer.updateUnit, which has always failed -// hard when it could not put a unit's bytes on disk — but it is exported, so it -// is kept in step with modelsdk/mpr's copy rather than left holding the bug. -// These mirror the modelsdk tests against it. - -// newV2WriterForCommitTest builds a minimal MPR v2 writer over a temp SQLite DB -// and mprcontents folder, seeded with one unit holding stored. -func newV2WriterForCommitTest(t *testing.T, unitID string, stored []byte) (*Writer, string) { - t.Helper() - - root := t.TempDir() - dbPath := filepath.Join(root, "app.mpr") - contentsDir := filepath.Join(root, "mprcontents") - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT - ) - `); err != nil { - t.Fatalf("create Unit table: %v", err) - } - - blob := uuidToBlob(unitID) - swapped := blobToUUIDSwapped(blob) - unitPath := filepath.Join(contentsDir, swapped[0:2], swapped[2:4], swapped+".mxunit") - if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil { - t.Fatalf("mkdir unit dir: %v", err) - } - if err := os.WriteFile(unitPath, stored, 0644); err != nil { - t.Fatalf("seed unit file: %v", err) - } - if _, err := db.Exec( - `INSERT INTO Unit (UnitID, ContentsHash) VALUES (?, ?)`, blob, contentHashBase64(stored), - ); err != nil { - t.Fatalf("insert unit row: %v", err) - } - - reader := &Reader{path: dbPath, db: db, version: MPRVersionV2, contentsDir: contentsDir} - return &Writer{reader: reader}, unitPath -} - -func commitTestStoredHash(t *testing.T, w *Writer, unitID string) string { - t.Helper() - var got string - if err := w.reader.db.QueryRow( - `SELECT ContentsHash FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID), - ).Scan(&got); err != nil { - t.Fatalf("read ContentsHash: %v", err) - } - return got -} - -func TestCommitFailsWhenAUnitFileCannotBeFinalized(t *testing.T) { - const unitID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - stored := []byte("stored bytes") - updated := []byte("updated bytes") - - w, unitPath := newV2WriterForCommitTest(t, unitID, stored) - - wt, err := w.BeginWriteTransaction() - if err != nil { - t.Fatalf("begin write transaction: %v", err) - } - if err := wt.WriteUnit(unitID, updated); err != nil { - t.Fatalf("write unit: %v", err) - } - - // Stand-in for the Windows lock: make the rename fail. What fails it is not - // the point, only that it can. - if err := os.Remove(wt.pendingFiles[0].tempPath); err != nil { - t.Fatalf("remove temp file: %v", err) - } - - if err := wt.Commit(); err == nil { - t.Fatal("Commit returned nil after failing to finalize a unit file") - } else if !strings.Contains(err.Error(), unitID) { - t.Errorf("Commit error %q does not name the unit %s", err, unitID) - } - - onDisk, err := os.ReadFile(unitPath) - if err != nil { - t.Fatalf("read unit file: %v", err) - } - if string(onDisk) != string(stored) { - t.Error("unit file changed despite the failed commit") - } - if got, want := commitTestStoredHash(t, w, unitID), contentHashBase64(stored); got != want { - t.Errorf("ContentsHash = %q, want %q: the database describes contents "+ - "that are not on disk", got, want) - } -} - -// TestCommitSucceedsWhenEveryUnitFileIsFinalized is the false-positive control. -func TestCommitSucceedsWhenEveryUnitFileIsFinalized(t *testing.T) { - const unitID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeef" - stored := []byte("stored bytes") - updated := []byte("updated bytes") - - w, unitPath := newV2WriterForCommitTest(t, unitID, stored) - - wt, err := w.BeginWriteTransaction() - if err != nil { - t.Fatalf("begin write transaction: %v", err) - } - if err := wt.WriteUnit(unitID, updated); err != nil { - t.Fatalf("write unit: %v", err) - } - if err := wt.Commit(); err != nil { - t.Fatalf("Commit: %v", err) - } - - onDisk, err := os.ReadFile(unitPath) - if err != nil { - t.Fatalf("read unit file: %v", err) - } - if string(onDisk) != string(updated) { - t.Error("unit file was not updated on the success path") - } - if got, want := commitTestStoredHash(t, w, unitID), contentHashBase64(updated); got != want { - t.Errorf("ContentsHash = %q, want %q", got, want) - } - - // No .tmp and no .bak: a successful commit leaves the folder as Mendix - // expects to find it. - entries, err := os.ReadDir(filepath.Dir(unitPath)) - if err != nil { - t.Fatalf("read dir: %v", err) - } - for _, e := range entries { - if !strings.HasSuffix(e.Name(), ".mxunit") { - t.Errorf("stray file left behind: %s", e.Name()) - } - } -} diff --git a/sdk/mpr/writer_core.go b/sdk/mpr/writer_core.go deleted file mode 100644 index c39e60a914..0000000000 --- a/sdk/mpr/writer_core.go +++ /dev/null @@ -1,321 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - - "github.com/mendixlabs/mxcli/mdl/types" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// idToBsonBinary converts a UUID string to BSON Binary format. -// For invalid or empty UUIDs (e.g. test placeholders), generates a random ID -// to maintain backward compatibility with existing serialization paths. -// -// WARNING: an empty id here is almost always a bug (e.g. an unset pointer on a -// SequenceFlow) and produces a random UUID that references nothing — which -// Studio Pro surfaces as "KeyNotFoundException". Fix callers to pass a real ID. -func idToBsonBinary(id string) primitive.Binary { - blob := types.UUIDToBlob(id) - if blob == nil || len(blob) != 16 { - blob = types.UUIDToBlob(types.GenerateID()) - } - return primitive.Binary{ - Subtype: 0x00, - Data: blob, - } -} - -// Writer provides methods to write Mendix project files. -type Writer struct { - reader *Reader - - // writesOffered / writesLanded count what this session tried to persist and - // how much of it was not skipped as a no-op (ADR-0008). Both unit writes and - // generated source files count: a code action's body lives in - // javascriptsource/ rather than in its unit, so counting units alone would - // call a body-only edit unchanged. The executor reads these to tell - // "Modified X" from "X was already in sync" — without them, re-running a - // script that changes nothing still announces a write for every statement, - // which is how the churn in #910 was misdiagnosed. - writesOffered int - writesLanded int -} - -// WriteStats reports how many writes this session offered to storage and how -// many of them actually changed something. -func (w *Writer) WriteStats() (offered, written int) { - return w.writesOffered, w.writesLanded -} - -// NewWriter creates a new writer from a reader opened in read-write mode. -func NewWriter(path string) (*Writer, error) { - reader, err := OpenWithOptions(path, OpenOptions{ReadOnly: false}) - if err != nil { - return nil, err - } - return &Writer{reader: reader}, nil -} - -// Close closes the writer. -func (w *Writer) Close() error { - return w.reader.Close() -} - -// Reader returns the underlying reader. -func (w *Writer) Reader() *Reader { - return w.reader -} - -// Transaction support - -// Transaction represents a database transaction. -type Transaction struct { - tx *sql.Tx - writer *Writer -} - -// BeginTransaction starts a new transaction. -func (w *Writer) BeginTransaction() (*Transaction, error) { - tx, err := w.reader.db.Begin() - if err != nil { - return nil, err - } - return &Transaction{tx: tx, writer: w}, nil -} - -// Commit commits the transaction. -func (t *Transaction) Commit() error { - return t.tx.Commit() -} - -// Rollback rolls back the transaction. -func (t *Transaction) Rollback() error { - return t.tx.Rollback() -} - -// WriteTransaction provides atomic write operations for MPR v2 format. -// It coordinates database and file system changes to ensure consistency. -type WriteTransaction struct { - tx *sql.Tx - writer *Writer - pendingFiles []pendingFile - finalized []finalizedFile - committed bool -} - -type pendingFile struct { - unitID string - tempPath string - finalPath string -} - -// finalizedFile records a rename that has already happened, so it can be undone -// if a later step of the same Commit fails. backupPath is empty when the unit -// had no file on disk to preserve. -type finalizedFile struct { - pendingFile - backupPath string -} - -// BeginWriteTransaction starts a new write transaction. -// For v2 format, this coordinates both database and file writes. -func (w *Writer) BeginWriteTransaction() (*WriteTransaction, error) { - tx, err := w.reader.db.Begin() - if err != nil { - return nil, err - } - return &WriteTransaction{ - tx: tx, - writer: w, - pendingFiles: make([]pendingFile, 0), - }, nil -} - -// WriteUnit writes a unit within the transaction. -// The actual file write is deferred until Commit. -func (wt *WriteTransaction) WriteUnit(unitID string, contents []byte) error { - unitIDBlob := uuidToBlob(unitID) - - if wt.writer.reader.version == MPRVersionV2 { - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Create directory if needed - dir := filepath.Join(wt.writer.reader.contentsDir, swappedUUID[0:2], swappedUUID[2:4]) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - // Write to temp file first - finalPath := filepath.Join(dir, swappedUUID+".mxunit") - tempPath := finalPath + ".tmp" - - if err := os.WriteFile(tempPath, contents, 0644); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - wt.pendingFiles = append(wt.pendingFiles, pendingFile{ - unitID: unitID, - tempPath: tempPath, - finalPath: finalPath, - }) - - contentsHash := contentHashBase64(contents) - _, err := wt.tx.Exec(` - UPDATE Unit SET ContentsHash = ? WHERE UnitID = ? - `, contentsHash, unitIDBlob) - return err - } - - // V1: Update in database directly - contentsHash := contentHashBase64(contents) - _, err := wt.tx.Exec(` - UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? - `, contents, contentsHash, unitIDBlob) - if err != nil && isContentsHashSchemaError(err) { - // Older v1 schemas do not have ContentsHash; retry without it. - // Any other error (disk full, invalid UnitID, rolled-back tx) propagates. - _, err = wt.tx.Exec(` - UPDATE Unit SET Contents = ? WHERE UnitID = ? - `, contents, unitIDBlob) - } - return err -} - -// Commit commits the transaction. -// -// For v2 the unit files are renamed into place FIRST and the database is -// committed only once every rename has succeeded; any failure undoes the -// renames and rolls the transaction back, so the two either move together or -// not at all. The order matters: committing first and then renaming leaves the -// Unit table — ContentsHash included — describing bytes that are not on disk, -// and the old code warned about that on stdout and returned nil (upstream -// #954). A rename fails for ordinary environmental reasons — on Windows a -// .mxunit held open without FILE_SHARE_DELETE by an editor, an indexer or a -// sync client is enough. -// -// Kept in step with modelsdk/mpr's copy, which is the one storage path the -// codec engine reaches. -func (wt *WriteTransaction) Commit() error { - if wt.committed { - return fmt.Errorf("transaction already committed") - } - - if err := wt.finalizeFiles(); err != nil { - wt.undoFinalizedFiles() - wt.cleanupTempFiles() - _ = wt.tx.Rollback() - return err - } - - if err := wt.tx.Commit(); err != nil { - wt.undoFinalizedFiles() - wt.cleanupTempFiles() - return err - } - - wt.discardBackups() - wt.committed = true - return nil -} - -// finalizeFiles renames each pending temp file into place, first moving the file -// it replaces aside so undoFinalizedFiles can put it back. It stops at the first -// failure, leaving wt.finalized describing exactly what has to be undone. -func (wt *WriteTransaction) finalizeFiles() error { - seen := make(map[string]bool, len(wt.pendingFiles)) - for _, pf := range wt.pendingFiles { - // A unit written twice in one transaction shares a temp path, so the - // first rename already carried the latest bytes; a second would move the - // file just written aside as if it were the stored one. - if seen[pf.finalPath] { - continue - } - seen[pf.finalPath] = true - - backupPath := "" - if _, err := os.Stat(pf.finalPath); err == nil { - backupPath = pf.finalPath + ".bak" - if err := os.Rename(pf.finalPath, backupPath); err != nil { - return fmt.Errorf("finalize unit %s: cannot move %s aside: %w", - pf.unitID, pf.finalPath, err) - } - } - if err := os.Rename(pf.tempPath, pf.finalPath); err != nil { - if backupPath != "" { - _ = os.Rename(backupPath, pf.finalPath) - } - return fmt.Errorf("finalize unit %s: cannot write %s: %w", - pf.unitID, pf.finalPath, err) - } - wt.finalized = append(wt.finalized, finalizedFile{pendingFile: pf, backupPath: backupPath}) - } - return nil -} - -// undoFinalizedFiles reverses finalizeFiles, newest first: the new bytes go back -// to their temp path (for cleanupTempFiles to remove) and the file that was -// moved aside returns to its own name. Diagnostics go to stderr — stdout carries -// the CLI's own output and must stay parseable. -func (wt *WriteTransaction) undoFinalizedFiles() { - for i := len(wt.finalized) - 1; i >= 0; i-- { - f := wt.finalized[i] - if err := os.Rename(f.finalPath, f.tempPath); err != nil { - fmt.Fprintf(os.Stderr, "mpr: could not undo write of %s: %v\n", f.finalPath, err) - } - if f.backupPath != "" { - if err := os.Rename(f.backupPath, f.finalPath); err != nil { - fmt.Fprintf(os.Stderr, "mpr: could not restore %s: %v\n", f.finalPath, err) - } - } - } - wt.finalized = nil -} - -// discardBackups drops the moved-aside files once the commit has succeeded and -// they can no longer be needed. A leftover is inert — nothing reads a path that -// is not .mxunit — so a failure here is reported, not returned. -func (wt *WriteTransaction) discardBackups() { - for _, f := range wt.finalized { - if f.backupPath == "" { - continue - } - if err := os.Remove(f.backupPath); err != nil { - fmt.Fprintf(os.Stderr, "mpr: could not remove %s: %v\n", f.backupPath, err) - } - } - wt.finalized = nil -} - -// Rollback rolls back the transaction and cleans up temp files. -func (wt *WriteTransaction) Rollback() error { - if wt.committed { - return fmt.Errorf("transaction already committed") - } - - // Clean up temp files - wt.cleanupTempFiles() - - // Rollback database - return wt.tx.Rollback() -} - -func (wt *WriteTransaction) cleanupTempFiles() { - for _, pf := range wt.pendingFiles { - os.Remove(pf.tempPath) - } -} - -// generateUUID delegates to types.GenerateID. -func generateUUID() string { - return types.GenerateID() -} - -// uuidToBlob delegates to types.UUIDToBlob. -func uuidToBlob(uuid string) []byte { - return types.UUIDToBlob(uuid) -} diff --git a/sdk/mpr/writer_customblob.go b/sdk/mpr/writer_customblob.go deleted file mode 100644 index 30b3b19f2f..0000000000 --- a/sdk/mpr/writer_customblob.go +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Generic writer for CustomBlobDocument units (the BSON -// wrapper used by all four agent-editor document types: Agent, Model, -// Knowledge Base, Consumed MCP Service). -// -// Type-specific Contents JSON encoders live in writer_agenteditor_*.go. -package mpr - -import ( - "encoding/json" - "fmt" - - "github.com/mendixlabs/mxcli/sdk/agenteditor" - - "go.mongodb.org/mongo-driver/bson" -) - -// customBlobInput holds the per-type payload for the wrapper writer. -type customBlobInput struct { - UnitID string // canonical UUID of the document - ContainerID string // canonical UUID of the parent container (module/folder) - Name string - Documentation string - Excluded bool - ExportLevel string // "Hidden" by default - CustomDocumentType string // e.g. "agenteditor.model" - ReadableTypeName string // e.g. "Model" - MetadataID string // canonical UUID for the embedded Metadata $ID - ContentsJSON string // type-specific JSON payload -} - -// writeCustomBlobDocument serializes a CustomBlobDocument BSON wrapper -// and inserts it as a Documents-containment unit in the project. -func (w *Writer) writeCustomBlobDocument(in customBlobInput) error { - if in.UnitID == "" { - return fmt.Errorf("CustomBlobDocument unit ID is required") - } - if in.ContainerID == "" { - return fmt.Errorf("CustomBlobDocument container ID is required") - } - if in.CustomDocumentType == "" { - return fmt.Errorf("CustomDocumentType is required") - } - if in.ReadableTypeName == "" { - return fmt.Errorf("ReadableTypeName is required") - } - if in.ExportLevel == "" { - in.ExportLevel = "Hidden" - } - if in.MetadataID == "" { - in.MetadataID = generateUUID() - } - - metadata := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.MetadataID)}, - {Key: "$Type", Value: "CustomBlobDocuments$CustomBlobDocumentMetadata"}, - {Key: "CreatedByExtension", Value: agenteditor.CreatedByExtensionID}, - {Key: "ReadableTypeName", Value: in.ReadableTypeName}, - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.UnitID)}, - {Key: "$Type", Value: customBlobDocType}, - {Key: "Contents", Value: in.ContentsJSON}, - {Key: "CustomDocumentType", Value: in.CustomDocumentType}, - {Key: "Documentation", Value: in.Documentation}, - {Key: "Excluded", Value: in.Excluded}, - {Key: "ExportLevel", Value: in.ExportLevel}, - {Key: "Metadata", Value: metadata}, - {Key: "Name", Value: in.Name}, - } - - contents, err := marshalUnitIDFirst(doc) - if err != nil { - return fmt.Errorf("failed to marshal CustomBlobDocument BSON: %w", err) - } - - return w.insertUnit(in.UnitID, in.ContainerID, "Documents", customBlobDocType, contents) -} - -// updateCustomBlobDocument serializes a CustomBlobDocument BSON wrapper and -// replaces the existing unit in the project, preserving its UUID. -func (w *Writer) updateCustomBlobDocument(in customBlobInput) error { - if in.UnitID == "" { - return fmt.Errorf("CustomBlobDocument unit ID is required for update") - } - if in.ExportLevel == "" { - in.ExportLevel = "Hidden" - } - if in.MetadataID == "" { - in.MetadataID = generateUUID() - } - - metadata := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.MetadataID)}, - {Key: "$Type", Value: "CustomBlobDocuments$CustomBlobDocumentMetadata"}, - {Key: "CreatedByExtension", Value: agenteditor.CreatedByExtensionID}, - {Key: "ReadableTypeName", Value: in.ReadableTypeName}, - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.UnitID)}, - {Key: "$Type", Value: customBlobDocType}, - {Key: "Contents", Value: in.ContentsJSON}, - {Key: "CustomDocumentType", Value: in.CustomDocumentType}, - {Key: "Documentation", Value: in.Documentation}, - {Key: "Excluded", Value: in.Excluded}, - {Key: "ExportLevel", Value: in.ExportLevel}, - {Key: "Metadata", Value: metadata}, - {Key: "Name", Value: in.Name}, - } - - contents, err := marshalUnitIDFirst(doc) - if err != nil { - return fmt.Errorf("failed to marshal CustomBlobDocument BSON: %w", err) - } - - return w.updateUnit(in.UnitID, contents) -} - -// marshalCanonicalJSON produces JSON without HTML escaping, matching the -// shape Studio Pro's agent-editor extension produces. -func marshalCanonicalJSON(v any) (string, error) { - b, err := json.Marshal(v) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/sdk/mpr/writer_datatransformer.go b/sdk/mpr/writer_datatransformer.go deleted file mode 100644 index 461d56741b..0000000000 --- a/sdk/mpr/writer_datatransformer.go +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateDataTransformer creates a new DataTransformers$DataTransformer document. -func (w *Writer) CreateDataTransformer(dt *model.DataTransformer) error { - if dt.ID == "" { - dt.ID = model.ID(generateUUID()) - } - dt.TypeName = "DataTransformers$DataTransformer" - - contents, err := serializeDataTransformer(dt) - if err != nil { - return fmt.Errorf("failed to serialize data transformer: %w", err) - } - - return w.insertUnit(string(dt.ID), string(dt.ContainerID), "Documents", "DataTransformers$DataTransformer", contents) -} - -// UpdateDataTransformer replaces an existing data transformer unit, preserving its UUID. -func (w *Writer) UpdateDataTransformer(dt *model.DataTransformer) error { - dt.TypeName = "DataTransformers$DataTransformer" - - contents, err := serializeDataTransformer(dt) - if err != nil { - return fmt.Errorf("failed to serialize data transformer: %w", err) - } - - return w.updateUnit(string(dt.ID), contents) -} - -// DeleteDataTransformer deletes a data transformer by ID. -func (w *Writer) DeleteDataTransformer(id model.ID) error { - return w.deleteUnit(string(id)) -} - -func serializeDataTransformer(dt *model.DataTransformer) ([]byte, error) { - // Root element - rootElemID := generateUUID() - rootElement := bson.D{ - {Key: "$ID", Value: idToBsonBinary(rootElemID)}, - {Key: "$Type", Value: "DataTransformers$StructureObject"}, - {Key: "Attributes", Value: bson.A{int32(2)}}, - } - - // Source - var source bson.D - switch strings.ToUpper(dt.SourceType) { - case "XML": - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$XmlSource"}, - {Key: "Content", Value: dt.SourceJSON}, - } - default: // JSON - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$JsonSource"}, - {Key: "Content", Value: dt.SourceJSON}, - } - } - - // Steps - steps := bson.A{int32(2)} - for _, step := range dt.Steps { - var action bson.D - switch strings.ToUpper(step.Technology) { - case "JSLT": - action = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$JsltAction"}, - {Key: "Jslt", Value: step.Expression}, - } - case "XSLT": - action = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$XsltAction"}, - {Key: "Xslt", Value: step.Expression}, - } - default: - action = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$JsltAction"}, - {Key: "Jslt", Value: step.Expression}, - } - } - - steps = append(steps, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$Step"}, - {Key: "Action", Value: action}, - {Key: "InputElementPointer", Value: idToBsonBinary(rootElemID)}, - {Key: "OutputElementPointer", Value: idToBsonBinary(rootElemID)}, - }) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dt.ID))}, - {Key: "$Type", Value: "DataTransformers$DataTransformer"}, - {Key: "Name", Value: dt.Name}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: dt.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Source", Value: source}, - {Key: "Elements", Value: bson.A{int32(2), rootElement}}, - {Key: "RootElementPointer", Value: idToBsonBinary(rootElemID)}, - {Key: "Steps", Value: steps}, - } - - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/writer_dbconnection.go b/sdk/mpr/writer_dbconnection.go deleted file mode 100644 index 1eee2ce914..0000000000 --- a/sdk/mpr/writer_dbconnection.go +++ /dev/null @@ -1,213 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/dbconnector" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// CreateDatabaseConnection creates a new DatabaseConnector$DatabaseConnection document. -func (w *Writer) CreateDatabaseConnection(conn *model.DatabaseConnection) error { - if conn.ID == "" { - conn.ID = model.ID(generateUUID()) - } - conn.TypeName = "DatabaseConnector$DatabaseConnection" - - contents, err := w.serializeDatabaseConnection(conn) - if err != nil { - return fmt.Errorf("failed to serialize database connection: %w", err) - } - - return w.insertUnit(string(conn.ID), string(conn.ContainerID), - "Documents", "DatabaseConnector$DatabaseConnection", contents) -} - -// UpdateDatabaseConnection updates an existing database connection. -func (w *Writer) UpdateDatabaseConnection(conn *model.DatabaseConnection) error { - contents, err := w.serializeDatabaseConnection(conn) - if err != nil { - return fmt.Errorf("failed to serialize database connection: %w", err) - } - - return w.updateUnit(string(conn.ID), contents) -} - -// MoveDatabaseConnection moves a database connection to a new container (module or folder). -func (w *Writer) MoveDatabaseConnection(conn *model.DatabaseConnection) error { - return w.moveUnitByID(string(conn.ID), string(conn.ContainerID)) -} - -// DeleteDatabaseConnection deletes a database connection by ID. -func (w *Writer) DeleteDatabaseConnection(id model.ID) error { - return w.deleteUnit(string(id)) -} - -func (w *Writer) serializeDatabaseConnection(conn *model.DatabaseConnection) ([]byte, error) { - // Build ConnectionInput — stores actual JDBC URL for Studio Pro development - connInput := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DatabaseConnector$ConnectionString"}, - {Key: "Value", Value: conn.ConnectionInputValue}, - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(conn.ID))}, - {Key: "$Type", Value: "DatabaseConnector$DatabaseConnection"}, - {Key: "Name", Value: conn.Name}, - {Key: "DatabaseType", Value: conn.DatabaseType}, - {Key: "ConnectionString", Value: conn.ConnectionString}, - {Key: "UserName", Value: conn.UserName}, - {Key: "Password", Value: conn.Password}, - {Key: "Documentation", Value: conn.Documentation}, - {Key: "Excluded", Value: conn.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "ConnectionInput", Value: connInput}, - } - - // Serialize Queries - queries := bson.A{int32(2)} // versioned array prefix - typeEnum := w.storesQueryTypeEnum() - for _, q := range conn.Queries { - queries = append(queries, serializeDBQuery(q, typeEnum)) - } - doc = append(doc, bson.E{Key: "Queries", Value: queries}) - - // AdditionalProperties (empty array) - doc = append(doc, bson.E{Key: "AdditionalProperties", Value: bson.A{int32(2)}}) - - // LastSelectedQuery (empty ref) - doc = append(doc, bson.E{Key: "LastSelectedQuery", Value: ""}) - - return marshalUnitIDFirst(doc) -} - -// storesQueryTypeEnum reports whether this project stores a query's type under the -// Mendix 11.13+ `Type` key. An unreadable version falls back to the legacy key. -func (w *Writer) storesQueryTypeEnum() bool { - if w.reader == nil { - return false - } - pv := w.reader.ProjectVersion() - if pv == nil { - return false - } - return dbconnector.StoresTypeEnum(pv.MajorVersion, pv.MinorVersion) -} - -func serializeDBQuery(q *model.DatabaseQuery, typeEnum bool) bson.D { - id := string(q.ID) - if id == "" { - id = generateUUID() - } - - // TableMappings - mappings := bson.A{int32(2)} - for _, m := range q.TableMappings { - mappings = append(mappings, serializeDBTableMapping(m)) - } - - // Parameters - params := bson.A{int32(2)} - for _, p := range q.Parameters { - params = append(params, serializeDBQueryParameter(p)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$DatabaseQuery"}, - {Key: "Name", Value: q.Name}, - {Key: "Query", Value: q.SQL}, - } - // Exactly one of the two spellings — writing the other invents a property the - // target version's metamodel does not define. See mdl/dbconnector. - if typeEnum { - doc = append(doc, bson.E{Key: dbconnector.TypeKey, - Value: dbconnector.TypeToWrite(q.QueryTypeName, q.SQL)}) - } else { - doc = append(doc, bson.E{Key: dbconnector.QueryTypeKey, Value: int64(q.QueryType)}) - } - return append(doc, - bson.E{Key: "TableMappings", Value: mappings}, - bson.E{Key: "Parameters", Value: params}, - ) -} - -func serializeDBQueryParameter(p *model.DatabaseQueryParameter) bson.D { - id := string(p.ID) - if id == "" { - id = generateUUID() - } - - // DataType - dataType := p.DataType - if dataType == "" { - dataType = "DataTypes$StringType" - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$QueryParameter"}, - {Key: "ParameterName", Value: p.ParameterName}, - {Key: "DatabaseParameterName", Value: ""}, - {Key: "DefaultValue", Value: p.DefaultValue}, - {Key: "EmptyValueBecomesNull", Value: p.EmptyValueBecomesNull}, - {Key: "Mode", Value: "Unknown"}, - {Key: "TableMapping", Value: nil}, - {Key: "DataType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: dataType}, - }}, - {Key: "SqlDataType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DatabaseConnector$SimpleSqlDataType"}, - {Key: "DataTypeName", Value: ""}, - }}, - } -} - -func serializeDBTableMapping(m *model.DatabaseTableMapping) bson.D { - id := string(m.ID) - if id == "" { - id = generateUUID() - } - - // Columns - columns := bson.A{int32(2)} - for _, c := range m.Columns { - columns = append(columns, serializeDBColumnMapping(c)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$TableMapping"}, - {Key: "Entity", Value: m.Entity}, - {Key: "TableName", Value: m.TableName}, - {Key: "Columns", Value: columns}, - } -} - -func serializeDBColumnMapping(c *model.DatabaseColumnMapping) bson.D { - id := string(c.ID) - if id == "" { - id = generateUUID() - } - - // SqlDataType — use SimpleSqlDataType as default - cDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$ColumnMapping"}, - {Key: "Attribute", Value: c.Attribute}, - {Key: "ColumnName", Value: c.ColumnName}, - } - - cDoc = append(cDoc, bson.E{Key: "SqlDataType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DatabaseConnector$SimpleSqlDataType"}, - }}) - - return cDoc -} diff --git a/sdk/mpr/writer_dbconnection_querytype_test.go b/sdk/mpr/writer_dbconnection_querytype_test.go deleted file mode 100644 index c6443218dc..0000000000 --- a/sdk/mpr/writer_dbconnection_querytype_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/mdl/dbconnector" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeDBQuery_QueryTypeSpellingFollowsVersion covers the Mendix 11.13 -// rename of the query-type property. 11.13 replaced the integer `QueryType` with -// the string enum `Type`; a query carrying only the legacy key reads as Unknown, -// which mxbuild reports as CE5277 ("Please re-run and save the query to fix the -// error") on every Execute-database-query activity pointing at it. -// -// Exactly one spelling must be written: the other is a property the target -// version's metamodel does not define, which is the shape Studio Pro refuses to -// open. -func TestSerializeDBQuery_QueryTypeSpellingFollowsVersion(t *testing.T) { - tests := []struct { - name string - typeEnum bool - wantKey string - wantValue any - absentKey string - }{ - { - name: "mendix_11_12_writes_legacy_int", - typeEnum: false, - wantKey: dbconnector.QueryTypeKey, - wantValue: int64(dbconnector.CustomSQLQueryType), - absentKey: dbconnector.TypeKey, - }, - { - name: "mendix_11_13_writes_type_enum", - typeEnum: true, - wantKey: dbconnector.TypeKey, - wantValue: dbconnector.TypeSelect, - absentKey: dbconnector.QueryTypeKey, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - q := &model.DatabaseQuery{ - Name: "GetAll", - SQL: "SELECT driverId FROM drivers", - QueryType: dbconnector.CustomSQLQueryType, - } - doc := docMap(serializeDBQuery(q, tc.typeEnum)) - - if got, ok := doc[tc.wantKey]; !ok || got != tc.wantValue { - t.Errorf("%s = %#v (present=%v), want %#v", tc.wantKey, got, ok, tc.wantValue) - } - if v, ok := doc[tc.absentKey]; ok { - t.Errorf("wrote %s = %#v; this Mendix version stores %s", - tc.absentKey, v, tc.wantKey) - } - }) - } -} - -// TestSerializeDBQuery_TypeFromStatement: mxcli never connects to the database, so -// it derives the 11.13 type from the statement rather than leaving it Unknown. -func TestSerializeDBQuery_TypeFromStatement(t *testing.T) { - tests := []struct { - sql string - stored string - want string - }{ - {sql: "SELECT 1", want: dbconnector.TypeSelect}, - {sql: "UPDATE drivers SET forename = 'x'", want: dbconnector.TypeNonSelect}, - // A value Studio Pro derived by running the query outranks the heuristic. - {sql: "EXEC dbo.GetRows", stored: dbconnector.TypeSelect, want: dbconnector.TypeSelect}, - } - for _, tc := range tests { - q := &model.DatabaseQuery{Name: "Q", SQL: tc.sql, QueryTypeName: tc.stored} - if got := docMap(serializeDBQuery(q, true))[dbconnector.TypeKey]; got != tc.want { - t.Errorf("Type for %q (stored %q) = %#v, want %q", tc.sql, tc.stored, got, tc.want) - } - } -} - -// TestParseDBQuery_ReadsEitherSpelling guards the read half: a project written by -// 11.13 has no QueryType at all, and reading 0 there would write Unknown straight -// back on the next ALTER. -func TestParseDBQuery_ReadsEitherSpelling(t *testing.T) { - legacy := parseDBQuery(map[string]any{ - "Name": "Q", - "Query": "SELECT 1", - dbconnector.QueryTypeKey: int32(dbconnector.CustomSQLQueryType), - "$Type": "DatabaseConnector$DatabaseQuery", - }) - if legacy.QueryType != dbconnector.CustomSQLQueryType || legacy.QueryTypeName != "" { - t.Errorf("legacy parse = %d/%q, want %d/\"\"", - legacy.QueryType, legacy.QueryTypeName, dbconnector.CustomSQLQueryType) - } - - modern := parseDBQuery(map[string]any{ - "Name": "Q", - "Query": "UPDATE t SET a = 1", - dbconnector.TypeKey: dbconnector.TypeNonSelect, - "$Type": "DatabaseConnector$DatabaseQuery", - }) - if modern.QueryTypeName != dbconnector.TypeNonSelect { - t.Errorf("QueryTypeName = %q, want %q", modern.QueryTypeName, dbconnector.TypeNonSelect) - } - if modern.QueryType != dbconnector.CustomSQLQueryType { - t.Errorf("QueryType = %d, want %d", modern.QueryType, dbconnector.CustomSQLQueryType) - } -} - -// docMap flattens a bson.D into a lookup keyed by property name. -func docMap(d bson.D) map[string]any { - out := make(map[string]any, len(d)) - for _, e := range d { - out[e.Key] = e.Value - } - return out -} diff --git a/sdk/mpr/writer_domainmodel.go b/sdk/mpr/writer_domainmodel.go deleted file mode 100644 index 8801abf665..0000000000 --- a/sdk/mpr/writer_domainmodel.go +++ /dev/null @@ -1,1572 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "fmt" - "sort" - "strings" - - "github.com/mendixlabs/mxcli/generated/metamodel" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "github.com/mendixlabs/mxcli/sdk/mpr/version" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateEntity creates a new entity in a domain model. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) CreateEntity(domainModelID model.ID, entity *domainmodel.Entity) error { - // Load the domain model by its ID - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Assign ID if not set - if entity.ID == "" { - entity.ID = model.ID(generateUUID()) - } - entity.TypeName = "DomainModels$Entity" - entity.ContainerID = domainModelID - - // Assign IDs to attributes if not set - for _, attr := range entity.Attributes { - if attr.ID == "" { - attr.ID = model.ID(generateUUID()) - } - attr.TypeName = "DomainModels$Attribute" - attr.ContainerID = entity.ID - } - - // Add entity to domain model - dm.Entities = append(dm.Entities, entity) - - // Serialize and update - return w.updateDomainModel(dm) -} - -// UpdateEntity updates an existing entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) UpdateEntity(domainModelID model.ID, entity *domainmodel.Entity) error { - // Refuse rather than downgrade — see serializeRuleInfo. - if ruleType, ok := validationRulesAreReproducible(entity); !ok { - return fmt.Errorf( - "entity %s has a %s validation rule, which mxcli cannot rewrite without losing it — "+ - "change this entity in Studio Pro, or remove the rule first.\n"+ - " (Rewriting would silently turn it into a Required rule: the constraint would be gone "+ - "and the build would still pass.)", - entity.Name, ruleType) - } - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find and replace the entity - for i, e := range dm.Entities { - if e.ID == entity.ID { - dm.Entities[i] = entity - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("entity not found: %s", entity.ID) -} - -// DeleteEntity deletes an entity from a domain model. -// domainModelID is the ID of the domain model itself (not the module ID). -// Cascade: any association in any module whose ParentID or ChildID matches -// entityID is also removed, preventing dangling unit-pointer errors. -func (w *Writer) DeleteEntity(domainModelID model.ID, entityID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find and remove the entity - found := false - for i, e := range dm.Entities { - if e.ID == entityID { - dm.Entities = append(dm.Entities[:i], dm.Entities[i+1:]...) - found = true - break - } - } - if !found { - return fmt.Errorf("entity not found: %s", entityID) - } - - // Remove associations referencing this entity from the same DM - var keptAssocs []*domainmodel.Association - for _, a := range dm.Associations { - if a.ParentID != entityID && a.ChildID != entityID { - keptAssocs = append(keptAssocs, a) - } - } - dm.Associations = keptAssocs - - if err := w.updateDomainModel(dm); err != nil { - return err - } - - // Cascade: remove associations referencing this entity from all other DMs - allDMs, err := w.reader.ListDomainModels() - if err != nil { - return fmt.Errorf("cascade cleanup: list domain models: %w", err) - } - for _, other := range allDMs { - if other.ID == domainModelID { - continue - } - changed := false - var kept []*domainmodel.Association - for _, a := range other.Associations { - if a.ParentID == entityID || a.ChildID == entityID { - changed = true - } else { - kept = append(kept, a) - } - } - if changed { - other.Associations = kept - if err := w.updateDomainModel(other); err != nil { - return fmt.Errorf("cascade cleanup: update domain model %s: %w", other.ID, err) - } - } - } - - return nil -} - -// MoveEntity moves an entity from one domain model to another. -// Associations referencing the moved entity are converted to CrossAssociations -// (cross-module associations with BY_NAME references to the remote entity). -// Validation rule attribute references are updated to reflect the new module name. -// Returns the names of converted associations (for caller to inform about). -func (w *Writer) MoveEntity(entity *domainmodel.Entity, sourceDMID, targetDMID model.ID, sourceModuleName, targetModuleName string) ([]string, error) { - // Load source domain model and remove the entity - sourceDM, err := w.reader.GetDomainModelByID(sourceDMID) - if err != nil { - return nil, fmt.Errorf("failed to load source domain model: %w", err) - } - - found := false - for i, e := range sourceDM.Entities { - if e.ID == entity.ID { - sourceDM.Entities = append(sourceDM.Entities[:i], sourceDM.Entities[i+1:]...) - found = true - break - } - } - if !found { - return nil, fmt.Errorf("entity not found in source domain model: %s", entity.ID) - } - - // Load target domain model - targetDM, err := w.reader.GetDomainModelByID(targetDMID) - if err != nil { - return nil, fmt.Errorf("failed to load target domain model: %w", err) - } - - // Convert associations referencing the moved entity to CrossAssociations. - // - If moved entity is the child: CrossAssoc stays in source DM (parent is local) - // - If moved entity is the parent: CrossAssoc goes to target DM (parent moves with entity) - var convertedAssocs []string - var keptAssocs []*domainmodel.Association - for _, a := range sourceDM.Associations { - if a.ChildID == entity.ID { - // Child is being moved → CrossAssoc stays in source DM - // ParentPointer = parent entity (stays local), Child = remote qualified name - ca := &domainmodel.CrossModuleAssociation{} - ca.ID = a.ID - ca.TypeName = "DomainModels$CrossAssociation" - ca.ContainerID = sourceDMID - ca.Name = a.Name - ca.Documentation = a.Documentation - ca.ParentID = a.ParentID - ca.ChildRef = targetModuleName + "." + entity.Name - ca.Type = a.Type - ca.Owner = a.Owner - ca.StorageFormat = a.StorageFormat - ca.ParentDeleteBehavior = a.ParentDeleteBehavior - ca.ChildDeleteBehavior = a.ChildDeleteBehavior - sourceDM.CrossAssociations = append(sourceDM.CrossAssociations, ca) - convertedAssocs = append(convertedAssocs, a.Name) - } else if a.ParentID == entity.ID { - // Parent is being moved → CrossAssoc goes to target DM - // ParentPointer = moved entity (will be local in target), Child = remote entity in source - var childEntityName string - for _, e := range sourceDM.Entities { - if e.ID == a.ChildID { - childEntityName = e.Name - break - } - } - ca := &domainmodel.CrossModuleAssociation{} - ca.ID = a.ID - ca.TypeName = "DomainModels$CrossAssociation" - ca.ContainerID = targetDMID - ca.Name = a.Name - ca.Documentation = a.Documentation - ca.ParentID = a.ParentID // parent entity ID (same, just moving to target DM) - ca.ChildRef = sourceModuleName + "." + childEntityName - ca.Type = a.Type - ca.Owner = a.Owner - ca.StorageFormat = a.StorageFormat - ca.ParentDeleteBehavior = a.ParentDeleteBehavior - ca.ChildDeleteBehavior = a.ChildDeleteBehavior - targetDM.CrossAssociations = append(targetDM.CrossAssociations, ca) - convertedAssocs = append(convertedAssocs, a.Name) - } else { - keptAssocs = append(keptAssocs, a) - } - } - sourceDM.Associations = keptAssocs - - // Update validation rule attribute references in the moved entity. - // These are BY_NAME qualified names like "OldModule.Entity.Attribute" that need - // to be updated to "NewModule.Entity.Attribute". - oldPrefix := sourceModuleName + "." - newPrefix := targetModuleName + "." - for _, vr := range entity.ValidationRules { - attrIDStr := string(vr.AttributeID) - if strings.HasPrefix(attrIDStr, oldPrefix) { - vr.AttributeID = model.ID(newPrefix + attrIDStr[len(oldPrefix):]) - } - } - - // Update SourceDocumentRef for view entities - if entity.Source == "DomainModels$OqlViewEntitySource" && entity.SourceDocumentRef != "" { - if strings.HasPrefix(entity.SourceDocumentRef, oldPrefix) { - entity.SourceDocumentRef = newPrefix + entity.SourceDocumentRef[len(oldPrefix):] - } - } - - // Save source domain model - if err := w.updateDomainModel(sourceDM); err != nil { - return nil, fmt.Errorf("failed to update source domain model: %w", err) - } - - // Add entity to target domain model and save - entity.ContainerID = targetDMID - targetDM.Entities = append(targetDM.Entities, entity) - - if err := w.updateDomainModel(targetDM); err != nil { - return nil, fmt.Errorf("failed to update target domain model: %w", err) - } - - return convertedAssocs, nil -} - -// UpdateEnumerationRefsInAllDomainModels updates enumeration references across all domain models. -// When an enumeration is moved to a different module, its qualified name changes and all -// EnumerationAttributeType references need to be updated. -func (w *Writer) UpdateEnumerationRefsInAllDomainModels(oldQualifiedName, newQualifiedName string) error { - dms, err := w.reader.ListDomainModels() - if err != nil { - return fmt.Errorf("failed to list domain models: %w", err) - } - - for _, dm := range dms { - changed := false - for _, entity := range dm.Entities { - for _, attr := range entity.Attributes { - if enumType, ok := attr.Type.(*domainmodel.EnumerationAttributeType); ok { - if enumType.EnumerationRef == oldQualifiedName { - enumType.EnumerationRef = newQualifiedName - enumType.EnumerationID = model.ID(newQualifiedName) - changed = true - } - } - } - } - if changed { - if err := w.updateDomainModel(dm); err != nil { - return fmt.Errorf("failed to update domain model %s: %w", dm.ID, err) - } - } - } - return nil -} - -// MoveViewEntitySourceDocument moves a ViewEntitySourceDocument to a new module. -func (w *Writer) MoveViewEntitySourceDocument(sourceModuleName string, targetModuleID model.ID, docName string) error { - docID, err := w.FindViewEntitySourceDocumentID(sourceModuleName, docName) - if err != nil { - return err - } - if docID == "" { - return nil // No document to move - } - - // Update ContainerID in database - return w.moveUnitByID(string(docID), string(targetModuleID)) -} - -// UpdateOqlQueriesForMovedEntity updates OQL queries in all ViewEntitySourceDocuments -// to reflect a moved entity's new qualified name. For example, when DmTest.Customer moves -// to DmTest2.Customer, all OQL references like "DmTest.Customer" are updated. -func (w *Writer) UpdateOqlQueriesForMovedEntity(oldQualifiedName, newQualifiedName string) (int, error) { - units, err := w.reader.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return 0, err - } - - updated := 0 - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - oql, _ := raw["Oql"].(string) - if oql == "" || !strings.Contains(oql, oldQualifiedName) { - continue - } - - // Replace entity references in OQL - newOql := strings.ReplaceAll(oql, oldQualifiedName, newQualifiedName) - raw["Oql"] = newOql - - // Re-serialize and update - contents, err := marshalUnitIDFirst(raw) - if err != nil { - continue - } - if err := w.updateUnit(u.ID, contents); err != nil { - return updated, fmt.Errorf("failed to update ViewEntitySourceDocument %s: %w", u.ID, err) - } - updated++ - } - return updated, nil -} - -// moveUnitByID changes a unit's ContainerID without modifying its contents. -// MoveUnitByID reparents any top-level document unit. Exported so backends can -// move doctypes that have no dedicated writer method of their own (Java actions, -// published OData services) — the containment row is all that changes. -func (w *Writer) MoveUnitByID(unitID string, newContainerID string) error { - return w.moveUnitByID(unitID, newContainerID) -} - -// Counted in WriteStats and elided when the unit already sits in that -// container, for the reasons on the modelsdk engine's MoveUnit: a move changes -// placement without changing contents, so it is invisible to both halves of -// ADR-0008 unless the row update accounts for itself. -func (w *Writer) moveUnitByID(unitID string, newContainerID string) error { - w.writesOffered++ - unitIDBlob := uuidToBlob(unitID) - containerIDBlob := uuidToBlob(newContainerID) - - var stored []byte - if err := w.reader.db.QueryRow(`SELECT ContainerID FROM Unit WHERE UnitID = ?`, unitIDBlob).Scan(&stored); err == nil { - if bytes.Equal(stored, containerIDBlob) { - return nil - } - } - - _, err := w.reader.db.Exec(`UPDATE Unit SET ContainerID = ? WHERE UnitID = ?`, containerIDBlob, unitIDBlob) - if err == nil { - w.writesLanded++ - w.reader.InvalidateCache() - } - return err -} - -// AddAttribute adds an attribute to an entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) AddAttribute(domainModelID model.ID, entityID model.ID, attr *domainmodel.Attribute) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find the entity - for _, e := range dm.Entities { - if e.ID == entityID { - if attr.ID == "" { - attr.ID = model.ID(generateUUID()) - } - attr.TypeName = "DomainModels$Attribute" - attr.ContainerID = entityID - e.Attributes = append(e.Attributes, attr) - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("entity not found: %s", entityID) -} - -// UpdateAttribute updates an existing attribute in an entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) UpdateAttribute(domainModelID model.ID, entityID model.ID, attr *domainmodel.Attribute) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find the entity - for _, e := range dm.Entities { - if e.ID == entityID { - // Find and update the attribute - for i, a := range e.Attributes { - if a.ID == attr.ID { - e.Attributes[i] = attr - return w.updateDomainModel(dm) - } - } - return fmt.Errorf("attribute not found: %s", attr.ID) - } - } - - return fmt.Errorf("entity not found: %s", entityID) -} - -// DeleteAttribute deletes an attribute from an entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) DeleteAttribute(domainModelID model.ID, entityID model.ID, attrID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find the entity - for _, e := range dm.Entities { - if e.ID == entityID { - // Find and remove the attribute - for i, a := range e.Attributes { - if a.ID == attrID { - e.Attributes = append(e.Attributes[:i], e.Attributes[i+1:]...) - return w.updateDomainModel(dm) - } - } - return fmt.Errorf("attribute not found: %s", attrID) - } - } - - return fmt.Errorf("entity not found: %s", entityID) -} - -// CreateAssociation creates a new association between entities. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) CreateAssociation(domainModelID model.ID, assoc *domainmodel.Association) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - if assoc.ID == "" { - assoc.ID = model.ID(generateUUID()) - } - assoc.TypeName = "DomainModels$Association" - assoc.ContainerID = domainModelID - - dm.Associations = append(dm.Associations, assoc) - return w.updateDomainModel(dm) -} - -// CreateCrossAssociation creates a cross-module association in a domain model. -// The parent entity must be local to this domain model; the child entity is -// referenced by qualified name (BY_NAME) since it lives in another module. -func (w *Writer) CreateCrossAssociation(domainModelID model.ID, ca *domainmodel.CrossModuleAssociation) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - if ca.ID == "" { - ca.ID = model.ID(generateUUID()) - } - ca.TypeName = "DomainModels$CrossAssociation" - ca.ContainerID = domainModelID - - dm.CrossAssociations = append(dm.CrossAssociations, ca) - return w.updateDomainModel(dm) -} - -// DeleteAssociation deletes an association. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) DeleteAssociation(domainModelID model.ID, assocID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - for i, a := range dm.Associations { - if a.ID == assocID { - dm.Associations = append(dm.Associations[:i], dm.Associations[i+1:]...) - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("association not found: %s", assocID) -} - -// DeleteCrossAssociation removes a cross-module association from a domain model. -func (w *Writer) DeleteCrossAssociation(domainModelID model.ID, assocID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - for i, ca := range dm.CrossAssociations { - if ca.ID == assocID { - dm.CrossAssociations = append(dm.CrossAssociations[:i], dm.CrossAssociations[i+1:]...) - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("cross-module association not found: %s", assocID) -} - -// CreateViewEntitySourceDocument creates a ViewEntitySourceDocument for a view entity. -// This is a separate document that contains the OQL query for the view entity. -func (w *Writer) CreateViewEntitySourceDocument(moduleID model.ID, moduleName, docName, oqlQuery, documentation string) (model.ID, error) { - docID := model.ID(generateUUID()) - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(docID))}, - {Key: "$Type", Value: "DomainModels$ViewEntitySourceDocument"}, - {Key: "Documentation", Value: documentation}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Name", Value: docName}, - {Key: "Oql", Value: oqlQuery}, - } - - contents, err := marshalUnitIDFirst(doc) - if err != nil { - return "", fmt.Errorf("failed to serialize ViewEntitySourceDocument: %w", err) - } - - if err := w.insertUnit(string(docID), string(moduleID), "Documents", "DomainModels$ViewEntitySourceDocument", contents); err != nil { - return "", fmt.Errorf("failed to insert ViewEntitySourceDocument: %w", err) - } - - return docID, nil -} - -// DeleteViewEntitySourceDocument deletes a ViewEntitySourceDocument. -func (w *Writer) DeleteViewEntitySourceDocument(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// FindViewEntitySourceDocumentID finds a ViewEntitySourceDocument by module and document name. -// Returns the document ID if found, empty string if not found. -func (w *Writer) FindViewEntitySourceDocumentID(moduleName, docName string) (model.ID, error) { - units, err := w.reader.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return "", err - } - - // Build module ID -> name map - modules, err := w.reader.ListModules() - if err != nil { - return "", err - } - moduleNames := make(map[string]string) - for _, m := range modules { - moduleNames[string(m.ID)] = m.Name - } - - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - modName := moduleNames[u.ContainerID] - - if modName == moduleName && name == docName { - return model.ID(u.ID), nil - } - } - - return "", nil // Not found -} - -// DeleteViewEntitySourceDocumentByName deletes ALL ViewEntitySourceDocuments matching the -// given module and document name. This handles cleanup of duplicate documents that may -// have accumulated from previous script runs or incomplete deletions. -// Returns nil if documents were deleted or none existed. -func (w *Writer) DeleteViewEntitySourceDocumentByName(moduleName, docName string) error { - docIDs, err := w.FindAllViewEntitySourceDocumentIDs(moduleName, docName) - if err != nil { - return err - } - for _, docID := range docIDs { - if err := w.deleteUnit(string(docID)); err != nil { - return err - } - } - return nil -} - -// FindAllViewEntitySourceDocumentIDs finds ALL ViewEntitySourceDocuments matching the -// given module and document name. Returns all matching IDs (not just the first). -func (w *Writer) FindAllViewEntitySourceDocumentIDs(moduleName, docName string) ([]model.ID, error) { - units, err := w.reader.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return nil, err - } - - // Build module ID -> name map - modules, err := w.reader.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[string]string) - for _, m := range modules { - moduleNames[string(m.ID)] = m.Name - } - - var ids []model.ID - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - modName := moduleNames[u.ContainerID] - - if modName == moduleName && name == docName { - ids = append(ids, model.ID(u.ID)) - } - } - - return ids, nil -} -func (w *Writer) serializeDomainModel(dm *domainmodel.DomainModel) ([]byte, error) { - // Look up module name for qualified names in validation rules - moduleName := "" - if dm.ContainerID != "" { - module, err := w.reader.GetModule(dm.ContainerID) - if err == nil && module != nil { - moduleName = module.Name - } - } - - // Entities array with version prefix 3 - pv := w.reader.ProjectVersion() - entities := bson.A{int32(3)} - for _, e := range dm.Entities { - entities = append(entities, serializeEntity(e, moduleName, pv)) - } - - // Associations array with version prefix 3 - associations := bson.A{int32(3)} - for _, a := range dm.Associations { - associations = append(associations, serializeAssociation(a)) - } - - // Annotations array with version prefix 3. - // - // This used to be written as the bare empty array regardless of what the - // domain model held, so every rewrite deleted every note on the canvas — - // adding one entity to a blank app took its annotation count from 1 to 0. - // `mx check` reports 0 errors either way: an annotation is decorative, so - // nothing below Studio Pro can see it go. - annotations := bson.A{int32(3)} - for _, a := range dm.Annotations { - annotations = append(annotations, serializeDomainModelAnnotation(a)) - } - - // CrossAssociations array with version prefix 3 - crossAssociations := bson.A{int32(3)} - for _, ca := range dm.CrossAssociations { - crossAssociations = append(crossAssociations, serializeCrossAssociation(ca)) - } - - // Use bson.D (ordered) so $Type appears early — Mendix requires this for correct parsing - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dm.ID))}, - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "Documentation", Value: ""}, - {Key: "Annotations", Value: annotations}, - {Key: "Entities", Value: entities}, - {Key: "Associations", Value: associations}, - {Key: "CrossAssociations", Value: crossAssociations}, - } - return marshalUnitIDFirst(doc) -} - -func serializeEntity(e *domainmodel.Entity, moduleName string, pv *version.ProjectVersion) bson.D { - // Any of the three OData source types means the attributes need OData - // mapped value serialization (Rest$ODataMappedValue or its primitive - // collection variant), not the regular DomainModels$StoredValue. - isExternal := e.Source == "Rest$ODataRemoteEntitySource" || - e.Source == "Rest$ODataEntityTypeSource" || - e.Source == "Rest$ODataPrimitiveCollectionEntitySource" - - // Attributes array with version prefix 3 - attrs := bson.A{int32(3)} - for _, a := range e.Attributes { - attrs = append(attrs, serializeAttribute(a, isExternal)) - } - - // Indexes array with version prefix 3 - indexes := bson.A{int32(3)} - for _, idx := range e.Indexes { - indexes = append(indexes, serializeIndex(idx)) - } - - // ValidationRules array with version prefix 3 - validationRules := bson.A{int32(3)} - for _, vr := range e.ValidationRules { - validationRules = append(validationRules, serializeValidationRule(vr, moduleName, e)) - } - - // Generate a GUID for the entity if not present (used for qualified name) - entityGUID := idToBsonBinary(string(e.ID)) - - // Location is stored as "x;y" string format - location := fmt.Sprintf("%d;%d", e.Location.X, e.Location.Y) - - // Serialize generalization: either a parent entity reference or NoGeneralization - var maybeGeneralization bson.D - if e.GeneralizationRef != "" { - maybeGeneralization = serializeGeneralization(e.GeneralizationRef) - } else { - maybeGeneralization = serializeNoGeneralization(e, pv) - } - - // AccessRules array with version prefix 3 - accessRules := bson.A{int32(3)} - for _, ar := range e.AccessRules { - accessRules = append(accessRules, serializeAccessRule(ar)) - } - - // Use bson.D (ordered document) to match Studio Pro field order - // Mendix 11.12 requires "$ID" to be the first property of every storage object - // ("$Type" conventionally second); it rejects the unit otherwise. Remaining - // fields keep Studio Pro's order. - // CRITICAL: Attributes MUST come before ValidationRules for attribute lookup to work - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(e.ID))}, - {Key: "$Type", Value: "DomainModels$EntityImpl"}, - {Key: "Name", Value: e.Name}, - {Key: "Documentation", Value: e.Documentation}, - {Key: "MaybeGeneralization", Value: maybeGeneralization}, - {Key: "Attributes", Value: attrs}, // Must come before ValidationRules! - {Key: "AccessRules", Value: accessRules}, - {Key: "ValidationRules", Value: validationRules}, // After Attributes - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: entityGUID}, - {Key: "Location", Value: location}, - {Key: "Indexes", Value: indexes}, - {Key: "Events", Value: serializeEventHandlers(e.EventHandlers)}, - } - - // Add Source for view entities (references a ViewEntitySourceDocument) - if e.Source == "DomainModels$OqlViewEntitySource" && e.SourceDocumentRef != "" { - doc = append(doc, bson.E{Key: "Source", Value: serializeOqlViewEntitySource(e.SourceObjectID, e.SourceDocumentRef, e.OqlQuery, pv)}) - } - - // Add Source for external entities (OData remote entity source) - if e.Source == "Rest$ODataRemoteEntitySource" && e.RemoteServiceName != "" { - doc = append(doc, bson.E{Key: "Source", Value: serializeODataRemoteEntitySource(e)}) - } - - // Source for entity-type-only external entities (derived/abstract/contained types - // that have no entity set, e.g. PlanItem, Flight, Trip) - if e.Source == "Rest$ODataEntityTypeSource" && e.RemoteServiceName != "" { - doc = append(doc, bson.E{Key: "Source", Value: serializeODataEntityTypeSource(e)}) - } - - // Source for primitive collection NPEs (e.g. TripTag for Trip.Tags = Collection(Edm.String)) - if e.Source == "Rest$ODataPrimitiveCollectionEntitySource" && e.RemoteServiceName != "" { - doc = append(doc, bson.E{Key: "Source", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataPrimitiveCollectionEntitySource"}, - {Key: "SourceDocument", Value: e.RemoteServiceName}, - }}) - } - - return doc -} - -// serializeODataEntityTypeSource emits Rest$ODataEntityTypeSource for an entity -// that maps to an OData entity type but has no entity set (e.g. derived, -// abstract, or contained nav target). It carries only the type name, key, and -// SourceDocument — no CRUD or paging fields. -func serializeODataEntityTypeSource(e *domainmodel.Entity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataEntityTypeSource"}, - {Key: "EntityTypeName", Value: e.RemoteEntityName}, - {Key: "IsOpen", Value: e.IsOpen}, - } - - if len(e.RemoteKeyParts) > 0 { - parts := bson.A{int32(2)} - for _, kp := range e.RemoteKeyParts { - parts = append(parts, serializeODataKeyPart(kp)) - } - doc = append(doc, bson.E{Key: "Key", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataKey"}, - {Key: "Parts", Value: parts}, - }}) - } - - doc = append(doc, bson.E{Key: "SourceDocument", Value: e.RemoteServiceName}) - return doc -} - -// serializeEventHandlers serializes a list of EventHandlers to a BSON array. -// Returns [int32(3)] for empty (storageListType 3 = stored object list). -func serializeEventHandlers(handlers []*domainmodel.EventHandler) bson.A { - arr := bson.A{int32(3)} - for _, eh := range handlers { - arr = append(arr, serializeEventHandler(eh)) - } - return arr -} - -// serializeEventHandler serializes a single EventHandler to BSON. -// $Type is "DomainModels$EntityEvent". Microflow uses BY_NAME (string) reference. -func serializeEventHandler(eh *domainmodel.EventHandler) bson.D { - ehID := string(eh.ID) - if ehID == "" { - ehID = generateUUID() - } - moment := string(eh.Moment) - if moment == "" { - moment = "Before" - } - event := string(eh.Event) - if event == "" { - event = "Commit" - } - // BY_NAME reference for the microflow - var microflowRef interface{} - if eh.MicroflowName != "" { - microflowRef = eh.MicroflowName - } else if eh.MicroflowID != "" { - microflowRef = idToBsonBinary(string(eh.MicroflowID)) - } else { - microflowRef = "" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(ehID)}, - {Key: "$Type", Value: "DomainModels$EntityEvent"}, - {Key: "Microflow", Value: microflowRef}, - {Key: "Moment", Value: moment}, - {Key: "RaiseErrorOnFalse", Value: eh.RaiseErrorOnFalse}, - {Key: "SendInputParameter", Value: eh.PassEventObject}, - {Key: "Type", Value: event}, - } -} - -func serializeAccessRule(ar *domainmodel.AccessRule) bson.D { - // AllowedModuleRoles: storageListType 1 (BY_NAME references) - roles := bson.A{int32(1)} - for _, name := range ar.ModuleRoleNames { - roles = append(roles, name) - } - - // MemberAccesses: storageListType 3 - memberAccesses := bson.A{int32(3)} - for _, ma := range ar.MemberAccesses { - memberAccesses = append(memberAccesses, serializeMemberAccess(ma)) - } - - ruleID := string(ar.ID) - if ruleID == "" { - ruleID = generateUUID() - } - - defaultMemberAccess := string(ar.DefaultMemberAccessRights) - if defaultMemberAccess == "" { - defaultMemberAccess = "None" - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(ruleID)}, - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowedModuleRoles", Value: roles}, - {Key: "AllowCreate", Value: ar.AllowCreate}, - {Key: "AllowDelete", Value: ar.AllowDelete}, - {Key: "DefaultMemberAccessRights", Value: defaultMemberAccess}, - {Key: "XPathConstraint", Value: ar.XPathConstraint}, - {Key: "XPathConstraintCaption", Value: ""}, - {Key: "Documentation", Value: ""}, - {Key: "MemberAccesses", Value: memberAccesses}, - } -} - -func serializeMemberAccess(ma *domainmodel.MemberAccess) bson.D { - maID := string(ma.ID) - if maID == "" { - maID = generateUUID() - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(maID)}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: string(ma.AccessRights)}, - } - - // Attribute reference (BY_NAME) - if ma.AttributeName != "" { - doc = append(doc, bson.E{Key: "Attribute", Value: ma.AttributeName}) - } - - // Association reference (BY_NAME) - if ma.AssociationName != "" { - doc = append(doc, bson.E{Key: "Association", Value: ma.AssociationName}) - } - - return doc -} - -func serializeNoGeneralization(e *domainmodel.Entity, pv *version.ProjectVersion) bson.D { - // Persistability rules for external entities, verified against Studio Pro - // reference projects: - // Rest$ODataRemoteEntitySource → Persistable=true - // Rest$ODataEntityTypeSource → Persistable=false - // Rest$ODataPrimitiveCollectionEntitySource → Persistable=false - persistable := e.Persistable - switch e.Source { - case "Rest$ODataRemoteEntitySource": - persistable = true - case "Rest$ODataEntityTypeSource", "Rest$ODataPrimitiveCollectionEntitySource": - persistable = false - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$NoGeneralization"}, - {Key: "Persistable", Value: persistable}, - } - // Mendix >= 11.9 renamed HasOwner → HasOwnerAttr, etc. - useAttrSuffix := pv != nil && pv.IsAtLeast(11, 9) - ownerKey, changedByKey, changedDateKey, createdDateKey := "HasOwner", "HasChangedBy", "HasChangedDate", "HasCreatedDate" - if useAttrSuffix { - ownerKey, changedByKey, changedDateKey, createdDateKey = "HasOwnerAttr", "HasChangedByAttr", "HasChangedDateAttr", "HasCreatedDateAttr" - } - if e.HasOwner { - doc = append(doc, bson.E{Key: ownerKey, Value: true}) - } - if e.HasChangedBy { - doc = append(doc, bson.E{Key: changedByKey, Value: true}) - } - if e.HasChangedDate { - doc = append(doc, bson.E{Key: changedDateKey, Value: true}) - } - if e.HasCreatedDate { - doc = append(doc, bson.E{Key: createdDateKey, Value: true}) - } - return doc -} - -func serializeGeneralization(parentRef string) bson.D { - // Generalization stores the parent entity as a BY_NAME qualified name string - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$Generalization"}, - {Key: "Generalization", Value: parentRef}, - } -} - -func serializeOqlViewEntitySource(sourceObjectID model.ID, sourceDocumentRef, oqlQuery string, pv *version.ProjectVersion) bson.D { - id := string(sourceObjectID) - if id == "" { - id = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DomainModels$OqlViewEntitySource"}, - } - // Mendix 10.x stores the OQL query inline on the source object (reflection data: 10.21 has "Oql" property). - // Mendix 11.0+ removed this field; only the ViewEntitySourceDocument stores the OQL. - if !pv.IsAtLeast(11, 0) { - doc = append(doc, bson.E{Key: "Oql", Value: oqlQuery}) - } - doc = append(doc, bson.E{Key: "SourceDocument", Value: sourceDocumentRef}) - return doc -} - -func serializeODataRemoteEntitySource(e *domainmodel.Entity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataRemoteEntitySource"}, - {Key: "Countable", Value: e.Countable}, - {Key: "Creatable", Value: e.Creatable}, - {Key: "CreateChangeLocally", Value: e.CreateChangeLocally}, - {Key: "Deletable", Value: e.Deletable}, - {Key: "EntitySet", Value: e.RemoteEntitySet}, - } - - // Key with KeyParts (storageListType 2) - if len(e.RemoteKeyParts) > 0 { - parts := bson.A{int32(2)} - for _, kp := range e.RemoteKeyParts { - parts = append(parts, serializeODataKeyPart(kp)) - } - key := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataKey"}, - {Key: "Parts", Value: parts}, - } - doc = append(doc, bson.E{Key: "Key", Value: key}) - } - - doc = append(doc, - bson.E{Key: "RemoteName", Value: e.RemoteEntityName}, - bson.E{Key: "SkipSupported", Value: e.SkipSupported}, - bson.E{Key: "SourceDocument", Value: e.RemoteServiceName}, - bson.E{Key: "TopSupported", Value: e.TopSupported}, - ) - return doc -} - -func serializeODataKeyPart(kp *domainmodel.RemoteKeyPart) bson.D { - // Build the type sub-document, similar to serializeAttribute's NewType - typeName := "DomainModels$StringAttributeType" - if kp.Type != nil { - typeName = "DomainModels$" + kp.Type.GetTypeName() + "AttributeType" - } - typeDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: typeName}, - } - if t, ok := kp.Type.(*domainmodel.StringAttributeType); ok { - typeDoc = append(typeDoc, bson.E{Key: "Length", Value: t.Length}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataKeyPart"}, - {Key: "EntityKeyPartName", Value: kp.Name}, - {Key: "Filterable", Value: true}, - {Key: "Name", Value: kp.RemoteName}, - {Key: "RemoteType", Value: kp.RemoteType}, - {Key: "Type", Value: typeDoc}, - } -} - -func serializeAttribute(a *domainmodel.Attribute, isExternalEntity bool) bson.D { - // Attribute type with its own ID - use bson.D for ordered fields - typeName := "DomainModels$StringAttributeType" - if a.Type != nil { - switch a.Type.(type) { - case *domainmodel.DateAttributeType: - // Date is stored as DateTimeAttributeType with LocalizeDate=false - typeName = "DomainModels$DateTimeAttributeType" - default: - typeName = "DomainModels$" + a.Type.GetTypeName() + "AttributeType" - } - } - - attrTypeID := generateUUID() - if a.Type != nil { - if elem, ok := a.Type.(model.Element); ok && elem.GetID() != "" { - attrTypeID = string(elem.GetID()) - } - } - attrType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(attrTypeID)}, - {Key: "$Type", Value: typeName}, - } - // Add type-specific properties - if a.Type != nil { - switch t := a.Type.(type) { - case *domainmodel.StringAttributeType: - attrType = append(attrType, bson.E{Key: "Length", Value: t.Length}) - case *domainmodel.DateTimeAttributeType: - attrType = append(attrType, bson.E{Key: "LocalizeDate", Value: t.LocalizeDate}) - case *domainmodel.DateAttributeType: - attrType = append(attrType, bson.E{Key: "LocalizeDate", Value: false}) - case *domainmodel.EnumerationAttributeType: - // Enumeration uses BY_NAME_REFERENCE - store as qualified name string - enumRef := t.EnumerationRef - if enumRef == "" && t.EnumerationID != "" { - // Fall back to ID if no ref (though this shouldn't happen for new entities) - enumRef = string(t.EnumerationID) - } - attrType = append(attrType, bson.E{Key: "Enumeration", Value: enumRef}) - } - } - - // Determine value type: OqlViewValue, CalculatedValue, ODataMappedValue, or StoredValue - var valueDoc bson.D - valueID := "" - if a.Value != nil && a.Value.ID != "" { - valueID = string(a.Value.ID) - } - if valueID == "" { - valueID = generateUUID() - } - if a.Value != nil && a.Value.ViewReference != "" { - // View entity attribute - use OqlViewValue - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "DomainModels$OqlViewValue"}, - {Key: "Reference", Value: a.Value.ViewReference}, - } - } else if a.Value != nil && a.Value.Type == "CalculatedValue" { - // Calculated attribute - use CalculatedValue (Microflow is ByNameReference → string) - microflowRef := a.Value.MicroflowName - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$CalculatedValue"}, - {Key: "Microflow", Value: microflowRef}, - {Key: "PassEntity", Value: microflowRef != ""}, - } - } else if isExternalEntity && a.IsPrimitiveCollection { - // Single attribute of a primitive collection NPE (e.g. TripTag.Tag) - defaultValue := "" - if a.Value != nil && a.Value.DefaultValue != "" { - defaultValue = a.Value.DefaultValue - } - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "Rest$ODataMappedPrimitiveCollectionValue"}, - {Key: "DefaultValueDesignTime", Value: defaultValue}, - {Key: "RemoteName", Value: a.RemoteName}, - {Key: "RemoteType", Value: a.RemoteType}, - } - } else if isExternalEntity && a.RemoteName != "" { - // External entity attribute backed by an OData property - use ODataMappedValue - defaultValue := "" - if a.Value != nil && a.Value.DefaultValue != "" { - defaultValue = a.Value.DefaultValue - } - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "Rest$ODataMappedValue"}, - {Key: "Creatable", Value: a.Creatable}, - {Key: "DefaultValueDesignTime", Value: defaultValue}, - {Key: "Filterable", Value: a.Filterable}, - {Key: "RemoteName", Value: a.RemoteName}, - {Key: "RemoteType", Value: a.RemoteType}, - {Key: "RepresentsStream", Value: false}, - {Key: "Sortable", Value: a.Sortable}, - {Key: "Updatable", Value: a.Updatable}, - } - } else { - // Regular entity attribute - use StoredValue - defaultValue := "" - if a.Value != nil && a.Value.DefaultValue != "" { - defaultValue = a.Value.DefaultValue - } - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "DefaultValue", Value: defaultValue}, - } - } - - // Mendix 11.12 requires "$ID" first, "$Type" second; remaining fields keep - // Studio Pro's order (Name, Documentation, ExportLevel, GUID, NewType, Value). - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "DomainModels$Attribute"}, - {Key: "Name", Value: a.Name}, - {Key: "Documentation", Value: a.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: idToBsonBinary(string(a.ID))}, - {Key: "NewType", Value: attrType}, - {Key: "Value", Value: valueDoc}, - } -} - -func serializeAssociation(a *domainmodel.Association) bson.D { - storageFormat := string(a.StorageFormat) - if storageFormat == "" { - storageFormat = "Column" - } - - var source any - switch a.Source { - case "Rest$ODataRemoteAssociationSource": - nav := a.Navigability2 - if nav == "" { - nav = "ParentToChild" - } - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataRemoteAssociationSource"}, - {Key: "CreatableFromChild", Value: a.CreatableFromChild}, - {Key: "CreatableFromParent", Value: a.CreatableFromParent}, - {Key: "Navigability2", Value: nav}, - {Key: "RemoteChildNavigationProperty", Value: a.RemoteChildNavigationProperty}, - {Key: "RemoteParentNavigationProperty", Value: a.RemoteParentNavigationProperty}, - {Key: "UpdatableFromChild", Value: a.UpdatableFromChild}, - {Key: "UpdatableFromParent", Value: a.UpdatableFromParent}, - } - case "Rest$ODataPrimitiveCollectionAssociationSource": - // Studio Pro emits this with no extra fields — it's a marker that - // pairs with Rest$ODataPrimitiveCollectionEntitySource on the child. - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataPrimitiveCollectionAssociationSource"}, - } - case domainmodel.OqlViewAssociationSource: - source = oqlViewAssociationSourceDoc(a.ViewSourceReference) - default: - source = nil - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "DomainModels$Association"}, - {Key: "Name", Value: a.Name}, - {Key: "Documentation", Value: a.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: idToBsonBinary(string(a.ID))}, - {Key: "ParentPointer", Value: idToBsonBinary(string(a.ParentID))}, - {Key: "ChildPointer", Value: idToBsonBinary(string(a.ChildID))}, - {Key: "Type", Value: string(a.Type)}, - {Key: "Owner", Value: string(a.Owner)}, - {Key: "ParentConnection", Value: domainmodel.FormatConnectionPoint(a.ParentConnection, domainmodel.DefaultParentConnection)}, - {Key: "ChildConnection", Value: domainmodel.FormatConnectionPoint(a.ChildConnection, domainmodel.DefaultChildConnection)}, - {Key: "StorageFormat", Value: storageFormat}, - {Key: "DeleteBehavior", Value: serializeDeleteBehavior(a.ParentDeleteBehavior, a.ChildDeleteBehavior)}, - {Key: "Source", Value: source}, - } -} - -func serializeCrossAssociation(ca *domainmodel.CrossModuleAssociation) bson.D { - storageFormat := string(ca.StorageFormat) - if storageFormat == "" { - storageFormat = "Column" - } - // CrossAssociation does NOT have ParentConnection/ChildConnection properties - // (unlike Association). Writing them causes Studio Pro to crash with - // InvalidOperationException in MprProperty..ctor. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ca.ID))}, - {Key: "$Type", Value: "DomainModels$CrossAssociation"}, - {Key: "Name", Value: ca.Name}, - {Key: "Documentation", Value: ca.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: idToBsonBinary(string(ca.ID))}, - {Key: "ParentPointer", Value: idToBsonBinary(string(ca.ParentID))}, - {Key: "Child", Value: ca.ChildRef}, - {Key: "Type", Value: string(ca.Type)}, - {Key: "Owner", Value: string(ca.Owner)}, - {Key: "StorageFormat", Value: storageFormat}, - {Key: "Source", Value: crossAssociationSource(ca)}, - {Key: "DeleteBehavior", Value: serializeDeleteBehavior(ca.ParentDeleteBehavior, ca.ChildDeleteBehavior)}, - } -} - -// oqlViewAssociationSourceDoc builds that subdocument. Three keys and no more — -// the shape is pinned against a Studio Pro document (ako/TestApp, 11.14) and -// re-measured here on 11.13.0. -func oqlViewAssociationSourceDoc(reference string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: domainmodel.OqlViewAssociationSource}, - {Key: "Reference", Value: reference}, - } -} - -func crossAssociationSource(ca *domainmodel.CrossModuleAssociation) any { - if ca.Source == domainmodel.OqlViewAssociationSource { - return oqlViewAssociationSourceDoc(ca.ViewSourceReference) - } - // A CrossAssociation has never carried an OData source — those live between - // external entities, which are not cross-module — so nil stays the default - // rather than being widened speculatively. - return nil -} - -func serializeDeleteBehavior(parentBehavior, childBehavior *domainmodel.DeleteBehavior) bson.D { - parentType := "DeleteMeButKeepReferences" - childType := "DeleteMeButKeepReferences" - - if parentBehavior != nil && parentBehavior.Type != "" { - parentType = string(parentBehavior.Type) - } - if childBehavior != nil && childBehavior.Type != "" { - childType = string(childBehavior.Type) - } - - // A "delete me if no references" child side carries the message the user sees - // when the delete is refused; every other behaviour leaves it null. Both were - // hardcoded null here, and for that one behaviour that produces a model whose - // RUNTIME will not start — `None.get` in SchemeFactory, with `mx check` - // reporting 0 errors either way (CapTrackV2 §1). - // - // Shape measured on a Studio Pro reference (ako/TestApp, - // Mappings.Order_Customer): an ordinary Texts$Text, which serializeText - // already produces with the typed-array marker 3 this needs. - var childMessage any - if childType == string(domainmodel.DeleteBehaviorTypeDeleteMeIfNoReferences) { - msg := "" - if childBehavior != nil { - msg = childBehavior.ErrorMessage - } - childMessage = serializeText(&model.Text{Translations: map[string]string{"en_US": msg}}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DeleteBehavior"}, - {Key: "ChildDeleteBehavior", Value: childType}, - {Key: "ChildErrorMessage", Value: childMessage}, - {Key: "ParentDeleteBehavior", Value: parentType}, - {Key: "ParentErrorMessage", Value: nil}, - } -} - -// zeroGUID is the all-zero UUID Studio Pro writes for an unset GUID reference. -const zeroGUID = "00000000-0000-0000-0000-000000000000" - -func serializeIndex(idx *domainmodel.Index) bson.D { - // IndexedAttribute lists use typed-array marker 2 (NOT the domain-model - // default of 3) — verified against real Studio-Pro 11.x BSON - // (mx-test-projects/test7-app: IdxProbe). - attrs := bson.A{int32(2)} - for _, ia := range idx.Attributes { - attrs = append(attrs, serializeIndexAttribute(ia)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(idx.ID))}, - {Key: "$Type", Value: "DomainModels$EntityIndex"}, - {Key: "Attributes", Value: attrs}, - {Key: "GUID", Value: idToBsonBinary(string(idx.ID))}, - {Key: "IncludeInOffline", Value: false}, - } -} - -// serializeIndexAttribute emits the Studio-Pro 11.x index-segment shape: -// Ascending(bool)+Type("Normal")+AttributePointer, plus an all-zero -// AssociationPointer for an attribute-based segment. This replaces the stale -// "SortOrder" string the writer previously emitted — the legacy parser already -// reads Ascending (with a SortOrder fallback), so writer and parser are now -// aligned, and the output matches what Studio Pro produces. -func serializeIndexAttribute(ia *domainmodel.IndexAttribute) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ia.ID))}, - {Key: "$Type", Value: "DomainModels$IndexedAttribute"}, - {Key: "AttributePointer", Value: idToBsonBinary(string(ia.AttributeID))}, // BSON Binary like $ID - {Key: "AssociationPointer", Value: idToBsonBinary(zeroGUID)}, // zero GUID: attribute-based segment - {Key: "Ascending", Value: ia.Ascending}, - {Key: "Type", Value: "Normal"}, - } -} - -func serializeValidationRule(vr *domainmodel.ValidationRule, moduleName string, entity *domainmodel.Entity) bson.D { - // Look up attribute name from the entity's attributes using AttributeID - // The Attribute field uses BY_NAME_REFERENCE, so it must be a qualified name STRING - // Format: "ModuleName.EntityName.AttributeName" - // - // NOTE: AttributeID can be either: - // 1. A UUID (when entity was just created) - compare with attr.ID - // 2. A qualified name string (when entity was read from disk) - extract attr name and compare - attributeQualifiedName := "" - attrIDStr := string(vr.AttributeID) - - // Check if AttributeID is already a qualified name (contains dots) - if strings.Contains(attrIDStr, ".") { - // It's already a qualified name - use it directly - attributeQualifiedName = attrIDStr - } else { - // It's a UUID - look up the attribute name - for _, attr := range entity.Attributes { - if attr.ID == vr.AttributeID { - attributeQualifiedName = fmt.Sprintf("%s.%s.%s", moduleName, entity.Name, attr.Name) - break - } - } - } - - // Use bson.D (ordered document) to match Studio Pro's field order: - // $ID, $Type, Attribute, Message, RuleInfo - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(vr.ID))}, - {Key: "$Type", Value: "DomainModels$ValidationRule"}, - {Key: "Attribute", Value: attributeQualifiedName}, // BY_NAME_REFERENCE: qualified name STRING - } - - // Message comes before RuleInfo in Studio Pro's format - if vr.ErrorMessage != nil && len(vr.ErrorMessage.Translations) > 0 { - doc = append(doc, bson.E{Key: "Message", Value: serializeText(vr.ErrorMessage)}) - } - - // RuleInfo comes last - doc = append(doc, bson.E{Key: "RuleInfo", Value: serializeRuleInfo(vr)}) - - return doc -} - -// serializeRuleInfo returns the RuleInfo child for a rule type, or nil for a -// type this writer cannot reproduce. -// -// A nil return is a REFUSAL, not a default. The previous code fell back to -// RequiredRuleInfo for anything it did not recognise, which made an entity -// rewrite a silent downgrade: a RegEx rule came back as Required, the pattern -// reference gone and the field merely mandatory, with mxbuild none the wiser -// because both are valid rules. Callers must check reproducibleRuleType first. -// It takes the whole rule rather than its type, because the type alone does not -// determine the document: a RegEx rule IS its reference and a Range rule IS its -// bounds. Writing a bare RuleInfo for either produces a rule Mendix accepts and -// that constrains nothing — the same silent downgrade wearing the right type -// name — so a rule whose payload did not survive the read is refused too. -// -// Keys are STORAGE names. The regex reference is "RegExIdentifier", not the SDK -// name "RegularExpression": writing the latter makes mxbuild report CE0135 "No -// regular expression specified" (measured on 11.13.0). -func serializeRuleInfo(vr *domainmodel.ValidationRule) bson.D { - if vr == nil { - return nil - } - // Use bson.D (ordered document) - Studio Pro uses $ID first, then $Type - switch vr.Type { - case "Required", "": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$RequiredRuleInfo"}, - } - case "Unique": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$UniqueRuleInfo"}, - } - - case "RegEx": - info, ok := vr.Rule.(*domainmodel.RegexValidationRuleInfo) - if !ok || info.RegularExpressionQualifiedName == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$RegExRuleInfo"}, - {Key: "RegExIdentifier", Value: info.RegularExpressionQualifiedName}, - } - - case "Range": - info, ok := vr.Rule.(*domainmodel.RangeValidationRuleInfo) - if !ok { - return nil - } - typeOfRange, ok := rangeKindFor(info) - if !ok { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$RangeRuleInfo"}, - {Key: "TypeOfRange", Value: typeOfRange}, - {Key: "UseMinValue", Value: info.UseMinValue}, - {Key: "UseMaxValue", Value: info.UseMaxValue}, - } - if info.MinValue != nil { - doc = append(doc, bson.E{Key: "MinValue", Value: *info.MinValue}) - } - if info.MaxValue != nil { - doc = append(doc, bson.E{Key: "MaxValue", Value: *info.MaxValue}) - } - // A bound may point at another attribute instead of a literal. MDL - // cannot author that, but a stored rule must survive the rewrite. - if info.MinAttributeQualifiedName != "" { - doc = append(doc, bson.E{Key: "MinAttribute", Value: info.MinAttributeQualifiedName}) - } - if info.MaxAttributeQualifiedName != "" { - doc = append(doc, bson.E{Key: "MaxAttribute", Value: info.MaxAttributeQualifiedName}) - } - return doc - - default: - // MaxLength, EqualsTo — no model payload type, so a rewrite would lose - // them. Refuse instead. - return nil - } -} - -// rangeKindFor derives the TypeOfRange enum from which bounds are in use. -// Mendix has exactly three values and no strict inequality, so a range using -// neither bound has no representation and is refused. -func rangeKindFor(info *domainmodel.RangeValidationRuleInfo) (string, bool) { - switch { - case info.UseMinValue && info.UseMaxValue: - return string(metamodel.DomainModelsTypeOfRangeBetween), true - case info.UseMinValue: - return string(metamodel.DomainModelsTypeOfRangeGreaterThanOrEqualTo), true - case info.UseMaxValue: - return string(metamodel.DomainModelsTypeOfRangeSmallerThanOrEqualTo), true - default: - return "", false - } -} - -// reproducibleRule reports whether this writer can serialize a validation rule. -func reproducibleRule(vr *domainmodel.ValidationRule) bool { - return serializeRuleInfo(vr) != nil -} - -// validationRulesAreReproducible returns the first rule type this writer cannot -// serialize, so the caller can refuse the write instead of downgrading it. -func validationRulesAreReproducible(e *domainmodel.Entity) (string, bool) { - for _, vr := range e.ValidationRules { - if !reproducibleRule(vr) { - return vr.Type, false - } - } - return "", true -} - -func serializeText(text *model.Text) bson.D { - // Translations as Items array with version prefix 3 - // Use bson.D for ordered documents to match Studio Pro format - items := bson.A{int32(3)} - // Sort language keys for deterministic output - langs := make([]string, 0, len(text.Translations)) - for lang := range text.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, lang := range langs { - value := text.Translations[lang] - items = append(items, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: lang}, - {Key: "Text", Value: value}, - }) - } - - // Studio Pro order: $ID, $Type, Items - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(text.ID))}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: items}, - } -} - -// serializeDomainModelAnnotation writes one canvas note in the shape Studio Pro -// stores it — pinned against the annotation a blank 11.13.0 app ships with in -// MyFirstModule. -// -// The position is the string "x;y", NOT a sub-document: that is the same -// convention an entity's Location follows, and writing a sub-document is what -// the parser used to (wrongly) expect. ExportLevel is "Hidden" on every Studio -// Pro-authored annotation. -// -// There is no colour property. A domain model holds exactly four child -// collections — Annotations, Associations, CrossAssociations and Entities — so -// the "coloured section box" a modeller sees is this element, drawn in Studio -// Pro's own styling, and nothing about that styling is stored in the model. -func serializeDomainModelAnnotation(a *domainmodel.Annotation) bson.D { - id := string(a.ID) - if id == "" { - id = GenerateID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DomainModels$Annotation"}, - {Key: "Caption", Value: a.Caption}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Location", Value: fmt.Sprintf("%d;%d", a.Location.X, a.Location.Y)}, - {Key: "Width", Value: int32(a.Width)}, - } -} diff --git a/sdk/mpr/writer_domainmodel_test.go b/sdk/mpr/writer_domainmodel_test.go deleted file mode 100644 index dac37dbba6..0000000000 --- a/sdk/mpr/writer_domainmodel_test.go +++ /dev/null @@ -1,309 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================= -// Issue #50: CrossAssociation must NOT include ParentConnection/ChildConnection -// ============================================================================= - -// TestSerializeCrossAssociation_NoConnectionFields verifies that -// serializeCrossAssociation does NOT emit ParentConnection or ChildConnection. -// These properties only exist on DomainModels$Association, not on -// DomainModels$CrossAssociation. Writing them causes Studio Pro to crash with -// System.InvalidOperationException: Sequence contains no matching element. -func TestSerializeCrossAssociation_NoConnectionFields(t *testing.T) { - ca := &domainmodel.CrossModuleAssociation{ - Name: "Child_Parent", - ParentID: "parent-entity-id", - ChildRef: "OtherModule.Parent", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - ca.ID = "test-cross-assoc-id" - - result := dToM(serializeCrossAssociation(ca)) - - // Must NOT contain these fields - for key := range result { - if key == "ParentConnection" { - t.Error("serializeCrossAssociation must NOT include ParentConnection (only valid for Association)") - } - if key == "ChildConnection" { - t.Error("serializeCrossAssociation must NOT include ChildConnection (only valid for Association)") - } - } - - // Must contain all expected fields (exhaustive structural contract) - expectedKeys := []string{"$ID", "$Type", "Name", "Child", "ParentPointer", "Type", "Owner", - "Documentation", "ExportLevel", "GUID", "StorageFormat", "Source", "DeleteBehavior"} - for _, key := range expectedKeys { - if _, ok := result[key]; !ok { - t.Errorf("serializeCrossAssociation missing expected field %q", key) - } - } - - // $Type must be CrossAssociation - if got := result["$Type"]; got != "DomainModels$CrossAssociation" { - t.Errorf("$Type = %q, want %q", got, "DomainModels$CrossAssociation") - } -} - -// TestSerializeODataRemoteEntitySource_HasKeyAndMappedValues verifies that -// external entities serialized via serializeEntity produce: -// - Rest$ODataKey with Parts (fixes CE6010 "Key cannot be empty") -// - Rest$ODataMappedValue on each attribute (fixes CE6612 "attribute not supported") -// -// Regression guard for the bugs that caused 51+ Studio Pro errors when opening -// a project with external entities created by `CREATE EXTERNAL ENTITIES FROM`. -func TestSerializeODataRemoteEntitySource_HasKeyAndMappedValues(t *testing.T) { - entity := &domainmodel.Entity{ - Name: "Airlines", - Source: "Rest$ODataRemoteEntitySource", - RemoteServiceName: "TripPinTest.TripPinRW", - RemoteEntityName: "Airline", - RemoteEntitySet: "Airlines", - Persistable: true, - Creatable: true, - Countable: true, - SkipSupported: true, - TopSupported: true, - RemoteKeyParts: []*domainmodel.RemoteKeyPart{ - { - Name: "AirlineCode", - RemoteName: "AirlineCode", - RemoteType: "Edm.String", - Type: &domainmodel.StringAttributeType{Length: 100}, - }, - }, - Attributes: []*domainmodel.Attribute{ - { - BaseElement: model.BaseElement{ID: "attr-airlinecode"}, - Name: "AirlineCode", - RemoteName: "AirlineCode", - RemoteType: "Edm.String", - Filterable: true, - Sortable: true, - Creatable: true, - Type: &domainmodel.StringAttributeType{Length: 100}, - }, - { - BaseElement: model.BaseElement{ID: "attr-name"}, - Name: "Name", - RemoteName: "Name", - RemoteType: "Edm.String", - Filterable: true, - Sortable: true, - Type: &domainmodel.StringAttributeType{Length: 0}, - }, - }, - } - entity.ID = "entity-test-id" - - doc := serializeEntity(entity, "TripPinTest", nil) - - // Marshal to BSON map for inspection - raw, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - var m map[string]any - if err := bson.Unmarshal(raw, &m); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - - // Source must be Rest$ODataRemoteEntitySource - sourceRaw, ok := m["Source"] - if !ok { - t.Fatal("Source field missing from entity BSON") - } - source, ok := sourceRaw.(map[string]any) - if !ok { - t.Fatalf("Source: expected map, got %T", sourceRaw) - } - if got := source["$Type"]; got != "Rest$ODataRemoteEntitySource" { - t.Errorf("Source.$Type = %v, want Rest$ODataRemoteEntitySource", got) - } - - // CE6010: Key must be present with Rest$ODataKey type - keyRaw, ok := source["Key"] - if !ok { - t.Fatal("CE6010: Source.Key missing — Studio Pro reports 'Key cannot be empty'") - } - key, ok := keyRaw.(map[string]any) - if !ok { - t.Fatalf("Source.Key: expected map, got %T", keyRaw) - } - if got := key["$Type"]; got != "Rest$ODataKey" { - t.Errorf("Source.Key.$Type = %v, want Rest$ODataKey", got) - } - if key["Parts"] == nil { - t.Error("Source.Key.Parts is nil") - } - - // CE6612: Each attribute Value must be Rest$ODataMappedValue - attrItems := extractBsonArray(m["Attributes"]) - if len(attrItems) == 0 { - t.Fatal("Attributes array is empty") - } - for i, item := range attrItems { - attrMap, ok := item.(map[string]any) - if !ok { - continue - } - valueMap, ok := attrMap["Value"].(map[string]any) - if !ok { - t.Errorf("Attribute[%d].Value: expected map, got %T", i, attrMap["Value"]) - continue - } - if vType := valueMap["$Type"]; vType != "Rest$ODataMappedValue" { - t.Errorf("CE6612: Attribute[%d].Value.$Type = %v, want Rest$ODataMappedValue", i, vType) - } - } -} - -// TestSerializeAssociation_HasConnectionFields verifies that the regular -// serializeAssociation DOES include ParentConnection and ChildConnection -// (to ensure we didn't accidentally remove them from the wrong function). -func TestSerializeAssociation_HasConnectionFields(t *testing.T) { - a := &domainmodel.Association{ - Name: "Child_Parent", - ParentID: "parent-entity-id", - ChildID: "child-entity-id", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - a.ID = "test-assoc-id" - - result := dToM(serializeAssociation(a)) - - hasParentConn := false - hasChildConn := false - for key := range result { - if key == "ParentConnection" { - hasParentConn = true - } - if key == "ChildConnection" { - hasChildConn = true - } - } - - if !hasParentConn { - t.Error("serializeAssociation must include ParentConnection") - } - if !hasChildConn { - t.Error("serializeAssociation must include ChildConnection") - } -} - -// upstream #872: the legacy writer hardcoded the association's line anchors, so -// running any association write on the legacy engine destroyed whatever the -// developer had dragged the connector to in Studio Pro — exactly as the modelsdk -// engine did. Both engines share the semantic model, so both had to change; a -// fix in one is invisible to a user on the other (`--engine`/`MXCLI_ENGINE`). -func TestSerializeAssociation_PreservesConnectionPoints(t *testing.T) { - base := func() *domainmodel.Association { - a := &domainmodel.Association{ - Name: "Child_Parent", - ParentID: "parent-entity-id", - ChildID: "child-entity-id", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - a.ID = "test-assoc-id" - return a - } - - // Nothing stored → mxcli's defaults, so a brand-new association still gets a - // sensible connector rather than one pinned to the box's top-left corner. - plain := dToM(serializeAssociation(base())) - if got := plain["ParentConnection"]; got != domainmodel.DefaultParentConnection { - t.Errorf("ParentConnection = %v, want the default %q", got, domainmodel.DefaultParentConnection) - } - if got := plain["ChildConnection"]; got != domainmodel.DefaultChildConnection { - t.Errorf("ChildConnection = %v, want the default %q", got, domainmodel.DefaultChildConnection) - } - - // A read anchor goes back verbatim. {0,0} is included deliberately: it is a - // real anchor (top-left) and must not be mistaken for "unset". - tuned := base() - tuned.ParentConnection = &model.Point{X: 50, Y: 100} - tuned.ChildConnection = &model.Point{X: 0, Y: 0} - got := dToM(serializeAssociation(tuned)) - if got["ParentConnection"] != "50;100" { - t.Errorf("ParentConnection = %v, want \"50;100\" — a hand-tuned anchor was reset", got["ParentConnection"]) - } - if got["ChildConnection"] != "0;0" { - t.Errorf("ChildConnection = %v, want \"0;0\" — the zero point is a value, not an absence", got["ChildConnection"]) - } -} - -// TestSerializeAssociation_OqlViewSource pins the one field that makes an -// association to a VIEW ENTITY legal. Measured on Mendix 11.13.0: without it -// mxbuild reports CE6771 "It is not possible to create associations to/from -// View Entities" AND CE6770 on the view entity; adding exactly this three-key -// subdocument takes the same project to 0 errors. -func TestSerializeAssociation_OqlViewSource(t *testing.T) { - a := &domainmodel.Association{ - Name: "MeterRef", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - StorageFormat: domainmodel.StorageFormatColumn, - Source: domainmodel.OqlViewAssociationSource, - ViewSourceReference: "MeterRef", - } - got := dToM(serializeAssociation(a)) - m, ok := got["Source"].(bson.M) - if !ok { - t.Fatalf("Source is %T, want a subdocument", got["Source"]) - } - if m["$Type"] != domainmodel.OqlViewAssociationSource { - t.Errorf("$Type = %v", m["$Type"]) - } - if m["Reference"] != "MeterRef" { - t.Errorf("Reference = %v, want the OQL select alias", m["Reference"]) - } - // Three keys and no more — the shape is pinned against a Studio Pro document. - if len(m) != 3 { - t.Errorf("Source has %d keys, want exactly $ID/$Type/Reference: %v", len(m), m) - } - - // Control: an ordinary association still writes a null Source. Widening the - // switch must not start decorating every association. - plain := dToM(serializeAssociation(&domainmodel.Association{Name: "Plain"})) - if plain["Source"] != nil { - t.Errorf("a plain association got Source = %v, want nil", plain["Source"]) - } -} - -// A view entity pointing at an entity in another module is stored as a -// CrossAssociation, which is the shape the defect was reported in — so the two -// serializers have to carry the field together. -func TestSerializeCrossAssociation_OqlViewSource(t *testing.T) { - ca := &domainmodel.CrossModuleAssociation{ - Name: "persistent_order", - ChildRef: "Mappings.Order", - Source: domainmodel.OqlViewAssociationSource, - ViewSourceReference: "persistent_order", - } - m, ok := dToM(serializeCrossAssociation(ca))["Source"].(bson.M) - if !ok { - t.Fatalf("Source is %T, want a subdocument", dToM(serializeCrossAssociation(ca))["Source"]) - } - if m["$Type"] != domainmodel.OqlViewAssociationSource || m["Reference"] != "persistent_order" { - t.Errorf("cross-association Source = %v", m) - } - - // Control. - plain := dToM(serializeCrossAssociation(&domainmodel.CrossModuleAssociation{Name: "Plain"})) - if plain["Source"] != nil { - t.Errorf("a plain cross-association got Source = %v, want nil", plain["Source"]) - } -} diff --git a/sdk/mpr/writer_elision_test.go b/sdk/mpr/writer_elision_test.go deleted file mode 100644 index 63e46c0232..0000000000 --- a/sdk/mpr/writer_elision_test.go +++ /dev/null @@ -1,206 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "os" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// The legacy engine shares the modelsdk engine's no-op elision (ADR-0008 -// decision 1) rather than reimplementing it, because which engine ran is an -// --engine flag and must not be visible in a user's diff. -// -// These tests also pin the assumption the wiring rests on — that the unit id the -// writer is handed is the same form GetRawUnitBytes expects. That is invisible by -// inspection, and getting it wrong would fail silently as "unreadable, so write -// it": no elision, no error, no test failure. -// -// Two fixtures, deliberately: -// -// v1-project Mendix 9.24, MPR v1 — unit contents live in SQLite, a -// different branch of updateUnit from everything else. It has -// no microflows, so it carries the elision half only. -// expr-checker Mendix 11.6, MPR v2 — has microflows with a StableId, so it -// carries the identity half. -// -// Neither test may skip. A skipped test reports success and proves nothing, -// which is how #808 stayed green while broken. - -func copyProject(t *testing.T, srcDir, mprName string) string { - t.Helper() - dst := t.TempDir() - if err := os.CopyFS(dst, os.DirFS(srcDir)); err != nil { - t.Fatalf("copy %s: %v", srcDir, err) - } - return filepath.Join(dst, mprName) -} - -// aUnit returns the lowest-id unit of the given type, so the choice is stable -// across runs. An empty typeName accepts any unit. -func aUnit(t *testing.T, r *Reader, typeName string) (model.ID, []byte) { - t.Helper() - units, err := r.ListUnits() - if err != nil { - t.Fatalf("ListUnits: %v", err) - } - var bestID model.ID - var bestRaw []byte - for _, u := range units { - if typeName != "" && u.Type != typeName { - continue - } - raw, err := r.GetRawUnitBytes(u.ID) - if err != nil || len(raw) == 0 { - continue - } - if bestID == "" || u.ID < bestID { - bestID, bestRaw = u.ID, append([]byte(nil), raw...) - } - } - if bestID == "" { - t.Fatalf("fixture has no readable unit of type %q — this test cannot prove anything", typeName) - } - return bestID, bestRaw -} - -// reordered returns the same document with its top-level fields in reverse -// order: byte-different, canonically identical. It stands in for a rebuild -// without having to renumber a graph of element IDs by hand. -func reordered(t *testing.T, raw []byte) []byte { - t.Helper() - var d bson.D - if err := bson.Unmarshal(raw, &d); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(d) < 2 { - t.Fatalf("document has %d top-level fields; reordering cannot make it byte-different", len(d)) - } - rev := make(bson.D, 0, len(d)) - for i := len(d) - 1; i >= 0; i-- { - rev = append(rev, d[i]) - } - out, err := bson.Marshal(rev) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if bytes.Equal(out, raw) { - t.Fatal("reordering produced identical bytes; this fixture cannot exercise canonical elision") - } - return out -} - -func stableIDBytes(raw []byte) []byte { - var d bson.M - if err := bson.Unmarshal(raw, &d); err != nil { - return nil - } - b, ok := d["StableId"].(primitive.Binary) - if !ok { - return nil - } - return b.Data -} - -// TestLegacyUpdateUnit_ElidesCanonicallyEqualWrite covers the MPR v1 branch: -// contents in SQLite rather than .mxunit files. -func TestLegacyUpdateUnit_ElidesCanonicallyEqualWrite(t *testing.T) { - w, err := NewWriter(copyProject(t, "testdata/v1-project", "App.mpr")) - if err != nil { - t.Fatalf("NewWriter: %v", err) - } - defer w.Close() - - id, before := aUnit(t, w.reader, "") - if err := w.UpdateRawUnit(string(id), reordered(t, before)); err != nil { - t.Fatalf("UpdateRawUnit: %v", err) - } - after, err := w.reader.GetRawUnitBytes(id) - if err != nil { - t.Fatalf("GetRawUnitBytes: %v", err) - } - if !bytes.Equal(before, after) { - t.Errorf("a canonically-equal write was not elided: %d bytes -> %d bytes", len(before), len(after)) - } -} - -// The control: with elision off the same write must land, otherwise the test -// above is passing for a reason that has nothing to do with elision. -func TestLegacyUpdateUnit_ControlWritesWhenElisionOff(t *testing.T) { - t.Setenv("MXCLI_ALWAYS_WRITE", "1") - w, err := NewWriter(copyProject(t, "testdata/v1-project", "App.mpr")) - if err != nil { - t.Fatalf("NewWriter: %v", err) - } - defer w.Close() - - id, before := aUnit(t, w.reader, "") - if err := w.UpdateRawUnit(string(id), reordered(t, before)); err != nil { - t.Fatalf("UpdateRawUnit: %v", err) - } - after, err := w.reader.GetRawUnitBytes(id) - if err != nil { - t.Fatalf("GetRawUnitBytes: %v", err) - } - if bytes.Equal(before, after) { - t.Fatal("with elision disabled the write did not land — the elision test proves nothing") - } -} - -// TestLegacyUpdateUnit_StableIdOnlyDifferenceIsElided is the identity half, and -// the exact shape of re-running an unchanged microflow: the incoming document -// differs from storage only in a freshly minted StableId. The stored identity is -// carried in first, what remains compares equal, and nothing is written. -func TestLegacyUpdateUnit_StableIdOnlyDifferenceIsElided(t *testing.T) { - w, err := NewWriter(copyProject(t, "../../testdata/expr-checker", "minimal.mpr")) - if err != nil { - t.Fatalf("NewWriter: %v", err) - } - defer w.Close() - - id, before := aUnit(t, w.reader, "Microflows$Microflow") - original := stableIDBytes(before) - if len(original) != 16 { - t.Fatalf("fixture microflow %s has no 16-byte StableId; this test cannot prove preservation", id) - } - - var d bson.D - if err := bson.Unmarshal(before, &d); err != nil { - t.Fatalf("unmarshal: %v", err) - } - fresh := bytes.Repeat([]byte{0x5A}, 16) - replaced := false - for i := range d { - if d[i].Key == "StableId" { - d[i].Value = primitive.Binary{Subtype: 0x00, Data: fresh} - replaced = true - } - } - if !replaced { - t.Fatal("StableId not found as a top-level field") - } - mutated, err := bson.Marshal(d) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - if err := w.UpdateRawUnit(string(id), mutated); err != nil { - t.Fatalf("UpdateRawUnit: %v", err) - } - after, err := w.reader.GetRawUnitBytes(id) - if err != nil { - t.Fatalf("GetRawUnitBytes: %v", err) - } - if got := stableIDBytes(after); !bytes.Equal(got, original) { - t.Errorf("StableId = %x, want the stored %x", got, original) - } - if !bytes.Equal(before, after) { - t.Errorf("a write differing only in StableId should have been elided; stored bytes changed") - } -} diff --git a/sdk/mpr/writer_enumeration.go b/sdk/mpr/writer_enumeration.go deleted file mode 100644 index c03f861f2b..0000000000 --- a/sdk/mpr/writer_enumeration.go +++ /dev/null @@ -1,228 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "sort" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateEnumeration creates a new enumeration. -func (w *Writer) CreateEnumeration(enum *model.Enumeration) error { - if enum.ID == "" { - enum.ID = model.ID(generateUUID()) - } - enum.TypeName = "Enumerations$Enumeration" - - contents, err := w.serializeEnumeration(enum) - if err != nil { - return fmt.Errorf("failed to serialize enumeration: %w", err) - } - - return w.insertUnit(string(enum.ID), string(enum.ContainerID), "Documents", "Enumerations$Enumeration", contents) -} - -// UpdateEnumeration updates an existing enumeration. -func (w *Writer) UpdateEnumeration(enum *model.Enumeration) error { - contents, err := w.serializeEnumeration(enum) - if err != nil { - return fmt.Errorf("failed to serialize enumeration: %w", err) - } - - return w.updateUnit(string(enum.ID), contents) -} - -// MoveEnumeration moves an enumeration to a new container (module or folder). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MoveEnumeration(enum *model.Enumeration) error { - return w.moveUnitByID(string(enum.ID), string(enum.ContainerID)) -} - -// DeleteEnumeration deletes an enumeration. -func (w *Writer) DeleteEnumeration(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveConstant moves a constant to a new container (module or folder). -func (w *Writer) MoveConstant(constant *model.Constant) error { - return w.moveUnitByID(string(constant.ID), string(constant.ContainerID)) -} - -// CreateConstant creates a new constant. -func (w *Writer) CreateConstant(constant *model.Constant) error { - if constant.ID == "" { - constant.ID = model.ID(generateUUID()) - } - constant.TypeName = "Constants$Constant" - - contents, err := w.serializeConstant(constant) - if err != nil { - return fmt.Errorf("failed to serialize constant: %w", err) - } - - return w.insertUnit(string(constant.ID), string(constant.ContainerID), "Documents", "Constants$Constant", contents) -} - -// UpdateConstant updates an existing constant. -func (w *Writer) UpdateConstant(constant *model.Constant) error { - contents, err := w.serializeConstant(constant) - if err != nil { - return fmt.Errorf("failed to serialize constant: %w", err) - } - - return w.updateUnit(string(constant.ID), contents) -} - -// DeleteConstant deletes a constant. -func (w *Writer) DeleteConstant(id model.ID) error { - return w.deleteUnit(string(id)) -} -func (w *Writer) serializeEnumeration(enum *model.Enumeration) ([]byte, error) { - values := bson.A{int32(3)} // Version prefix - for _, v := range enum.Values { - valueID := string(v.ID) - if valueID == "" { - valueID = generateUUID() - } - captionID := generateUUID() - - // Build translation items (sorted for deterministic output) - translationItems := bson.A{int32(3)} - if v.Caption != nil { - langs := make([]string, 0, len(v.Caption.Translations)) - for lang := range v.Caption.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, langCode := range langs { - translationItems = append(translationItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: langCode}, - {Key: "Text", Value: v.Caption.Translations[langCode]}, - }) - } - } - - // Use bson.D (ordered) so $Type appears first — Mendix requires this for correct parsing - valueDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "Enumerations$EnumerationValue"}, - {Key: "Name", Value: v.Name}, - {Key: "Caption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(captionID)}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: translationItems}, - }}, - {Key: "Image", Value: ""}, - {Key: "RemoteValue", Value: nil}, - } - values = append(values, valueDoc) - } - - // Use bson.D (ordered) so $Type appears early — Mendix requires this for correct parsing - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(enum.ID))}, - {Key: "$Type", Value: "Enumerations$Enumeration"}, - {Key: "Name", Value: enum.Name}, - {Key: "Documentation", Value: enum.Documentation}, - {Key: "Excluded", Value: enum.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "RemoteSource", Value: nil}, - {Key: "Values", Value: values}, - } - return marshalUnitIDFirst(doc) -} - -func (w *Writer) serializeConstant(constant *model.Constant) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(constant.ID))}, - {Key: "$Type", Value: "Constants$Constant"}, - {Key: "Name", Value: constant.Name}, - {Key: "Documentation", Value: constant.Documentation}, - {Key: "Type", Value: serializeConstantDataType(constant.Type)}, - {Key: "DefaultValue", Value: constant.DefaultValue}, - {Key: "ExposedToClient", Value: constant.ExposedToClient}, - {Key: "Excluded", Value: constant.Excluded}, - {Key: "ExportLevel", Value: constant.ExportLevel}, - } - return marshalUnitIDFirst(doc) -} - -// serializeConstantDataType converts a ConstantDataType to BSON. -func serializeConstantDataType(dt model.ConstantDataType) bson.D { - typeID := idToBsonBinary(GenerateID()) - - switch dt.Kind { - case "String": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - case "Integer": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case "Long": - // Mendix uses IntegerType for both Integer and Long in BSON storage. - // DataTypes$LongType does not exist in the metamodel type cache. - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case "Decimal": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DecimalType"}, - } - case "Boolean": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BooleanType"}, - } - case "DateTime", "Date": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DateTimeType"}, - } - case "Binary": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BinaryType"}, - } - case "Float": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$FloatType"}, - } - case "Enumeration": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$EnumerationType"}, - {Key: "Enumeration", Value: dt.EnumRef}, - } - case "Object": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: dt.EntityRef}, - } - case "List": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ListType"}, - {Key: "Entity", Value: dt.EntityRef}, - } - default: - // Default to string type - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - } -} diff --git a/sdk/mpr/writer_export_mapping.go b/sdk/mpr/writer_export_mapping.go deleted file mode 100644 index a1e7d767f3..0000000000 --- a/sdk/mpr/writer_export_mapping.go +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateExportMapping creates a new export mapping document. -func (w *Writer) CreateExportMapping(em *model.ExportMapping) error { - if em.ID == "" { - em.ID = model.ID(generateUUID()) - } - em.TypeName = "ExportMappings$ExportMapping" - - contents, err := w.serializeExportMapping(em) - if err != nil { - return fmt.Errorf("failed to serialize export mapping: %w", err) - } - - return w.insertUnit(string(em.ID), string(em.ContainerID), "Documents", "ExportMappings$ExportMapping", contents) -} - -// UpdateExportMapping updates an existing export mapping document. -func (w *Writer) UpdateExportMapping(em *model.ExportMapping) error { - contents, err := w.serializeExportMapping(em) - if err != nil { - return fmt.Errorf("failed to serialize export mapping: %w", err) - } - return w.updateUnit(string(em.ID), contents) -} - -// DeleteExportMapping deletes an export mapping document. -func (w *Writer) DeleteExportMapping(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveExportMapping moves an export mapping to a new container. -func (w *Writer) MoveExportMapping(em *model.ExportMapping) error { - return w.moveUnitByID(string(em.ID), string(em.ContainerID)) -} - -func (w *Writer) serializeExportMapping(em *model.ExportMapping) ([]byte, error) { - elements := bson.A{int32(2)} - for _, elem := range em.Elements { - elements = append(elements, serializeExportMappingElement(elem, "(Object)")) - } - - exportLevel := em.ExportLevel - if exportLevel == "" { - exportLevel = "Hidden" - } - - nullValueOption := em.NullValueOption - if nullValueOption == "" { - nullValueOption = "LeaveOutElement" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(em.ID))}, - {Key: "$Type", Value: "ExportMappings$ExportMapping"}, - {Key: "Name", Value: em.Name}, - {Key: "Documentation", Value: em.Documentation}, - {Key: "Excluded", Value: em.Excluded}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "JsonStructure", Value: em.JsonStructure}, - {Key: "XmlSchema", Value: em.XmlSchema}, - {Key: "MessageDefinition", Value: em.MessageDefinition}, - {Key: "NullValueOption", Value: nullValueOption}, - {Key: "Elements", Value: elements}, - // Required fields with defaults — verified against Studio Pro-created BSON - {Key: "PublicName", Value: ""}, // Studio Pro writes "" not the mapping name - {Key: "XsdRootElementName", Value: ""}, - {Key: "IsHeaderParameter", Value: false}, - {Key: "ParameterName", Value: ""}, - {Key: "OperationName", Value: ""}, - {Key: "ServiceName", Value: ""}, - {Key: "WsdlFile", Value: ""}, - {Key: "MappingSourceReference", Value: nil}, - } - // MessageDefinition2 is version-introduced (11.10+) and CARRIED, never - // invented: adding the key to a document written before then is the shape - // mxbuild tolerates and Studio Pro refuses to open. nil means absent, which - // is not the same as present-and-empty (ako/mxcli#279). - if em.MessageDefinition2 != nil { - doc = append(doc, bson.E{Key: "MessageDefinition2", Value: *em.MessageDefinition2}) - } - return marshalUnitIDFirst(doc) -} - -func serializeExportMappingElement(elem *model.ExportMappingElement, parentPath string) bson.D { - id := string(elem.ID) - if id == "" { - id = generateUUID() - } - - if isMappingObjectKind(elem.Kind) { - return serializeExportObjectElement(id, elem, parentPath) - } - return serializeExportValueElement(id, elem, parentPath) -} - -func serializeExportObjectElement(id string, elem *model.ExportMappingElement, parentPath string) bson.D { - // Use pre-computed JsonPath from the executor (which knows the JSON structure element types). - // Fall back to a simple parentPath + "|" + ExposedName only when JsonPath was not set. - jsonPath := elem.JsonPath - if jsonPath == "" { - if elem.ExposedName == "" { - jsonPath = parentPath - } else { - jsonPath = parentPath + "|" + elem.ExposedName - } - } - - children := bson.A{int32(2)} - for _, child := range elem.Children { - children = append(children, serializeExportMappingElement(child, jsonPath)) - } - - // IMPORTANT: The correct $Type is "ExportMappings$ObjectMappingElement" (no "Export" prefix in the element name). - // The generated metamodel (ExportMappingsExportObjectMappingElement) is misleading — Studio Pro will throw - // TypeCacheUnknownTypeException if you use "ExportMappings$ExportObjectMappingElement". - // Same convention as ImportMappings: element types do NOT repeat the namespace prefix. - objectHandling := elem.ObjectHandling - if objectHandling == "" { - objectHandling = "Parameter" - } - - maxOccurs := int32(elem.MaxOccurs) - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ExportMappings$ObjectMappingElement"}, - {Key: "Entity", Value: elem.Entity}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "ObjectHandling", Value: objectHandling}, - // Every export object element in the demo apps stores Error (537 of - // 537), and the handling values are not in the backup enum (#261). - {Key: "ObjectHandlingBackup", Value: "Error"}, - {Key: "ObjectHandlingBackupAllowOverride", Value: false}, - {Key: "Association", Value: elem.Association}, - {Key: "Children", Value: children}, - // A schema ROOT has MinOccurs 1; hardcoding 0 lost that (#279). - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "MaxOccurs", Value: maxOccurs}, - {Key: "Nillable", Value: true}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: elementTypeForKind(elem.Kind)}, - {Key: "Documentation", Value: ""}, - {Key: "CustomHandlerCall", Value: nil}, - } -} - -func serializeExportValueElement(id string, elem *model.ExportMappingElement, parentPath string) bson.D { - dataType := serializeImportValueDataType(elem.DataType) // reuse — same DataTypes$* types - // Use pre-computed JsonPath when available, otherwise derive from parentPath. - jsonPath := elem.JsonPath - if jsonPath == "" { - jsonPath = parentPath + "|" + elem.ExposedName - } - - // IMPORTANT: "ExportMappings$ValueMappingElement" — no "Export" prefix. See comment in serializeExportObjectElement. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ExportMappings$ValueMappingElement"}, - {Key: "Attribute", Value: elem.Attribute}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "Type", Value: dataType}, - // A schema ROOT has MinOccurs 1; hardcoding 0 lost that (#279). - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - // Mirror the bound schema element: Mendix cross-validates the two and - // reports CE5015 on any mismatch. Hardcoding 0 only worked while the - // JSON structure also wrote 0 for every element (#841). - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "Nillable", Value: true}, - // IsDefaultType is NOT written here: it belongs to the OBJECT element type - // only. The generated metamodel declares it on Import/ExportObjectMappingElement - // and on neither ValueMappingElement, and Studio Pro's own mappings in a blank - // app carry it on the object element alone. A property the type does not own is - // the shape mxbuild accepts and Studio Pro refuses to open. (issue #882) - {Key: "ElementType", Value: "Value"}, - {Key: "Documentation", Value: ""}, - {Key: "Converter", Value: elem.Converter}, - {Key: "FractionDigits", Value: int32(-1)}, - {Key: "TotalDigits", Value: int32(-1)}, - // Mirrors the bound schema element, like MaxOccurs: Studio Pro stores 0 for - // a string element and -1 for a numeric one (#277). - {Key: "MaxLength", Value: int32(elem.MaxLength)}, - // Studio Pro writes IsKey on export value elements too; omitting it was a - // divergence from the import twin (#277). - {Key: "IsKey", Value: elem.IsKey}, - {Key: "IsContent", Value: false}, - {Key: "IsXmlAttribute", Value: false}, - {Key: "OriginalValue", Value: elem.OriginalValue}, - {Key: "XmlPrimitiveType", Value: xmlPrimitiveTypeName(elem.DataType)}, - } -} diff --git a/sdk/mpr/writer_export_mapping_properties_test.go b/sdk/mpr/writer_export_mapping_properties_test.go deleted file mode 100644 index 3885032594..0000000000 --- a/sdk/mpr/writer_export_mapping_properties_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// The export writers hardcoded three properties the import twin already read -// off the element, so no export mapping mxcli wrote matched its Studio Pro -// original — which kept every one of them in #260's silent-loss set even once -// its source kind was authorable. -// -// Studio Pro's values, measured on FeedbackModule.EXM_PostFeedback and -// MxGenAIConnector.EM_CohereEmbed_Request (11.13): -// -// object root MinOccurs 1 hardcoded 0 (#279) -// value element MaxLength 0 / -1 hardcoded 0 (#277) -// value element IsKey false not written (#277) -// -// MaxLength is the one to watch: it is 0 for a STRING element and -1 for a -// numeric one, mirroring the bound schema element exactly as MaxOccurs does, so -// a single hardcoded value cannot be right for both. - -// assertVal compares a BSON field of any scalar type; the package's assertField -// only handles strings. -func assertVal(t *testing.T, m map[string]any, key string, want any) { - t.Helper() - got, ok := m[key] - if !ok { - t.Errorf("field %q: missing", key) - return - } - if got != want { - t.Errorf("field %q = %v (%T), want %v (%T)", key, got, got, want, want) - } -} - -func exportDoc(t *testing.T, em *model.ExportMapping) map[string]any { - t.Helper() - w := &Writer{} - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - return raw -} - -func TestExportMappingMirrorsSchemaFacets(t *testing.T) { - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "em-1", TypeName: "ExportMappings$ExportMapping"}, - Name: "EXM_Probe", - Elements: []*model.ExportMappingElement{{ - Kind: "Object", Entity: "M.E", ObjectHandling: "Parameter", - MinOccurs: 1, MaxOccurs: 1, JsonPath: "(Object)", - Children: []*model.ExportMappingElement{ - {Kind: "Value", Attribute: "M.E.Name", DataType: "String", - MinOccurs: 0, MaxOccurs: 1, MaxLength: 0, JsonPath: "(Object)|name"}, - {Kind: "Value", Attribute: "M.E.Width", DataType: "Integer", - MinOccurs: 0, MaxOccurs: 1, MaxLength: -1, JsonPath: "(Object)|width"}, - }, - }}, - } - - root, ok := extractBsonArray(exportDoc(t, em)["Elements"])[0].(map[string]any) - if !ok { - t.Fatal("root element is not a document") - } - // A schema root has MinOccurs 1; the writer hardcoded 0. - assertVal(t, root, "MinOccurs", int32(1)) - - children := extractBsonArray(root["Children"]) - if len(children) != 2 { - t.Fatalf("got %d children, want 2", len(children)) - } - str, _ := children[0].(map[string]any) - num, _ := children[1].(map[string]any) - - // Both were hardcoded to 0, which is right for the string and wrong for the - // number — the reason a single constant cannot work here. - assertVal(t, str, "MaxLength", int32(0)) - assertVal(t, num, "MaxLength", int32(-1)) - - // Studio Pro writes IsKey on export value elements; it was not written. - assertVal(t, str, "IsKey", false) - assertVal(t, num, "IsKey", false) -} - -// MessageDefinition2 is version-introduced (11.10+). It is CARRIED, never -// invented: writing it onto an older document is the shape mxbuild tolerates -// and Studio Pro refuses to open. nil means absent, which is not the same as -// present-and-empty — hence the pointer. -func TestExportMappingCarriesMessageDefinition2(t *testing.T) { - base := func() *model.ExportMapping { - return &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "em-2", TypeName: "ExportMappings$ExportMapping"}, - Name: "EXM_Probe", - } - } - - absent := exportDoc(t, base()) - if _, ok := absent["MessageDefinition2"]; ok { - t.Error("nil carried the key through — a pre-11.10 document must not gain it") - } - - em := base() - empty := "" - em.MessageDefinition2 = &empty - present := exportDoc(t, em) - v, ok := present["MessageDefinition2"] - if !ok { - t.Fatal("present-and-empty was dropped — that is what a blank 11.13 app stores") - } - if v != "" { - t.Errorf("MessageDefinition2 = %v, want the empty string", v) - } -} diff --git a/sdk/mpr/writer_export_mapping_test.go b/sdk/mpr/writer_export_mapping_test.go deleted file mode 100644 index a7e88a4f42..0000000000 --- a/sdk/mpr/writer_export_mapping_test.go +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeExportMapping_TypeNames verifies the critical $Type naming convention. -// The correct names are "ExportMappings$ObjectMappingElement" and -// "ExportMappings$ValueMappingElement" — the namespace prefix is never repeated. -// Using "ExportMappings$ExportObjectMappingElement" causes TypeCacheUnknownTypeException. -func TestSerializeExportMapping_TypeNames(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ - ID: "test-em-id", - TypeName: "ExportMappings$ExportMapping", - }, - ContainerID: "test-module-id", - Name: "ExportPetRequest", - ExportLevel: "Hidden", - NullValueOption: "LeaveOutElement", - Elements: []*model.ExportMappingElement{ - { - BaseElement: model.BaseElement{ID: "obj-elem-id"}, - Kind: "Object", - ExposedName: "Root", - Entity: "MyModule.Pet", - JsonPath: "(Object)", - Children: []*model.ExportMappingElement{ - { - BaseElement: model.BaseElement{ID: "val-id-elem"}, - Kind: "Value", - ExposedName: "id", - Attribute: "MyModule.Pet.Id", - DataType: "Integer", - JsonPath: "(Object)|id", - }, - { - BaseElement: model.BaseElement{ID: "val-name-elem"}, - Kind: "Value", - ExposedName: "name", - Attribute: "MyModule.Pet.Name", - DataType: "String", - JsonPath: "(Object)|name", - }, - }, - }, - }, - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "$Type", "ExportMappings$ExportMapping") - assertField(t, raw, "Name", "ExportPetRequest") - assertField(t, raw, "NullValueOption", "LeaveOutElement") - - elems := extractBsonArray(raw["Elements"]) - if len(elems) != 1 { - t.Fatalf("Elements: expected 1, got %d", len(elems)) - } - - objElem, ok := elems[0].(map[string]any) - if !ok { - t.Fatalf("Elements[0]: expected map, got %T", elems[0]) - } - // CRITICAL: must NOT be "ExportMappings$ExportObjectMappingElement" - assertField(t, objElem, "$Type", "ExportMappings$ObjectMappingElement") - assertField(t, objElem, "Entity", "MyModule.Pet") - assertField(t, objElem, "ObjectHandling", "Parameter") - // The backup is a member of {Create, Error, Ignore} — "Parameter" never was. - // The writer used to echo the HANDLING into it, which is how an off-enum - // value reached disk; Studio Pro writes "Error" on every export element, - // since an export has nothing to find (#261). - assertField(t, objElem, "ObjectHandlingBackup", "Error") - - children := extractBsonArray(objElem["Children"]) - if len(children) != 2 { - t.Fatalf("Children: expected 2, got %d", len(children)) - } - - valElem, ok := children[0].(map[string]any) - if !ok { - t.Fatalf("Children[0]: expected map, got %T", children[0]) - } - // CRITICAL: must NOT be "ExportMappings$ExportValueMappingElement" - assertField(t, valElem, "$Type", "ExportMappings$ValueMappingElement") -} - -func TestSerializeExportMapping_DefaultNullValueOption(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-default-null"}, - ContainerID: "test-module-id", - Name: "DefaultNullMapping", - // NullValueOption intentionally omitted — should default to "LeaveOutElement" - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "NullValueOption", "LeaveOutElement") - assertField(t, raw, "ExportLevel", "Hidden") -} - -func TestSerializeExportMapping_RequiredFields(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-required"}, - ContainerID: "test-module-id", - Name: "MinimalExportMapping", - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - // These fields must be present — verified against Studio Pro-created BSON. - for _, field := range []string{ - "PublicName", - "XsdRootElementName", - "IsHeaderParameter", - "ParameterName", - "OperationName", - "ServiceName", - "WsdlFile", - } { - if _, ok := raw[field]; !ok { - t.Errorf("missing required field: %s", field) - } - } -} - -func TestSerializeExportMapping_WithJsonStructureRef(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-js-ref"}, - ContainerID: "test-module-id", - Name: "ExportWithSchema", - JsonStructure: "MyModule.PetJsonStructure", - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "JsonStructure", "MyModule.PetJsonStructure") -} - -func TestSerializeExportMapping_NullValueOptionSendAsNil(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-send-nil"}, - ContainerID: "test-module-id", - Name: "SendNilMapping", - NullValueOption: "SendAsNil", - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "NullValueOption", "SendAsNil") -} diff --git a/sdk/mpr/writer_external_action_returntype_test.go b/sdk/mpr/writer_external_action_returntype_test.go deleted file mode 100644 index d924790787..0000000000 --- a/sdk/mpr/writer_external_action_returntype_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// TestSerializeExternalActionReturnType covers the DataTypes$ element written -// into CallExternalAction.VariableDataType. -// -// Object and List were unreachable before: the resolver mapped only EDM -// primitives and returned "" for anything else, so an action returning an entity -// (or a collection of them) got NO VariableDataType at all, and Mendix reported -// CE7269 "The return type for remote action '' has changed" -// (mendixlabs/mxcli#1020). Both carry an Entity — a DataTypes$ObjectType without -// one is as unaligned as no type at all. -func TestSerializeExternalActionReturnType(t *testing.T) { - tests := []struct { - name string - kind string - entity string - wantType string - wantEntity string // "" = the key must be absent - }{ - {name: "object return", kind: "Object", entity: "Trippin.Airport", - wantType: "DataTypes$ObjectType", wantEntity: "Trippin.Airport"}, - {name: "list return", kind: "List", entity: "Trippin.Person", - wantType: "DataTypes$ListType", wantEntity: "Trippin.Person"}, - {name: "boolean", kind: "Boolean", wantType: "DataTypes$BooleanType"}, - {name: "string", kind: "String", wantType: "DataTypes$StringType"}, - {name: "integer", kind: "Integer", wantType: "DataTypes$IntegerType"}, - {name: "long is an integer", kind: "Long", wantType: "DataTypes$IntegerType"}, - {name: "decimal", kind: "Decimal", wantType: "DataTypes$DecimalType"}, - {name: "datetime", kind: "DateTime", wantType: "DataTypes$DateTimeType"}, - {name: "binary", kind: "Binary", wantType: "DataTypes$BinaryType"}, - {name: "void", kind: "Void", wantType: "DataTypes$VoidType"}, - {name: "empty is void", kind: "", wantType: "DataTypes$VoidType"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - doc := serializeExternalActionReturnType(tt.kind, tt.entity) - - var gotType, gotEntity string - var hasEntity, hasID bool - for _, e := range doc { - switch e.Key { - case "$Type": - gotType, _ = e.Value.(string) - case "Entity": - gotEntity, _ = e.Value.(string) - hasEntity = true - case "$ID": - hasID = true - } - } - - if gotType != tt.wantType { - t.Errorf("$Type = %q, want %q", gotType, tt.wantType) - } - if !hasID { - t.Error("every DataTypes$ element needs its own $ID") - } - if tt.wantEntity == "" { - if hasEntity { - t.Errorf("a primitive return must not carry an Entity (got %q)", gotEntity) - } - return - } - if gotEntity != tt.wantEntity { - t.Errorf("Entity = %q, want %q", gotEntity, tt.wantEntity) - } - }) - } -} diff --git a/sdk/mpr/writer_formattinginfo_test.go b/sdk/mpr/writer_formattinginfo_test.go deleted file mode 100644 index 21fe56bbd5..0000000000 --- a/sdk/mpr/writer_formattinginfo_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeClientTemplateParameter_FormattingInfoNoTimeFormat -// guards against the CE0463 "widget definition changed" regression we -// hit on Mendix 11.9: Forms$FormattingInfo's reflection schema does not -// declare a TimeFormat property, but our writer was emitting -// `"TimeFormat": "HoursMinutes"` for every parameter. Studio Pro then -// treated every pluggable widget that embeds FormattingInfo (gallery, -// datagrid2 captions, dynamictext) as having a stale widget definition, -// which cascaded into CE3637 on master-detail pages. -func TestSerializeClientTemplateParameter_FormattingInfoNoTimeFormat(t *testing.T) { - param := &pages.ClientTemplateParameter{Expression: "'hello'"} - doc := serializeClientTemplateParameter(param) - - fi, ok := getBSONField(doc, "FormattingInfo").(bson.D) - if !ok { - t.Fatalf("FormattingInfo is not bson.D, got %T", getBSONField(doc, "FormattingInfo")) - } - for _, e := range fi { - if e.Key == "TimeFormat" { - t.Fatalf("FormattingInfo unexpectedly contains TimeFormat=%q — schema only declares CustomDateFormat/DateFormat/DecimalPrecision/EnumFormat/GroupDigits", e.Value) - } - } - // Sanity: the five schema-declared keys are present. - for _, want := range []string{"CustomDateFormat", "DateFormat", "DecimalPrecision", "EnumFormat", "GroupDigits"} { - if getBSONField(fi, want) == nil { - t.Errorf("FormattingInfo missing required field %q", want) - } - } -} diff --git a/sdk/mpr/writer_id_order_test.go b/sdk/mpr/writer_id_order_test.go deleted file mode 100644 index a24967c5d0..0000000000 --- a/sdk/mpr/writer_id_order_test.go +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "testing" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/settingsoverlay" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - - "go.mongodb.org/mongo-driver/bson" -) - -// Mendix 11.12+ rejects any storage object whose first BSON property is not -// "$ID" (System.InvalidOperationException: "Expected '$ID' as the first -// property of a storage object, but got '...'"). The writer historically built -// many objects as bson.M (a Go map), which bson.Marshal serializes in random -// key order, so "$ID" only landed first by luck. These tests pin the invariant: -// every serialized storage object must have "$ID" first, with no duplicate keys. - -// validateStorageOrder walks a decoded BSON value (bson.D / bson.A, nesting -// preserved by unmarshalling into bson.D) and asserts that every document which -// looks like a storage object ($ID and/or $Type present) lists "$ID" first, and -// that no document contains duplicate keys (the hazard when a literal default is -// later "overwritten" via append). -func validateStorageOrder(t *testing.T, label string, v any) { - t.Helper() - switch d := v.(type) { - case bson.D: - seen := make(map[string]bool, len(d)) - hasID, hasType := false, false - for i, e := range d { - if seen[e.Key] { - t.Errorf("%s: duplicate key %q in storage object", label, e.Key) - } - seen[e.Key] = true - switch e.Key { - case "$ID": - hasID = true - if i != 0 { - t.Errorf("%s: $ID is at index %d, must be the first property", label, i) - } - case "$Type": - hasType = true - } - validateStorageOrder(t, label+"."+e.Key, e.Value) - } - if (hasID || hasType) && (len(d) == 0 || d[0].Key != "$ID") { - t.Errorf("%s: storage object does not start with $ID", label) - } - case bson.A: - for i, e := range d { - validateStorageOrder(t, fmt.Sprintf("%s[%d]", label, i), e) - } - } -} - -// marshalAndValidate round-trips a value through BSON bytes (the real on-the-wire -// form) and validates ordering of the decoded document. -func marshalAndValidate(t *testing.T, label string, v any) { - t.Helper() - raw, err := bson.Marshal(v) - if err != nil { - t.Fatalf("%s: marshal failed: %v", label, err) - } - var decoded bson.D - if err := bson.Unmarshal(raw, &decoded); err != nil { - t.Fatalf("%s: unmarshal failed: %v", label, err) - } - validateStorageOrder(t, label, decoded) -} - -func TestStorageObjects_IDIsFirstProperty(t *testing.T) { - w := &Writer{} - - // Module-tree serializers: created on every project/module create and - // shared by both engines — the universal offenders in the 11.12 nightly. - t.Run("Module", func(t *testing.T) { - mod := &model.Module{Name: "MyModule"} - mod.ID = "mod-1" - b, err := w.serializeModule(mod) - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "Module", d) - }) - - t.Run("Folder", func(t *testing.T) { - folder := &model.Folder{Name: "Pages"} - folder.ID = "f-1" - b, err := w.serializeFolder(folder) - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "Folder", d) - }) - - t.Run("ModuleSecurity", func(t *testing.T) { - b, err := w.serializeModuleSecurity("ms-1") - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "ModuleSecurity", d) - }) - - t.Run("ModuleSettings", func(t *testing.T) { - b, err := w.serializeModuleSettings("set-1") - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "ModuleSettings", d) - }) - - // Domain-model associations (lossy CREATE OR MODIFY had wiped these before). - t.Run("Association", func(t *testing.T) { - a := &domainmodel.Association{ - Name: "Order_Customer", - ParentID: "child-id", - ChildID: "parent-id", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - a.ID = "assoc-1" - marshalAndValidate(t, "Association", serializeAssociation(a)) - }) - - t.Run("CrossAssociation", func(t *testing.T) { - ca := &domainmodel.CrossModuleAssociation{ - Name: "Order_Customer", - ParentID: "child-id", - ChildRef: "Other.Customer", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - ca.ID = "xassoc-1" - marshalAndValidate(t, "CrossAssociation", serializeCrossAssociation(ca)) - }) - - // Entity + Attribute: these were bson.D but in Studio Pro field order - // (Name-first, $ID mid-document), which 11.12 rejects. validateStorageOrder - // recurses, so this also covers the nested AccessRule and MemberAccess (which - // were $Type-first). Regression guard for the 11.12 nightly `got 'Name'`. - t.Run("Entity", func(t *testing.T) { - attr := &domainmodel.Attribute{Name: "Amount", Type: &domainmodel.IntegerAttributeType{}} - attr.ID = "attr-1" - ma := &domainmodel.MemberAccess{AttributeName: "Amount", AccessRights: domainmodel.MemberAccessRightsReadWrite} - ma.ID = "ma-1" - ar := &domainmodel.AccessRule{ - ModuleRoleNames: []string{"Mod.User"}, - AllowRead: true, - MemberAccesses: []*domainmodel.MemberAccess{ma}, - } - ar.ID = "ar-1" - e := &domainmodel.Entity{ - Name: "Order", - Persistable: true, - Attributes: []*domainmodel.Attribute{attr}, - AccessRules: []*domainmodel.AccessRule{ar}, - } - e.ID = "ent-1" - marshalAndValidate(t, "Entity", serializeEntity(e, "Mod", nil)) - }) - - t.Run("Attribute", func(t *testing.T) { - attr := &domainmodel.Attribute{Name: "Amount", Type: &domainmodel.IntegerAttributeType{}} - attr.ID = "attr-2" - marshalAndValidate(t, "Attribute", serializeAttribute(attr, false)) - }) - - // Business-event tree: $ID was added dynamically after a $Type-first literal. - t.Run("BusinessEventDefinition", func(t *testing.T) { - def := &model.BusinessEventDefinition{ - ServiceName: "Svc", - Channels: []*model.BusinessEventChannel{{ - ChannelName: "Ch", - Messages: []*model.BusinessEventMessage{{ - MessageName: "Msg", - Attributes: []*model.BusinessEventAttribute{ - {AttributeName: "A1", AttributeType: "String"}, - {AttributeName: "A2", AttributeType: "DateTime"}, - }, - }}, - }}, - } - marshalAndValidate(t, "BusinessEventDefinition", serializeBusinessEventDefinition(def)) - }) - - // Database-connector query tree: $ID added dynamically; nested DataType/SqlDataType. - t.Run("DBQuery", func(t *testing.T) { - q := &model.DatabaseQuery{ - Name: "Q1", - SQL: "SELECT 1", - TableMappings: []*model.DatabaseTableMapping{{ - Entity: "Mod.Ent", - TableName: "ent", - Columns: []*model.DatabaseColumnMapping{{Attribute: "Name", ColumnName: "name"}}, - }}, - Parameters: []*model.DatabaseQueryParameter{{ParameterName: "p1"}}, - } - marshalAndValidate(t, "DBQuery", serializeDBQuery(q, false)) - }) - - // Server configuration: $ID added dynamically; nested ConstantValue list. - t.Run("ServerConfiguration", func(t *testing.T) { - cfg := &model.ServerConfiguration{ - Name: "Default", - ConstantValues: []*model.ConstantValue{ - {ConstantId: "Mod.C1", Value: "v"}, - }, - } - marshalAndValidate(t, "ServerConfiguration", - bsonutil.OrderStorageValue(settingsoverlay.ServerConfiguration(cfg, nil, nil))) - }) -} diff --git a/sdk/mpr/writer_imagecollection.go b/sdk/mpr/writer_imagecollection.go deleted file mode 100644 index 54787dbf37..0000000000 --- a/sdk/mpr/writer_imagecollection.go +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// CreateImageCollection creates a new empty image collection unit in the MPR. -func (w *Writer) CreateImageCollection(ic *ImageCollection) error { - if ic.ID == "" { - ic.ID = model.ID(generateUUID()) - } - if ic.ExportLevel == "" { - ic.ExportLevel = "Hidden" - } - - contents, err := serializeImageCollection(ic) - if err != nil { - return err - } - - return w.insertUnit(string(ic.ID), string(ic.ContainerID), - "Documents", "Images$ImageCollection", contents) -} - -// UpdateImageCollection re-serializes an existing image collection in-place, preserving its ID. -func (w *Writer) UpdateImageCollection(ic *ImageCollection) error { - contents, err := serializeImageCollection(ic) - if err != nil { - return err - } - return w.updateUnit(string(ic.ID), contents) -} - -// DeleteImageCollection deletes an image collection by ID. -func (w *Writer) DeleteImageCollection(id string) error { - return w.deleteUnit(id) -} - -func serializeImageCollection(ic *ImageCollection) ([]byte, error) { - // Images array always starts with the array marker int32(3) - images := bson.A{int32(3)} - for i := range ic.Images { - img := &ic.Images[i] - if img.ID == "" { - img.ID = model.ID(generateUUID()) - } - images = append(images, bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(img.ID))}, - {Key: "$Type", Value: "Images$Image"}, - {Key: "Image", Value: primitive.Binary{Subtype: 0, Data: img.Data}}, - {Key: "ImageFormat", Value: img.Format}, - {Key: "Name", Value: img.Name}, - }) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ic.ID))}, - {Key: "$Type", Value: "Images$ImageCollection"}, - {Key: "Documentation", Value: ic.Documentation}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: ic.ExportLevel}, - {Key: "Images", Value: images}, - {Key: "Name", Value: ic.Name}, - } - - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/writer_imagecollection_test.go b/sdk/mpr/writer_imagecollection_test.go deleted file mode 100644 index d7e0442ed8..0000000000 --- a/sdk/mpr/writer_imagecollection_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeImageCollection_EmptyImages(t *testing.T) { - ic := &ImageCollection{ - BaseElement: model.BaseElement{ID: "ic-test-1"}, - ContainerID: model.ID("module-id-1"), - Name: "TestIcons", - ExportLevel: "Hidden", - } - - data, err := serializeImageCollection(ic) - if err != nil { - t.Fatalf("serializeImageCollection: %v", err) - } - - var doc bson.D - if err := bson.Unmarshal(data, &doc); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - // Verify $Type - if got := getBSONField(doc, "$Type"); got != "Images$ImageCollection" { - t.Errorf("$Type = %q, want %q", got, "Images$ImageCollection") - } - - // Verify Name - if got := getBSONField(doc, "Name"); got != "TestIcons" { - t.Errorf("Name = %q, want %q", got, "TestIcons") - } - - // Verify ExportLevel - if got := getBSONField(doc, "ExportLevel"); got != "Hidden" { - t.Errorf("ExportLevel = %q, want %q", got, "Hidden") - } - - // Verify Excluded - if got := getBSONField(doc, "Excluded"); got != false { - t.Errorf("Excluded = %v, want false", got) - } - - // Images array must start with marker int32(3) - assertArrayMarker(t, doc, "Images", int32(3)) - - // Images should be empty (marker only) - arr := getBSONField(doc, "Images").(bson.A) - if len(arr) != 1 { - t.Errorf("Images length = %d, want 1 (marker only)", len(arr)) - } -} - -func TestSerializeImageCollection_DefaultExportLevel(t *testing.T) { - ic := &ImageCollection{ - BaseElement: model.BaseElement{ID: "ic-test-2"}, - ContainerID: model.ID("module-id-1"), - Name: "Icons", - // ExportLevel intentionally omitted to test CreateImageCollection default - } - - // CreateImageCollection sets default, but serializeImageCollection doesn't — - // test that empty ExportLevel serializes as empty string (caller's responsibility) - data, err := serializeImageCollection(ic) - if err != nil { - t.Fatalf("serializeImageCollection: %v", err) - } - - var doc bson.D - if err := bson.Unmarshal(data, &doc); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertArrayMarker(t, doc, "Images", int32(3)) -} diff --git a/sdk/mpr/writer_import_mapping.go b/sdk/mpr/writer_import_mapping.go deleted file mode 100644 index 362275ac6c..0000000000 --- a/sdk/mpr/writer_import_mapping.go +++ /dev/null @@ -1,290 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateImportMapping creates a new import mapping document. -func (w *Writer) CreateImportMapping(im *model.ImportMapping) error { - if im.ID == "" { - im.ID = model.ID(generateUUID()) - } - im.TypeName = "ImportMappings$ImportMapping" - - contents, err := w.serializeImportMapping(im) - if err != nil { - return fmt.Errorf("failed to serialize import mapping: %w", err) - } - - return w.insertUnit(string(im.ID), string(im.ContainerID), "Documents", "ImportMappings$ImportMapping", contents) -} - -// UpdateImportMapping updates an existing import mapping document. -func (w *Writer) UpdateImportMapping(im *model.ImportMapping) error { - contents, err := w.serializeImportMapping(im) - if err != nil { - return fmt.Errorf("failed to serialize import mapping: %w", err) - } - return w.updateUnit(string(im.ID), contents) -} - -// DeleteImportMapping deletes an import mapping document. -func (w *Writer) DeleteImportMapping(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveImportMapping moves an import mapping to a new container. -func (w *Writer) MoveImportMapping(im *model.ImportMapping) error { - return w.moveUnitByID(string(im.ID), string(im.ContainerID)) -} - -func (w *Writer) serializeImportMapping(im *model.ImportMapping) ([]byte, error) { - elements := bson.A{int32(2)} - for _, elem := range im.Elements { - elements = append(elements, serializeImportMappingElement(elem, "(Object)")) - } - - exportLevel := im.ExportLevel - if exportLevel == "" { - exportLevel = "Hidden" - } - - // ParameterType is a required sub-document even when unused: an - // unparameterised mapping stores the DataTypes$UnknownType marker, and one - // declaring an input object stores a DataTypes$ObjectType naming it (#265). - // Without the property Studio Pro fails to render the schema source and - // mapping elements correctly. - parameterType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$UnknownType"}, - } - if im.ParameterEntity != "" { - parameterType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: im.ParameterEntity}, - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(im.ID))}, - {Key: "$Type", Value: "ImportMappings$ImportMapping"}, - {Key: "Name", Value: im.Name}, - {Key: "Documentation", Value: im.Documentation}, - {Key: "Excluded", Value: im.Excluded}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "JsonStructure", Value: im.JsonStructure}, - {Key: "XmlSchema", Value: im.XmlSchema}, - {Key: "MessageDefinition", Value: im.MessageDefinition}, - {Key: "Elements", Value: elements}, - // Required fields with defaults — verified against Studio Pro-created BSON - {Key: "UseSubtransactionsForMicroflows", Value: false}, - {Key: "PublicName", Value: ""}, // Studio Pro writes "" not the mapping name - {Key: "XsdRootElementName", Value: ""}, - {Key: "MappingSourceReference", Value: nil}, - {Key: "ParameterType", Value: parameterType}, - {Key: "OperationName", Value: ""}, - {Key: "ServiceName", Value: ""}, - {Key: "WsdlFile", Value: ""}, - } - // MessageDefinition2 is version-introduced (11.10+) and CARRIED, never - // invented: adding the key to a document written before then is the shape - // mxbuild tolerates and Studio Pro refuses to open. nil means absent, which - // is not the same as present-and-empty (ako/mxcli#279). - if im.MessageDefinition2 != nil { - doc = append(doc, bson.E{Key: "MessageDefinition2", Value: *im.MessageDefinition2}) - } - return marshalUnitIDFirst(doc) -} - -func serializeImportMappingElement(elem *model.ImportMappingElement, parentPath string) bson.D { - id := string(elem.ID) - if id == "" { - id = generateUUID() - } - - if isMappingObjectKind(elem.Kind) { - return serializeImportObjectElement(id, elem, parentPath) - } - return serializeImportValueElement(id, elem, parentPath) -} - -func serializeImportObjectElement(id string, elem *model.ImportMappingElement, parentPath string) bson.D { - // Use pre-computed JsonPath from the executor when available. - // The executor aligns JsonPath with the JSON structure element paths. - jsonPath := elem.JsonPath - if jsonPath == "" { - if elem.ExposedName == "" { - jsonPath = parentPath - } else { - jsonPath = parentPath + "|" + elem.ExposedName - } - } - - children := bson.A{int32(2)} - for _, child := range elem.Children { - children = append(children, serializeImportMappingElement(child, jsonPath)) - } - - objectHandling := elem.ObjectHandling - if objectHandling == "" { - objectHandling = "Create" - } - // The backup takes {Create, Error, Ignore} only; copying the HANDLING into - // it wrote "Find"/"Custom", which occur in 0 of 1,261 real elements (#261). - objectHandlingBackup := "Create" - switch elem.ObjectHandlingBackup { - case "Create", "Error", "Ignore": - objectHandlingBackup = elem.ObjectHandlingBackup - } - if objectHandling == "FindOrCreate" { - objectHandling = "Find" - } - - // IMPORTANT: The correct $Type is "ImportMappings$ObjectMappingElement" (no "Import" prefix in the element name). - // The generated metamodel (ImportMappingsImportObjectMappingElement) is misleading — Studio Pro will throw - // TypeCacheUnknownTypeException if you use "ImportMappings$ImportObjectMappingElement". - // Rule: MappingElement $Type names do NOT repeat the namespace prefix (same for ExportMappings). - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ImportMappings$ObjectMappingElement"}, - {Key: "Entity", Value: elem.Entity}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "ObjectHandling", Value: objectHandling}, - {Key: "ObjectHandlingBackup", Value: objectHandlingBackup}, - {Key: "ObjectHandlingBackupAllowOverride", Value: elem.BackupAllowOverride}, - {Key: "Association", Value: elem.Association}, - {Key: "Children", Value: children}, - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "Nillable", Value: elem.Nillable}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: elementTypeForKind(elem.Kind)}, - {Key: "Documentation", Value: ""}, - {Key: "CustomHandlerCall", Value: nil}, - } -} - -func serializeImportValueElement(id string, elem *model.ImportMappingElement, parentPath string) bson.D { - dataType := serializeImportValueDataType(elem.DataType) - jsonPath := elem.JsonPath - if jsonPath == "" { - jsonPath = parentPath + "|" + elem.ExposedName - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ImportMappings$ValueMappingElement"}, - {Key: "Attribute", Value: elem.Attribute}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "IsKey", Value: elem.IsKey}, - {Key: "Type", Value: dataType}, - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "Nillable", Value: elem.Nillable}, - // IsDefaultType is NOT written here: it belongs to the OBJECT element type - // only. The generated metamodel declares it on Import/ExportObjectMappingElement - // and on neither ValueMappingElement, and Studio Pro's own mappings in a blank - // app carry it on the object element alone. A property the type does not own is - // the shape mxbuild accepts and Studio Pro refuses to open. (issue #882) - {Key: "ElementType", Value: "Value"}, - {Key: "Documentation", Value: ""}, - {Key: "Converter", Value: elem.Converter}, - {Key: "FractionDigits", Value: int32(elem.FractionDigits)}, - {Key: "TotalDigits", Value: int32(elem.TotalDigits)}, - {Key: "MaxLength", Value: int32(elem.MaxLength)}, - {Key: "IsContent", Value: false}, - {Key: "IsXmlAttribute", Value: false}, - {Key: "OriginalValue", Value: elem.OriginalValue}, - {Key: "XmlPrimitiveType", Value: xmlPrimitiveTypeName(elem.DataType)}, - } -} - -func xmlPrimitiveTypeName(dataType string) string { - switch dataType { - case "Integer", "Long": - return "Integer" - case "Decimal": - return "Decimal" - case "Boolean": - return "Boolean" - case "DateTime": - return "DateTime" - default: - return "String" - } -} - -// elementTypeForKind maps model Kind to BSON ElementType. -func elementTypeForKind(kind string) string { - if kind == "Array" { - return "Array" - } - if kind == "Wrapper" { - // An array of PRIMITIVES: one entity per item, with the value on an - // attribute (#268). - return "Wrapper" - } - if kind == "Value" { - return "Value" - } - return "Object" -} - -func serializeImportValueDataType(typeName string) bson.D { - typeID := idToBsonBinary(GenerateID()) - switch typeName { - case "Integer", "Long": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case "Decimal": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DecimalType"}, - } - case "Boolean": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BooleanType"}, - } - case "DateTime": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DateTimeType"}, - } - case "Binary": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BinaryType"}, - } - default: // String - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - } -} - -// isMappingObjectKind — see the note in mdl/backend/modelsdk/mapping_write.go. -// Duplicated rather than shared: the two engines' writers are independent by -// design (ADR-0002/0004). -func isMappingObjectKind(kind string) bool { - switch kind { - case "Object", "Array", "Wrapper": - return true - default: - return false - } -} diff --git a/sdk/mpr/writer_import_mapping_test.go b/sdk/mpr/writer_import_mapping_test.go deleted file mode 100644 index a571763910..0000000000 --- a/sdk/mpr/writer_import_mapping_test.go +++ /dev/null @@ -1,255 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeImportMapping_TypeNames verifies the critical $Type naming convention. -// The correct names are "ImportMappings$ObjectMappingElement" and -// "ImportMappings$ValueMappingElement" — the namespace prefix is never repeated in the -// element name. Using the wrong name causes TypeCacheUnknownTypeException in Studio Pro. -func TestSerializeImportMapping_TypeNames(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ - ID: "test-im-id", - TypeName: "ImportMappings$ImportMapping", - }, - ContainerID: "test-module-id", - Name: "ImportPetResponse", - ExportLevel: "Hidden", - Elements: []*model.ImportMappingElement{ - { - BaseElement: model.BaseElement{ID: "obj-elem-id"}, - Kind: "Object", - ExposedName: "", - Entity: "MyModule.Pet", - Children: []*model.ImportMappingElement{ - { - BaseElement: model.BaseElement{ID: "val-id-elem"}, - Kind: "Value", - ExposedName: "id", - Attribute: "MyModule.Pet.Id", - DataType: "Integer", - IsKey: true, - }, - { - BaseElement: model.BaseElement{ID: "val-name-elem"}, - Kind: "Value", - ExposedName: "name", - Attribute: "MyModule.Pet.Name", - DataType: "String", - }, - }, - }, - }, - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "$Type", "ImportMappings$ImportMapping") - assertField(t, raw, "Name", "ImportPetResponse") - - elems := extractBsonArray(raw["Elements"]) - if len(elems) != 1 { - t.Fatalf("Elements: expected 1, got %d", len(elems)) - } - - objElem, ok := elems[0].(map[string]any) - if !ok { - t.Fatalf("Elements[0]: expected map, got %T", elems[0]) - } - // CRITICAL: must NOT be "ImportMappings$ImportObjectMappingElement" - assertField(t, objElem, "$Type", "ImportMappings$ObjectMappingElement") - assertField(t, objElem, "Entity", "MyModule.Pet") - assertField(t, objElem, "ObjectHandling", "Create") - - children := extractBsonArray(objElem["Children"]) - if len(children) != 2 { - t.Fatalf("Children: expected 2, got %d", len(children)) - } - - valElem, ok := children[0].(map[string]any) - if !ok { - t.Fatalf("Children[0]: expected map, got %T", children[0]) - } - // CRITICAL: must NOT be "ImportMappings$ImportValueMappingElement" - assertField(t, valElem, "$Type", "ImportMappings$ValueMappingElement") - assertField(t, valElem, "Attribute", "MyModule.Pet.Id") - - // IsKey must be true on the first (key) element - if valElem["IsKey"] != true { - t.Errorf("IsKey: expected true, got %v", valElem["IsKey"]) - } -} - -func TestSerializeImportMapping_RequiredFields(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-required"}, - ContainerID: "test-module-id", - Name: "MinimalMapping", - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - // These fields must be present with defaults — verified against Studio Pro-created BSON. - // Missing fields cause CE errors when opening in Studio Pro. - for _, field := range []string{ - "UseSubtransactionsForMicroflows", - "PublicName", - "XsdRootElementName", - "OperationName", - "ServiceName", - "WsdlFile", - } { - if _, ok := raw[field]; !ok { - t.Errorf("missing required field: %s", field) - } - } - - // ParameterType must be a sub-document with $Type DataTypes$UnknownType - pt, ok := raw["ParameterType"].(map[string]any) - if !ok { - t.Fatalf("ParameterType: expected map, got %T", raw["ParameterType"]) - } - assertField(t, pt, "$Type", "DataTypes$UnknownType") -} - -func TestSerializeImportMapping_DefaultExportLevel(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-default-export"}, - ContainerID: "test-module-id", - Name: "DefaultExportLevelMapping", - // ExportLevel intentionally omitted - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "ExportLevel", "Hidden") -} - -func TestSerializeImportMapping_WithJsonStructureRef(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-js-ref"}, - ContainerID: "test-module-id", - Name: "MappingWithSchema", - JsonStructure: "MyModule.PetJsonStructure", - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "JsonStructure", "MyModule.PetJsonStructure") -} - -func TestSerializeImportMapping_FindOrCreateUsesFindWithCreateBackup(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-upsert"}, - ContainerID: "test-module-id", - Name: "UpsertMapping", - Elements: []*model.ImportMappingElement{ - { - BaseElement: model.BaseElement{ID: "root-id"}, - Kind: "Object", - Entity: "MyModule.Pet", - ObjectHandling: "FindOrCreate", - }, - }, - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - elems := extractBsonArray(raw["Elements"]) - if len(elems) != 1 { - t.Fatalf("Elements: expected 1, got %d", len(elems)) - } - - objElem, ok := elems[0].(map[string]any) - if !ok { - t.Fatalf("Elements[0]: expected map, got %T", elems[0]) - } - assertField(t, objElem, "ObjectHandling", "Find") - assertField(t, objElem, "ObjectHandlingBackup", "Create") -} - -// TestSerializeImportValueDataType_AllTypes verifies that all supported data types -// map to the correct DataTypes$* BSON $Type values. -func TestSerializeImportValueDataType_AllTypes(t *testing.T) { - tests := []struct { - input string - wantType string - }{ - {"String", "DataTypes$StringType"}, - {"Integer", "DataTypes$IntegerType"}, - {"Long", "DataTypes$IntegerType"}, - {"Decimal", "DataTypes$DecimalType"}, - {"Boolean", "DataTypes$BooleanType"}, - {"DateTime", "DataTypes$DateTimeType"}, - {"Binary", "DataTypes$BinaryType"}, - {"", "DataTypes$StringType"}, // unknown falls back to String - } - - for _, tc := range tests { - result := serializeImportValueDataType(tc.input) - - var found string - for _, kv := range result { - if kv.Key == "$Type" { - found, _ = kv.Value.(string) - break - } - } - if found != tc.wantType { - t.Errorf("serializeImportValueDataType(%q): $Type = %q, want %q", - tc.input, found, tc.wantType) - } - } -} diff --git a/sdk/mpr/writer_javaactions.go b/sdk/mpr/writer_javaactions.go deleted file mode 100644 index 98d5c9a147..0000000000 --- a/sdk/mpr/writer_javaactions.go +++ /dev/null @@ -1,433 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Java action writer support. -package mpr - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// emptyBinary is the BSON subtype-0 binary Studio Pro writes for an unset -// toolbox bitmap. It must never be BSON null: the MicroflowActionInfo *Data -// properties are mandatory binaries and a null crashes Studio Pro's UnitWriter -// on re-serialize (issue #656). -func bsonBinary(b []byte) primitive.Binary { - if b == nil { - b = []byte{} - } - return primitive.Binary{Subtype: 0x00, Data: b} -} - -// microflowActionInfoBSON serializes a MicroflowActionInfo in the current -// metamodel shape: $Type CodeActions$MicroflowActionInfo, with all four icon/ -// image bitmaps always present as (possibly empty) binaries and never null. -// The legacy JavaActions$ alias and the removed `Icon` key are not emitted. -func microflowActionInfoBSON(mai *javaactions.MicroflowActionInfo) bson.D { - maiID := string(mai.ID) - if maiID == "" { - maiID = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(maiID)}, - {Key: "$Type", Value: "CodeActions$MicroflowActionInfo"}, - {Key: "Caption", Value: mai.Caption}, - {Key: "Category", Value: mai.Category}, - {Key: "IconData", Value: bsonBinary(mai.IconData)}, - {Key: "IconDataDark", Value: bsonBinary(mai.IconDataDark)}, - {Key: "ImageData", Value: bsonBinary(mai.ImageData)}, - {Key: "ImageDataDark", Value: bsonBinary(mai.ImageDataDark)}, - } -} - -// CreateJavaAction creates a new Java action in the MPR. -func (w *Writer) CreateJavaAction(ja *javaactions.JavaAction) error { - if ja.ID == "" { - ja.ID = model.ID(generateUUID()) - } - ja.TypeName = "JavaActions$JavaAction" - - contents, err := w.serializeJavaAction(ja) - if err != nil { - return fmt.Errorf("failed to serialize java action: %w", err) - } - - return w.insertUnit(string(ja.ID), string(ja.ContainerID), "Documents", "JavaActions$JavaAction", contents) -} - -// UpdateJavaAction updates an existing Java action. -func (w *Writer) UpdateJavaAction(ja *javaactions.JavaAction) error { - contents, err := w.serializeJavaAction(ja) - if err != nil { - return fmt.Errorf("failed to serialize java action: %w", err) - } - - return w.updateUnit(string(ja.ID), contents) -} - -// DeleteJavaAction deletes a Java action by ID. -func (w *Writer) DeleteJavaAction(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// WriteJavaSourceFile writes the Java source file to the javasource directory. -// moduleName is the lowercase module name (e.g., "mymodule") -// actionName is the action name (e.g., "ValidateEmail") -// javaCode is the executeAction() body code -// params is the list of parameters with their types -// returnType is the return type (can be nil for void) -func (w *Writer) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string) error { - // Get project root directory (parent of .mpr file) - projectRoot := filepath.Dir(w.reader.path) - - // Build the javasource path - moduleNameLower := strings.ToLower(moduleName) - javaDir := filepath.Join(projectRoot, "javasource", moduleNameLower, "actions") - - // Create directory if it doesn't exist - if err := os.MkdirAll(javaDir, 0755); err != nil { - return fmt.Errorf("failed to create javasource directory: %w", err) - } - - // Generate Java source (shared with the modelsdk engine) - source := javaactions.GenerateSource(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode) - - // Write the file, unless it already says exactly this. The counters feed the - // executor's "Modified" vs "Unchanged" reporting: a code action's body lives - // here rather than in its unit, so judging the statement on unit writes alone - // would call a body-only edit unchanged. - filePath := filepath.Join(javaDir, actionName+".java") - w.writesOffered++ - changed, err := javaactions.WriteSourceIfChanged(filePath, source) - if err != nil { - return fmt.Errorf("failed to write Java source file: %w", err) - } - if changed { - w.writesLanded++ - } - - return nil -} - -// DeleteJavaSourceFile removes the Java source file for a dropped Java action. -func (w *Writer) DeleteJavaSourceFile(moduleName, actionName string) error { - projectRoot := filepath.Dir(w.reader.path) - filePath := filepath.Join(projectRoot, "javasource", strings.ToLower(moduleName), "actions", actionName+".java") - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete Java source file: %w", err) - } - return nil -} - -// RenameJavaSourceFile renames the Java source file when a Java action is renamed. -func (w *Writer) RenameJavaSourceFile(moduleName, oldName, newName string) error { - projectRoot := filepath.Dir(w.reader.path) - dir := filepath.Join(projectRoot, "javasource", strings.ToLower(moduleName), "actions") - oldPath := filepath.Join(dir, oldName+".java") - newPath := filepath.Join(dir, newName+".java") - if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to rename Java source file: %w", err) - } - return nil -} - -// ReadJavaSourceFile reads the Java source file for a Java action. -func (w *Writer) ReadJavaSourceFile(moduleName, actionName string) (string, error) { - projectRoot := filepath.Dir(w.reader.path) - moduleNameLower := strings.ToLower(moduleName) - filePath := filepath.Join(projectRoot, "javasource", moduleNameLower, "actions", actionName+".java") - - content, err := os.ReadFile(filePath) - if err != nil { - return "", fmt.Errorf("failed to read Java source file: %w", err) - } - - return string(content), nil -} - -// serializeJavaAction serializes a Java action to BSON. -func (w *Writer) serializeJavaAction(ja *javaactions.JavaAction) ([]byte, error) { - // Build parameters array (storageListType: 2) - params := bson.A{int32(2)} // Array type marker for storageListType: 2 - for _, param := range ja.Parameters { - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(param.ID))}, - {Key: "$Type", Value: param.TypeName}, - {Key: "Category", Value: param.Category}, - {Key: "Description", Value: param.Description}, - {Key: "IsRequired", Value: param.IsRequired}, - {Key: "Name", Value: param.Name}, - } - if param.ParameterType != nil { - paramDoc = append(paramDoc, bson.E{Key: "ParameterType", Value: serializeParameterType(param.ParameterType)}) - } - params = append(params, paramDoc) - } - - // Build type parameters array (storageListType: 2) - typeParams := bson.A{int32(2)} // Array type marker for storageListType: 2 - for _, tp := range ja.TypeParameters { - tpID := string(tp.ID) - if tpID == "" { - tpID = generateUUID() - } - typeParams = append(typeParams, bson.D{ - {Key: "$ID", Value: idToBsonBinary(tpID)}, - {Key: "$Type", Value: "CodeActions$TypeParameter"}, - {Key: "Name", Value: tp.Name}, - }) - } - - // Build MicroflowActionInfo - var maiValue any - if ja.MicroflowActionInfo != nil { - maiValue = microflowActionInfoBSON(ja.MicroflowActionInfo) - } - - // Build main document - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ja.ID))}, - {Key: "$Type", Value: "JavaActions$JavaAction"}, - {Key: "ActionDefaultReturnName", Value: stringOrDefault(ja.ActionDefaultReturnName, "")}, - {Key: "Documentation", Value: ja.Documentation}, - {Key: "Excluded", Value: ja.Excluded}, - {Key: "ExportLevel", Value: stringOrDefault(ja.ExportLevel, "Hidden")}, - {Key: "MicroflowActionInfo", Value: maiValue}, - {Key: "Name", Value: ja.Name}, - {Key: "Parameters", Value: params}, - {Key: "TypeParameters", Value: typeParams}, - } - - // Add return type - if ja.ReturnType != nil { - doc = append(doc, bson.E{Key: "JavaReturnType", Value: serializeReturnType(ja.ReturnType)}) - } else { - // Default to void type - doc = append(doc, bson.E{Key: "JavaReturnType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - }}) - } - - return marshalUnitIDFirst(doc) -} - -// serializeReturnType serializes a CodeActionReturnType to BSON. -func serializeReturnType(t javaactions.CodeActionReturnType) bson.D { - if t == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - } - - switch v := t.(type) { - case *javaactions.VoidType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - case *javaactions.BooleanType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$BooleanType"}, - } - case *javaactions.IntegerType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.LongType: - // Mendix uses IntegerType for 64-bit integers (Long in Java) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.DecimalType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DecimalType"}, - } - case *javaactions.StringType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$StringType"}, - } - case *javaactions.DateTimeType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DateTimeType"}, - } - case *javaactions.EnumerationType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$EnumerationType"}, - {Key: "Enumeration", Value: v.Enumeration}, - } - case *javaactions.EntityType: - // Use ConcreteEntityType for return types - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - } - case *javaactions.ListType: - // ListType contains a Parameter which is a ConcreteEntityType - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ListType"}, - {Key: "Parameter", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - }}, - } - case *javaactions.TypeParameter: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ParameterizedEntityType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - } -} - -// serializeParameterType serializes a CodeActionParameterType to BSON. -// Parameter types are wrapped in BasicParameterType with a nested Type property. -func serializeParameterType(t javaactions.CodeActionParameterType) bson.D { - if t == nil { - // Default to String type wrapped in BasicParameterType - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$BasicParameterType"}, - {Key: "Type", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$StringType"}, - }}, - } - } - - // Special case for StringTemplateParameterType - not wrapped in BasicParameterType - if v, ok := t.(*javaactions.StringTemplateParameterType); ok { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$StringTemplateParameterType"}, - {Key: "Grammar", Value: v.Grammar}, - } - } - - // Special case for EntityTypeParameterType - not wrapped in BasicParameterType - if v, ok := t.(*javaactions.EntityTypeParameterType); ok { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$EntityTypeParameterType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - } - - // Special case for TypeParameter (ParameterizedEntityType) - wrapped in BasicParameterType - if v, ok := t.(*javaactions.TypeParameter); ok { - innerType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ParameterizedEntityType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$BasicParameterType"}, - {Key: "Type", Value: innerType}, - } - } - - // All other types are wrapped in BasicParameterType - innerType := serializeInnerType(t) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$BasicParameterType"}, - {Key: "Type", Value: innerType}, - } -} - -// serializeInnerType serializes the inner type for BasicParameterType. -func serializeInnerType(t javaactions.CodeActionParameterType) bson.D { - switch v := t.(type) { - case *javaactions.BooleanType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$BooleanType"}, - } - case *javaactions.IntegerType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.LongType: - // Mendix uses IntegerType for 64-bit integers (Long in Java) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.DecimalType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DecimalType"}, - } - case *javaactions.StringType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$StringType"}, - } - case *javaactions.DateTimeType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DateTimeType"}, - } - case *javaactions.EnumerationType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$EnumerationType"}, - {Key: "Enumeration", Value: v.Enumeration}, - } - case *javaactions.EntityType: - // Use ConcreteEntityType for entity parameters - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - } - case *javaactions.ListType: - // ListType contains a Parameter which is a ConcreteEntityType - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ListType"}, - {Key: "Parameter", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - }}, - } - case *javaactions.TypeParameter: - // ParameterizedEntityType - references a type parameter by ID - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ParameterizedEntityType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$StringType"}, - } - } -} diff --git a/sdk/mpr/writer_javaactions_enum_680_test.go b/sdk/mpr/writer_javaactions_enum_680_test.go deleted file mode 100644 index c7792fad96..0000000000 --- a/sdk/mpr/writer_javaactions_enum_680_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" -) - -// Issue #680: an EnumerationType parameter/return must serialize as -// CodeActions$EnumerationType with the Enumeration qualified name — never as an -// entity reference. - -func TestSerializeInnerType_Enumeration(t *testing.T) { - d := serializeInnerType(&javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Enumeration: "Barcode.BarcodeFormat", - }) - m := map[string]any{} - for _, e := range d { - m[e.Key] = e.Value - } - if m["$Type"] != "CodeActions$EnumerationType" { - t.Errorf("$Type = %v, want CodeActions$EnumerationType", m["$Type"]) - } - if m["Enumeration"] != "Barcode.BarcodeFormat" { - t.Errorf("Enumeration = %v", m["Enumeration"]) - } -} - -func TestSerializeReturnType_Enumeration(t *testing.T) { - d := serializeReturnType(&javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: "22222222-2222-2222-2222-222222222222"}, - Enumeration: "M.Status", - }) - m := map[string]any{} - for _, e := range d { - m[e.Key] = e.Value - } - if m["$Type"] != "CodeActions$EnumerationType" { - t.Errorf("$Type = %v, want CodeActions$EnumerationType", m["$Type"]) - } - if m["Enumeration"] != "M.Status" { - t.Errorf("Enumeration = %v", m["Enumeration"]) - } -} diff --git a/sdk/mpr/writer_javascriptactions.go b/sdk/mpr/writer_javascriptactions.go deleted file mode 100644 index 52c23b640a..0000000000 --- a/sdk/mpr/writer_javascriptactions.go +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - JavaScript action writer support. -package mpr - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" -) - -// CreateJavaScriptAction serializes and inserts a new JavaScript action unit. -func (w *Writer) CreateJavaScriptAction(jsa *JavaScriptAction) error { - if jsa.ID == "" { - jsa.ID = model.ID(generateUUID()) - } - jsa.TypeName = "JavaScriptActions$JavaScriptAction" - - contents, err := w.serializeJavaScriptAction(jsa) - if err != nil { - return fmt.Errorf("failed to serialize javascript action: %w", err) - } - return w.insertUnit(string(jsa.ID), string(jsa.ContainerID), "Documents", "JavaScriptActions$JavaScriptAction", contents) -} - -// UpdateJavaScriptAction rewrites an existing JavaScript action unit. -func (w *Writer) UpdateJavaScriptAction(jsa *JavaScriptAction) error { - jsa.TypeName = "JavaScriptActions$JavaScriptAction" - contents, err := w.serializeJavaScriptAction(jsa) - if err != nil { - return fmt.Errorf("failed to serialize javascript action: %w", err) - } - return w.updateUnit(string(jsa.ID), contents) -} - -// DeleteJavaScriptAction removes a JavaScript action unit. -func (w *Writer) DeleteJavaScriptAction(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeJavaScriptAction serializes a JavaScript action to BSON. The shape -// mirrors a Java action (shared parameter/return-type/MicroflowActionInfo -// serialization) with $Type JavaScriptActions$JavaScriptAction, JavaScript -// parameter $Types, and an added Platform field. -func (w *Writer) serializeJavaScriptAction(jsa *JavaScriptAction) ([]byte, error) { - params := bson.A{int32(2)} // typed-array marker - for _, param := range jsa.Parameters { - paramType := param.TypeName - if paramType == "" { - paramType = "JavaScriptActions$JavaScriptActionParameter" - } - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(param.ID))}, - {Key: "$Type", Value: paramType}, - {Key: "Category", Value: param.Category}, - {Key: "Description", Value: param.Description}, - {Key: "IsRequired", Value: param.IsRequired}, - {Key: "Name", Value: param.Name}, - } - if param.ParameterType != nil { - paramDoc = append(paramDoc, bson.E{Key: "ParameterType", Value: serializeParameterType(param.ParameterType)}) - } - params = append(params, paramDoc) - } - - typeParams := bson.A{int32(2)} - for _, tp := range jsa.TypeParameters { - tpID := string(tp.ID) - if tpID == "" { - tpID = generateUUID() - } - typeParams = append(typeParams, bson.D{ - {Key: "$ID", Value: idToBsonBinary(tpID)}, - {Key: "$Type", Value: "CodeActions$TypeParameter"}, - {Key: "Name", Value: tp.Name}, - }) - } - - var maiValue any - if jsa.MicroflowActionInfo != nil { - maiValue = microflowActionInfoBSON(jsa.MicroflowActionInfo) - } - - var returnType bson.D - if jsa.ReturnType != nil { - returnType = serializeReturnType(jsa.ReturnType) - } else { - returnType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - } - - platform := jsa.Platform - if platform == "" { - platform = "Web" - } - - // Key order follows what Studio Pro writes (alphabetical). - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(jsa.ID))}, - {Key: "$Type", Value: "JavaScriptActions$JavaScriptAction"}, - {Key: "ActionDefaultReturnName", Value: stringOrDefault(jsa.ActionDefaultReturnName, "ReturnValueName")}, - {Key: "Documentation", Value: jsa.Documentation}, - {Key: "Excluded", Value: jsa.Excluded}, - {Key: "ExportLevel", Value: stringOrDefault(jsa.ExportLevel, "Hidden")}, - {Key: "JavaReturnType", Value: returnType}, - {Key: "MicroflowActionInfo", Value: maiValue}, - {Key: "Name", Value: jsa.Name}, - {Key: "Parameters", Value: params}, - {Key: "Platform", Value: platform}, - {Key: "TypeParameters", Value: typeParams}, - } - - return marshalUnitIDFirst(doc) -} - -// jsActionSourceDir returns javascriptsource//actions with the module -// name LOWERCASED, which is where Mendix looks: a blank Mendix 11 app ships -// javascriptsource/nanoflowcommons/, /datawidgets/ and /webactions/ for modules -// named NanoflowCommons, DataWidgets and WebActions. -// -// Writing the original casing instead is silent and total: mxbuild finds no -// source at the path it reads, generates a stub whose body throws -// "JavaScript action was not implemented", and bundles that. The action parses, -// passes `mxcli check` and builds cleanly, then throws when a user clicks it. -// Only reproduces on a case-sensitive filesystem — on macOS and Windows the two -// spellings are the same directory, which is why it went unnoticed. -func (w *Writer) jsActionSourceDir(moduleName string) string { - return filepath.Join(filepath.Dir(w.reader.path), "javascriptsource", strings.ToLower(moduleName), "actions") -} - -// WriteJavaScriptSourceFile writes javascriptsource//actions/.js. -func (w *Writer) WriteJavaScriptSourceFile(moduleName, actionName string, jsCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType) error { - dir := w.jsActionSourceDir(moduleName) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create javascriptsource directory: %w", err) - } - source := javaactions.GenerateJavaScriptSource(actionName, jsCode, params, returnType) - w.writesOffered++ - changed, err := javaactions.WriteSourceIfChanged(filepath.Join(dir, actionName+".js"), source) - if err != nil { - return fmt.Errorf("failed to write JavaScript source file: %w", err) - } - if changed { - w.writesLanded++ - } - return nil -} - -// DeleteJavaScriptSourceFile removes the .js file for a dropped JavaScript action. -func (w *Writer) DeleteJavaScriptSourceFile(moduleName, actionName string) error { - filePath := filepath.Join(w.jsActionSourceDir(moduleName), actionName+".js") - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete JavaScript source file: %w", err) - } - return nil -} - -// RenameJavaScriptSourceFile renames the .js file when a JavaScript action is renamed. -func (w *Writer) RenameJavaScriptSourceFile(moduleName, oldName, newName string) error { - dir := w.jsActionSourceDir(moduleName) - oldPath := filepath.Join(dir, oldName+".js") - newPath := filepath.Join(dir, newName+".js") - if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to rename JavaScript source file: %w", err) - } - return nil -} diff --git a/sdk/mpr/writer_javascriptactions_test.go b/sdk/mpr/writer_javascriptactions_test.go deleted file mode 100644 index 14894a61e2..0000000000 --- a/sdk/mpr/writer_javascriptactions_test.go +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeJavaScriptAction_Shape asserts the serialized JS action uses the -// JavaScriptActions$ document and parameter $Type names, carries a Platform -// field, and reuses the CodeActions$ inner parameter/return types. -func TestSerializeJavaScriptAction_Shape(t *testing.T) { - w := &Writer{} - jsa := &JavaScriptAction{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Name: "JSA", - Platform: "All", - Parameters: []*javaactions.JavaActionParameter{ - { - BaseElement: model.BaseElement{ID: "22222222-2222-2222-2222-222222222222", TypeName: "JavaScriptActions$JavaScriptActionParameter"}, - Name: "Input", - IsRequired: true, - ParameterType: &javaactions.StringType{BaseElement: model.BaseElement{ID: "33333333-3333-3333-3333-333333333333", TypeName: "CodeActions$StringType"}}, - }, - }, - ReturnType: &javaactions.BooleanType{BaseElement: model.BaseElement{ID: "44444444-4444-4444-4444-444444444444", TypeName: "CodeActions$BooleanType"}}, - } - - raw, err := w.serializeJavaScriptAction(jsa) - if err != nil { - t.Fatal(err) - } - var doc bson.D - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - m := map[string]any{} - for _, e := range doc { - m[e.Key] = e.Value - } - - if m["$Type"] != "JavaScriptActions$JavaScriptAction" { - t.Errorf("$Type = %v", m["$Type"]) - } - if m["Platform"] != "All" { - t.Errorf("Platform = %v, want All", m["Platform"]) - } - if m["ActionDefaultReturnName"] != "ReturnValueName" { - t.Errorf("ActionDefaultReturnName = %v", m["ActionDefaultReturnName"]) - } - - params, ok := m["Parameters"].(bson.A) - if !ok || len(params) < 2 { - t.Fatalf("Parameters = %v", m["Parameters"]) - } - if marker, _ := params[0].(int32); marker != 2 { - t.Errorf("param array marker = %v, want 2", params[0]) - } - p0 := params[1].(bson.D) - var pType string - for _, e := range p0 { - if e.Key == "$Type" { - pType, _ = e.Value.(string) - } - } - if pType != "JavaScriptActions$JavaScriptActionParameter" { - t.Errorf("param $Type = %q", pType) - } -} - -// TestSerializeJavaScriptAction_DefaultPlatform asserts an unset platform -// defaults to Web. -func TestSerializeJavaScriptAction_DefaultPlatform(t *testing.T) { - w := &Writer{} - raw, err := w.serializeJavaScriptAction(&JavaScriptAction{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Name: "JSA", - }) - if err != nil { - t.Fatal(err) - } - var doc bson.D - _ = bson.Unmarshal(raw, &doc) - for _, e := range doc { - if e.Key == "Platform" && e.Value != "Web" { - t.Errorf("default Platform = %v, want Web", e.Value) - } - } -} diff --git a/sdk/mpr/writer_jsonstructure.go b/sdk/mpr/writer_jsonstructure.go deleted file mode 100644 index a1622e1068..0000000000 --- a/sdk/mpr/writer_jsonstructure.go +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// PrettyPrintJSON delegates to types.PrettyPrintJSON. -func PrettyPrintJSON(s string) string { return types.PrettyPrintJSON(s) } - -// BuildJsonElementsFromSnippet delegates to types.BuildJsonElementsFromSnippet. -func BuildJsonElementsFromSnippet(snippet string, customNameMap, itemNameMap map[string]string) ([]*JsonElement, error) { - return types.BuildJsonElementsFromSnippet(snippet, customNameMap, itemNameMap) -} - -// CreateJsonStructure creates a new JSON structure unit in the MPR. -func (w *Writer) CreateJsonStructure(js *JsonStructure) error { - if js.ID == "" { - js.ID = model.ID(generateUUID()) - } - if js.ExportLevel == "" { - js.ExportLevel = "Hidden" - } - - contents, err := serializeJsonStructure(js) - if err != nil { - return err - } - - return w.insertUnit(string(js.ID), string(js.ContainerID), - "Documents", "JsonStructures$JsonStructure", contents) -} - -// UpdateJsonStructure re-serializes an existing JSON structure in-place, preserving its ID. -func (w *Writer) UpdateJsonStructure(js *JsonStructure) error { - contents, err := serializeJsonStructure(js) - if err != nil { - return err - } - return w.updateUnit(string(js.ID), contents) -} - -// DeleteJsonStructure deletes a JSON structure by ID. -func (w *Writer) DeleteJsonStructure(id string) error { - return w.deleteUnit(id) -} - -func serializeJsonStructure(js *JsonStructure) ([]byte, error) { - elements := bson.A{int32(2)} - for _, elem := range js.Elements { - elements = append(elements, serializeJsonElement(elem)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(js.ID))}, - {Key: "$Type", Value: "JsonStructures$JsonStructure"}, - {Key: "Documentation", Value: js.Documentation}, - {Key: "Elements", Value: elements}, - {Key: "Excluded", Value: js.Excluded}, - {Key: "ExportLevel", Value: js.ExportLevel}, - {Key: "JsonSnippet", Value: js.JsonSnippet}, - {Key: "Name", Value: js.Name}, - } - - return marshalUnitIDFirst(doc) -} - -// serializeJsonElement serializes a single JsonElement to BSON. -// Note: JsonStructures$JsonElement uses int32 for numeric properties (MinOccurs, MaxOccurs, etc.), -// unlike most other Mendix document types which use int64. Verified against Studio Pro-generated BSON. -func serializeJsonElement(elem *JsonElement) bson.D { - children := bson.A{int32(2)} - for _, child := range elem.Children { - children = append(children, serializeJsonElement(child)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "JsonStructures$JsonElement"}, - {Key: "Children", Value: children}, - {Key: "ElementType", Value: elem.ElementType}, - {Key: "ErrorMessage", Value: ""}, - {Key: "ExposedItemName", Value: elem.ExposedItemName}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "FractionDigits", Value: int32(elem.FractionDigits)}, - {Key: "IsDefaultType", Value: elem.IsDefaultType}, - {Key: "MaxLength", Value: int32(elem.MaxLength)}, - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "Nillable", Value: elem.Nillable}, - {Key: "OriginalValue", Value: elem.OriginalValue}, - {Key: "Path", Value: elem.Path}, - {Key: "PrimitiveType", Value: elem.PrimitiveType}, - {Key: "TotalDigits", Value: int32(elem.TotalDigits)}, - {Key: "WarningMessage", Value: ""}, - } -} diff --git a/sdk/mpr/writer_listoperation_test.go b/sdk/mpr/writer_listoperation_test.go deleted file mode 100644 index 5285256218..0000000000 --- a/sdk/mpr/writer_listoperation_test.go +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeListOperation_FindByAttribute(t *testing.T) { - doc := serializeListOperation(µflows.FindByAttributeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - Attribute: "Demo.Item.Code", - Expression: "$IteratorItem/ExternalCode", - }) - fields := listOperationDocMap(doc) - - if got := fields["$Type"]; got != "Microflows$Find" { - t.Fatalf("$Type = %v, want Microflows$Find", got) - } - if got := fields["Attribute"]; got != "Demo.Item.Code" { - t.Fatalf("Attribute = %v, want Demo.Item.Code", got) - } - if got := fields["Expression"]; got != "$IteratorItem/ExternalCode" { - t.Fatalf("Expression = %v, want $IteratorItem/ExternalCode", got) - } - if got := fields["ListName"]; got != "Items" { - t.Fatalf("ListName = %v, want Items", got) - } -} - -func TestSerializeListOperation_FilterByAssociation(t *testing.T) { - doc := serializeListOperation(µflows.FilterByAttributeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - Association: "Demo.Item_Category", - Expression: "$Category", - }) - fields := listOperationDocMap(doc) - - if got := fields["$Type"]; got != "Microflows$Filter" { - t.Fatalf("$Type = %v, want Microflows$Filter", got) - } - if got := fields["Association"]; got != "Demo.Item_Category" { - t.Fatalf("Association = %v, want Demo.Item_Category", got) - } - if got := fields["Expression"]; got != "$Category" { - t.Fatalf("Expression = %v, want $Category", got) - } -} - -// upstream #966, the legacy engine's half. -// -// serializeListOperation had no ListRangeOperation case at all, so it fell -// through to `return nil` — and serializeListOperationAction appends that nil -// under "NewOperation" without checking, which lands in the file as an empty -// sub-document. The result is not a range that lost its bounds; it is a project -// Mendix cannot OPEN. Measured on mxbuild 11.13.0: -// -// ERROR: System.AggregateException: … (Expected '$ID' as the first property -// of a storage object, but got 'NewOperation'.) -// at StreamingBsonUnitReader.ConstructObject(…) -// -// The control was the same script with `filter` in place of `range`: that one -// wrote a well-formed NewOperation and loaded fine, so the Range case is what -// produced the empty document. -// -// The parser has read the nested CustomRange since it was written (see -// TestParseListOperation_Range), so the writer is the only side that was -// missing — which is why `--engine legacy` was never a workaround for #966. -func TestSerializeListOperation_Range(t *testing.T) { - doc := serializeListOperation(µflows.ListRangeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - OffsetExpression: "$Skip", - LimitExpression: "$Take", - }) - if doc == nil { - t.Fatal("serializeListOperation returned nil — the action is written with an empty NewOperation and Mendix cannot load the project") - } - fields := listOperationDocMap(doc) - - if got := fields["$Type"]; got != "Microflows$ListRange" { - t.Fatalf("$Type = %v, want Microflows$ListRange", got) - } - if got := fields["ListName"]; got != "Items" { - t.Errorf("ListName = %v, want Items", got) - } - // The bounds live one level down, in a Microflows$CustomRange child — the - // shape the parser beside this file already expects. Flat keys build a - // model mxbuild rejects with CE6520. - cr, ok := fields["CustomRange"].(bson.D) - if !ok { - t.Fatalf("CustomRange = %#v, want a bson.D child document", fields["CustomRange"]) - } - crFields := listOperationDocMap(cr) - if got := crFields["$Type"]; got != "Microflows$CustomRange" { - t.Errorf("CustomRange $Type = %v, want Microflows$CustomRange", got) - } - if got := crFields["OffsetExpression"]; got != "$Skip" { - t.Errorf("CustomRange.OffsetExpression = %v, want $Skip", got) - } - if got := crFields["LimitExpression"]; got != "$Take" { - t.Errorf("CustomRange.LimitExpression = %v, want $Take", got) - } -} - -// The write→read pairing within the legacy engine. The parser was already -// right, so this asserts the writer now speaks the same shape the parser reads -// — the property that was missing when `range` was the one list operation -// legacy could parse but not write. -func TestSerializeListOperation_RangeRoundTrips(t *testing.T) { - doc := serializeListOperation(µflows.ListRangeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - OffsetExpression: "$Skip", - LimitExpression: "$Take", - }) - - // Re-present the document the way the parser receives it. - raw := map[string]any{} - for _, e := range doc { - if child, ok := e.Value.(bson.D); ok { - m := map[string]any{} - for _, ce := range child { - m[ce.Key] = ce.Value - } - raw[e.Key] = m - continue - } - raw[e.Key] = e.Value - } - - op, ok := parseListOperation(raw).(*microflows.ListRangeOperation) - if !ok { - t.Fatalf("parseListOperation → %T, want *microflows.ListRangeOperation", parseListOperation(raw)) - } - if op.OffsetExpression != "$Skip" || op.LimitExpression != "$Take" { - t.Errorf("round trip: offset=%q limit=%q, want $Skip/$Take", op.OffsetExpression, op.LimitExpression) - } -} - -// An operation the writer has no case for must not reach the file as an empty -// NewOperation: that is the unloadable-project shape above, and it is worse -// than the honest alternative (no action → mxbuild's CE0008 "No action -// defined", which names the activity). unknownListOperation stands in for a -// model type a future metamodel adds before this writer learns it. -type unknownListOperation struct{ microflows.HeadOperation } - -func TestSerializeListOperationAction_OmitsAnUnserializableOperation(t *testing.T) { - doc := serializeListOperationAction(µflows.ListOperationAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - Operation: &unknownListOperation{}, - OutputVariable: "Out", - }) - for _, e := range doc { - if e.Key == "NewOperation" { - t.Fatalf("NewOperation = %#v; an operation the writer cannot serialize must be omitted, "+ - "not written as an empty document (Mendix: \"Expected '$ID' as the first property of a storage object\")", e.Value) - } - } -} - -func listOperationDocMap(doc bson.D) map[string]any { - fields := make(map[string]any, len(doc)) - for _, elem := range doc { - fields[elem.Key] = elem.Value - } - return fields -} diff --git a/sdk/mpr/writer_listview_source_test.go b/sdk/mpr/writer_listview_source_test.go deleted file mode 100644 index 53c1a07c10..0000000000 --- a/sdk/mpr/writer_listview_source_test.go +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/sdk/pages" -) - -func dLookup(d bson.D, key string) (any, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// A ListView database source (legacy engine) must serialize with the same -// metamodel-valid shape as the pluggable CustomWidgetXPathSource: a -// Forms$GridSortBar with SortItems, and a Forms$ListViewSearch with SearchRefs. -// The old code emitted a bogus Forms$ListViewSort and a `Paths` key (the search -// list was renamed to SearchRefs in 7.11.0), producing a client model that -// omitted the arrays the Mendix client reads .length of → runtime crash in -// retrieveByXPath/processResult. -func TestSerializeListViewDataSource_Database(t *testing.T) { - doc := serializeListViewDataSource(&pages.DatabaseSource{ - EntityName: "M.Item", - Sorting: []*pages.GridSort{{AttributePath: "M.Item.Name", Direction: "Ascending"}}, - }) - - if v, _ := dLookup(doc, "$Type"); v != "Forms$ListViewXPathSource" { - t.Fatalf("$Type = %v", v) - } - // Must NOT carry the bogus keys. - if _, ok := dLookup(doc, "Sort"); ok { - t.Error("legacy ListView source must not emit a `Sort` key (Forms$ListViewSort is not a property of ListViewXPathSource)") - } - - // SortBar → GridSortBar with a SortItems list holding the GridSortItem. - sortBarV, ok := dLookup(doc, "SortBar") - if !ok { - t.Fatal("SortBar missing") - } - sortBar := sortBarV.(bson.D) - if v, _ := dLookup(sortBar, "$Type"); v != "Forms$GridSortBar" { - t.Errorf("SortBar $Type = %v", v) - } - items, ok := dLookup(sortBar, "SortItems") - if !ok { - t.Fatal("SortBar.SortItems missing") - } - if a, _ := items.(bson.A); len(a) < 2 { - t.Errorf("SortItems should contain the sort item, got %v", items) - } - - // Search → ListViewSearch with SearchRefs (not `Paths`). - searchV, ok := dLookup(doc, "Search") - if !ok { - t.Fatal("Search missing") - } - search := searchV.(bson.D) - if _, ok := dLookup(search, "SearchRefs"); !ok { - t.Error("Search.SearchRefs missing") - } - if _, ok := dLookup(search, "Paths"); ok { - t.Error("Search must not emit the obsolete `Paths` key (renamed SearchRefs in 7.11.0)") - } -} - -// The empty-datasource fallback (nil source) must produce the same valid shape. -func TestEmptyListViewXPathSource_Shape(t *testing.T) { - doc := emptyListViewXPathSource() - if _, ok := dLookup(doc, "SortBar"); !ok { - t.Error("fallback source missing SortBar") - } - searchV, ok := dLookup(doc, "Search") - if !ok { - t.Fatal("fallback source missing Search") - } - if _, ok := dLookup(searchV.(bson.D), "SearchRefs"); !ok { - t.Error("fallback Search missing SearchRefs") - } -} diff --git a/sdk/mpr/writer_listview_template_test.go b/sdk/mpr/writer_listview_template_test.go deleted file mode 100644 index c0724e4275..0000000000 --- a/sdk/mpr/writer_listview_template_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeListViewTemplateCarriesTheSpecialization pins the legacy engine's -// half of #940. -// -// The writer already emitted Forms$ListViewTemplate elements, but with only -// {$ID, $Type, Widgets} — no entity — so every template it wrote matched nothing -// and rendered never. Studio Pro's own documents (ako/TestApp, -// Pages.Vehicle_Overview) carry the entity under the storage name "Entity", not -// the SDK name "Specialization". -func TestSerializeListViewTemplateCarriesTheSpecialization(t *testing.T) { - lv := &pages.ListView{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: model.ID("lv"), TypeName: "Forms$ListView"}, - Name: "vehicleListView", - }, - Templates: []*pages.ListViewTemplate{ - {BaseElement: model.BaseElement{ID: model.ID("t1")}, Specialization: "Pages.Bus"}, - {BaseElement: model.BaseElement{ID: model.ID("t2")}, Specialization: "Pages.Truck"}, - }, - } - - doc := serializeListView(lv) - - var templates bson.A - for _, e := range doc { - if e.Key == "Templates" { - templates, _ = e.Value.(bson.A) - } - } - // The first element is the typed-array marker, not a template. - if len(templates) != 3 { - t.Fatalf("Templates has %d element(s) (marker + templates), want 3", len(templates)) - } - - want := []string{"Pages.Bus", "Pages.Truck"} - for i, wantEntity := range want { - tpl, ok := templates[i+1].(bson.D) - if !ok { - t.Fatalf("template %d is %T, want bson.D", i, templates[i+1]) - } - var got string - var keys []string - for _, e := range tpl { - keys = append(keys, e.Key) - if e.Key == "Entity" { - got, _ = e.Value.(string) - } - } - if got != wantEntity { - t.Errorf("template %d Entity = %q, want %q (keys present: %v)", i, got, wantEntity, keys) - } - // Key order matches Studio Pro's documents. - if len(keys) != 4 || keys[0] != "$ID" || keys[1] != "$Type" || keys[2] != "Entity" || keys[3] != "Widgets" { - t.Errorf("template %d keys = %v, want [$ID $Type Entity Widgets]", i, keys) - } - } -} diff --git a/sdk/mpr/writer_microflow.go b/sdk/mpr/writer_microflow.go deleted file mode 100644 index 55bdd607ad..0000000000 --- a/sdk/mpr/writer_microflow.go +++ /dev/null @@ -1,864 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "sort" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/sdk/mpr/version" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateMicroflow creates a new microflow. -func (w *Writer) CreateMicroflow(mf *microflows.Microflow) error { - if mf.ID == "" { - mf.ID = model.ID(generateUUID()) - } - mf.TypeName = "Microflows$Microflow" - - contents, err := w.serializeMicroflow(mf) - if err != nil { - return fmt.Errorf("failed to serialize microflow: %w", err) - } - - return w.insertUnit(string(mf.ID), string(mf.ContainerID), "Documents", "Microflows$Microflow", contents) -} - -// UpdateMicroflow updates an existing microflow. -func (w *Writer) UpdateMicroflow(mf *microflows.Microflow) error { - contents, err := w.serializeMicroflow(mf) - if err != nil { - return fmt.Errorf("failed to serialize microflow: %w", err) - } - - return w.updateUnit(string(mf.ID), contents) -} - -// DeleteMicroflow deletes a microflow. -func (w *Writer) DeleteMicroflow(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveMicroflow moves a microflow to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content -// (layout positions, flow connections, etc.) as-is. -func (w *Writer) MoveMicroflow(mf *microflows.Microflow) error { - return w.moveUnitByID(string(mf.ID), string(mf.ContainerID)) -} - -// CreateNanoflow creates a new nanoflow. -func (w *Writer) CreateNanoflow(nf *microflows.Nanoflow) error { - if nf.ID == "" { - nf.ID = model.ID(generateUUID()) - } - nf.TypeName = "Microflows$Nanoflow" - - contents, err := w.serializeNanoflow(nf) - if err != nil { - return fmt.Errorf("failed to serialize nanoflow: %w", err) - } - - return w.insertUnit(string(nf.ID), string(nf.ContainerID), "Documents", "Microflows$Nanoflow", contents) -} - -// UpdateNanoflow updates an existing nanoflow. -func (w *Writer) UpdateNanoflow(nf *microflows.Nanoflow) error { - contents, err := w.serializeNanoflow(nf) - if err != nil { - return fmt.Errorf("failed to serialize nanoflow: %w", err) - } - - return w.updateUnit(string(nf.ID), contents) -} - -// DeleteNanoflow deletes a nanoflow. -func (w *Writer) DeleteNanoflow(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveNanoflow moves a nanoflow to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MoveNanoflow(nf *microflows.Nanoflow) error { - return w.moveUnitByID(string(nf.ID), string(nf.ContainerID)) -} - -func (w *Writer) serializeMicroflow(mf *microflows.Microflow) ([]byte, error) { - // Build main document with required fields in correct order - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(mf.ID))}, - {Key: "$Type", Value: "Microflows$Microflow"}, - {Key: "AllowConcurrentExecution", Value: mf.AllowConcurrentExecution}, - {Key: "AllowedModuleRoles", Value: allowedModuleRolesArray(mf.AllowedModuleRoles)}, - // Carried, not hardcoded — see the modelsdk twin. A hardcoded false - // turned "apply entity access" OFF on every rewrite, widening what the - // microflow may read and write. - {Key: "ApplyEntityAccess", Value: mf.ApplyEntityAccess}, - {Key: "ConcurrencyErrorMicroflow", Value: ""}, - {Key: "ConcurrenyErrorMessage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, // Empty array marker - }}, - {Key: "Documentation", Value: mf.Documentation}, - {Key: "Excluded", Value: mf.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - } - - // Add Flows array (SequenceFlows and AnnotationFlows go here, not in ObjectCollection) - // The serialized shape depends on the project's Mendix major version. - // Fall back to the project default when no MPR is attached (in-memory tests). - majorVersion := version.DefaultVersion().MajorVersion - if pv := w.reader.ProjectVersion(); pv != nil { - majorVersion = pv.MajorVersion - } - flows := bson.A{int32(3)} // Start with array type marker - if mf.ObjectCollection != nil { - for _, flow := range mf.ObjectCollection.Flows { - flows = append(flows, serializeSequenceFlow(flow, majorVersion)) - } - for _, af := range mf.ObjectCollection.AnnotationFlows { - flows = append(flows, serializeAnnotationFlow(af, majorVersion)) - } - } - doc = append(doc, bson.E{Key: "Flows", Value: flows}) - - // Add remaining fields - doc = append(doc, bson.E{Key: "MarkAsUsed", Value: mf.MarkAsUsed}) - doc = append(doc, bson.E{Key: "MicroflowActionInfo", Value: nil}) - - // Note: Parameters are NOT stored in MicroflowParameterCollection - // They go in ObjectCollection.Objects as Microflows$MicroflowParameter entries - - // Add return type - if mf.ReturnType != nil { - doc = append(doc, bson.E{Key: "MicroflowReturnType", Value: serializeMicroflowDataType(mf.ReturnType)}) - } - - doc = append(doc, bson.E{Key: "Name", Value: mf.Name}) - - // Add object collection (without flows - they're in Flows array) - // Parameters go in ObjectCollection.Objects, pass them here - if mf.ObjectCollection != nil { - doc = append(doc, bson.E{Key: "ObjectCollection", Value: serializeMicroflowObjectCollectionWithoutFlows(mf.ObjectCollection, mf.Parameters, majorVersion)}) - } - - // ReturnVariableName, StableId, Url, and UrlSearchParameters were added in - // Mendix 10; Mendix 9 projects do not know about these fields and Studio Pro - // raises metamodel errors if they're present. - if majorVersion >= 10 { - // ReturnVariableName is "" by default (Studio Pro convention). - // Only set a custom name when explicitly specified via "RETURNS xxx AS $VarName". - doc = append(doc, bson.E{Key: "ReturnVariableName", Value: mf.ReturnVariableName}) - doc = append(doc, bson.E{Key: "StableId", Value: idToBsonBinary(generateUUID())}) - doc = append(doc, bson.E{Key: "Url", Value: ""}) - doc = append(doc, bson.E{Key: "UrlSearchParameters", Value: bson.A{int32(1)}}) - } - doc = append(doc, bson.E{Key: "WorkflowActionInfo", Value: nil}) - - return marshalUnitIDFirst(doc) -} - -// serializeSequenceFlow serializes a SequenceFlow to BSON with correct structure. -// -// The case value shape is version-specific: -// - Mendix 9: inline `NewCaseValue` document (NoCase for non-decision flows, -// EnumerationCase for decision branches). `CaseValues` is omitted. -// - Mendix 10+: `CaseValues = [marker, case]` where the case is always present -// (at minimum a NoCase object). Studio Pro rejects `CaseValues = [marker]` -// alone with CE0079/CE0773 "condition value must be configured". -func serializeSequenceFlow(flow *microflows.SequenceFlow, majorVersion int) bson.D { - // Build the case document. Every sequence flow needs a case — NoCase is the - // default when no branch condition has been set. - caseDoc := buildSequenceFlowCase(flow.CaseValue) - - originCV := flow.OriginControlVector - if originCV == "" { - originCV = "0;0" - } - destCV := flow.DestinationControlVector - if destCV == "" { - destCV = "0;0" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(flow.ID))}, - {Key: "$Type", Value: "Microflows$SequenceFlow"}, - } - - if majorVersion <= 9 { - // Legacy Mendix 9 shape: - // - inline NewCaseValue (no CaseValues array) - // - OriginBezierVector / DestinationBezierVector are top-level strings - // (no nested Line: Microflows$BezierCurve document) - doc = append(doc, bson.E{Key: "DestinationBezierVector", Value: destCV}) - doc = append(doc, bson.E{Key: "DestinationConnectionIndex", Value: int32(flow.DestinationConnectionIndex)}) - doc = append(doc, bson.E{Key: "DestinationPointer", Value: idToBsonBinary(string(flow.DestinationID))}) - doc = append(doc, bson.E{Key: "IsErrorHandler", Value: flow.IsErrorHandler}) - doc = append(doc, bson.E{Key: "NewCaseValue", Value: caseDoc}) - doc = append(doc, bson.E{Key: "OriginBezierVector", Value: originCV}) - doc = append(doc, bson.E{Key: "OriginConnectionIndex", Value: int32(flow.OriginConnectionIndex)}) - doc = append(doc, bson.E{Key: "OriginPointer", Value: idToBsonBinary(string(flow.OriginID))}) - return doc - } - - // Modern format (Mx 10+): CaseValues = [marker, caseDoc]. - doc = append(doc, bson.E{Key: "CaseValues", Value: bson.A{int32(2), caseDoc}}) - doc = append(doc, bson.E{Key: "DestinationConnectionIndex", Value: int32(flow.DestinationConnectionIndex)}) - doc = append(doc, bson.E{Key: "DestinationPointer", Value: idToBsonBinary(string(flow.DestinationID))}) - doc = append(doc, bson.E{Key: "IsErrorHandler", Value: flow.IsErrorHandler}) - doc = append(doc, bson.E{Key: "Line", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$BezierCurve"}, - {Key: "DestinationControlVector", Value: destCV}, - {Key: "OriginControlVector", Value: originCV}, - }}) - doc = append(doc, bson.E{Key: "OriginConnectionIndex", Value: int32(flow.OriginConnectionIndex)}) - doc = append(doc, bson.E{Key: "OriginPointer", Value: idToBsonBinary(string(flow.OriginID))}) - return doc -} - -// buildSequenceFlowCase renders the case document for a sequence flow. -// When no case has been set on the flow, a NoCase document is synthesised — -// Studio Pro requires every SequenceFlow to carry an explicit case object. -func buildSequenceFlowCase(cv microflows.CaseValue) bson.D { - // Normalise value receivers to pointers so each case is handled once. - switch c := cv.(type) { - case microflows.EnumerationCase: - cv = &c - case microflows.NoCase: - cv = &c - case microflows.ExpressionCase: - cv = &c - case microflows.InheritanceCase: - cv = &c - } - - switch c := cv.(type) { - case *microflows.EnumerationCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$EnumerationCase"}, - {Key: "Value", Value: c.Value}, - } - case *microflows.NoCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$NoCase"}, - } - case *microflows.ExpressionCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - // Studio Pro always uses EnumerationCase with Value="true"/"false" on the - // SequenceFlow; the expression itself lives on ExclusiveSplit.SplitCondition. - // This applies to all Mendix versions — Microflows$ExpressionCase was a - // mxcli-only type that Studio Pro has never recognised. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$EnumerationCase"}, - {Key: "Value", Value: c.Expression}, - } - case *microflows.InheritanceCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$InheritanceCase"}, - {Key: "Value", Value: c.EntityQualifiedName}, - } - } - // Default: synthesise a NoCase document with a fresh ID. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$NoCase"}, - } -} - -// serializeAnnotationFlow serializes an AnnotationFlow to BSON. -// The line shape is version-specific: Mendix 9 stores OriginBezierVector / -// DestinationBezierVector as top-level strings, while Mendix 10+ nests them -// inside a Microflows$BezierCurve document under `Line`. -func serializeAnnotationFlow(af *microflows.AnnotationFlow, majorVersion int) bson.D { - if majorVersion <= 9 { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(af.ID))}, - {Key: "$Type", Value: "Microflows$AnnotationFlow"}, - {Key: "DestinationBezierVector", Value: "0;0"}, - {Key: "DestinationConnectionIndex", Value: int32(0)}, - {Key: "DestinationPointer", Value: idToBsonBinary(string(af.DestinationID))}, - {Key: "OriginBezierVector", Value: "0;0"}, - {Key: "OriginConnectionIndex", Value: int32(0)}, - {Key: "OriginPointer", Value: idToBsonBinary(string(af.OriginID))}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(af.ID))}, - {Key: "$Type", Value: "Microflows$AnnotationFlow"}, - {Key: "DestinationConnectionIndex", Value: int32(0)}, - {Key: "DestinationPointer", Value: idToBsonBinary(string(af.DestinationID))}, - {Key: "Line", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$BezierCurve"}, - {Key: "DestinationControlVector", Value: "0;0"}, - {Key: "OriginControlVector", Value: "0;0"}, - }}, - {Key: "OriginConnectionIndex", Value: int32(0)}, - {Key: "OriginPointer", Value: idToBsonBinary(string(af.OriginID))}, - } -} - -// serializeMicroflowParameter serializes a MicroflowParameter to BSON. -// Parameters go in ObjectCollection.Objects, not in a separate collection. -// -// DefaultValue and IsRequired were introduced in Mendix 10; emitting them on a -// Mendix 9 project trips the Studio Pro metamodel checker, so they are gated. -func serializeMicroflowParameter(p *microflows.MicroflowParameter, posX int, majorVersion int) bson.D { - // An authored position is written as given; without one the parameter goes - // where the layout puts it — a row of boxes along the top of the canvas. - pos := microflows.DerivedParameterPosition(posX) - if p.Position != nil { - pos = *p.Position - } - relativeMiddlePoint := pointToString(pos) - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(p.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowParameter"}, - } - if majorVersion >= 10 { - doc = append(doc, bson.E{Key: "DefaultValue", Value: ""}) - } - doc = append(doc, bson.E{Key: "Documentation", Value: p.Documentation}) - doc = append(doc, bson.E{Key: "HasVariableNameBeenChanged", Value: false}) - if majorVersion >= 10 { - doc = append(doc, bson.E{Key: "IsRequired", Value: true}) - } - doc = append(doc, bson.E{Key: "Name", Value: p.Name}) - doc = append(doc, bson.E{Key: "RelativeMiddlePoint", Value: relativeMiddlePoint}) - doc = append(doc, bson.E{Key: "Size", Value: "30;30"}) - if p.Type != nil { - doc = append(doc, bson.E{Key: "VariableType", Value: serializeMicroflowDataType(p.Type)}) - } - return doc -} - -// serializeMicroflowDataType serializes a microflow data type to BSON. -func serializeMicroflowDataType(dt microflows.DataType) bson.D { - if dt == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - } - - switch t := dt.(type) { - case *microflows.BooleanType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$BooleanType"}, - } - case *microflows.IntegerType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case *microflows.LongType: - // Mendix uses IntegerType for 64-bit integers (Long in Java) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case *microflows.DecimalType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$DecimalType"}, - } - case *microflows.StringType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - case *microflows.DateTimeType, *microflows.DateType: // Both map to DataTypes$DateTimeType in BSON; Date is distinguished by LocalizeDate=false at the attribute level - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$DateTimeType"}, - } - case *microflows.BinaryType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$BinaryType"}, - } - case *microflows.VoidType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - case *microflows.ObjectType: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - } - // Entity is a BY_NAME_REFERENCE - stored as qualified name string, not binary GUID - if t.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: t.EntityQualifiedName}) - } - return doc - case *microflows.ListType: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ListType"}, - } - // Entity is a BY_NAME_REFERENCE - stored as qualified name string, not binary GUID - if t.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: t.EntityQualifiedName}) - } - return doc - case *microflows.EnumerationType: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$EnumerationType"}, - } - // Enumeration is a BY_NAME_REFERENCE - stored as qualified name string, not binary GUID - if t.EnumerationQualifiedName != "" { - doc = append(doc, bson.E{Key: "Enumeration", Value: t.EnumerationQualifiedName}) - } - return doc - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - } -} - -// serializeMicroflowObjectCollectionWithoutFlows serializes the object collection to BSON (flows are in separate Flows array). -// Parameters are also included in the Objects array. -func serializeMicroflowObjectCollectionWithoutFlows(oc *microflows.MicroflowObjectCollection, params []*microflows.MicroflowParameter, majorVersion int) bson.D { - // Start with array type marker, then serialize objects (NOT flows) - objects := bson.A{int32(3)} // Array type marker - - // Add parameters first (they appear at the top of the microflow) - for i, p := range params { - objects = append(objects, serializeMicroflowParameter(p, i, majorVersion)) - } - - // Add regular microflow objects - for _, obj := range oc.Objects { - if objDoc := serializeMicroflowObject(obj); objDoc != nil { - objects = append(objects, objDoc) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(oc.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: objects}, - } -} - -// serializeMicroflowObjectCollection serializes the object collection for nested collections (like in LoopedActivity). -// Note: Flows are NOT included here - in Mendix, all flows are stored at the top-level microflow, -// not inside nested ObjectCollections. SequenceFlow's container must be a Microflow, not a MicroflowObjectCollection. -func serializeMicroflowObjectCollection(oc *microflows.MicroflowObjectCollection) bson.D { - objects := bson.A{int32(3)} // Array type marker - - for _, obj := range oc.Objects { - if objDoc := serializeMicroflowObject(obj); objDoc != nil { - objects = append(objects, objDoc) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(oc.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: objects}, - } -} - -// serializeMicroflowObject serializes a single microflow object. -func serializeMicroflowObject(obj microflows.MicroflowObject) bson.D { - switch o := obj.(type) { - case *microflows.StartEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$StartEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.EndEvent: - // Pristine Mx 9 EndEvents carry `ReturnValue` but not a synthetic trailing - // line break. Adding one can make Studio Pro reject list-return EndEvents - // with CE0117 even though mxcli's parser accepts the expression. - returnValue := o.ReturnValue - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$EndEvent"}, - {Key: "Documentation", Value: ""}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "ReturnValue", Value: returnValue}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - return doc - - case *microflows.ErrorEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ErrorEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.ActionActivity: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ActionActivity"}, - } - if o.Action != nil { - doc = append(doc, bson.E{Key: "Action", Value: serializeMicroflowAction(o.Action)}) - } - bgColor := o.BackgroundColor - if bgColor == "" { - bgColor = "Default" - } - doc = append(doc, bson.E{Key: "AutoGenerateCaption", Value: o.AutoGenerateCaption}) - doc = append(doc, bson.E{Key: "BackgroundColor", Value: bgColor}) - doc = append(doc, bson.E{Key: "Caption", Value: o.Caption}) - doc = append(doc, bson.E{Key: "Disabled", Value: o.Disabled}) - doc = append(doc, bson.E{Key: "Documentation", Value: o.Documentation}) - doc = append(doc, bson.E{Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}) - doc = append(doc, bson.E{Key: "Size", Value: sizeToString(o.Size)}) - return doc - - case *microflows.ExclusiveSplit: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ExclusiveSplit"}, - {Key: "Caption", Value: o.Caption}, - {Key: "Documentation", Value: o.Documentation}, - {Key: "ErrorHandlingType", Value: string(o.ErrorHandlingType)}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - // Serialize SplitCondition - if o.SplitCondition != nil { - switch sc := o.SplitCondition.(type) { - case *microflows.ExpressionSplitCondition: - doc = append(doc, bson.E{Key: "SplitCondition", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(sc.ID))}, - {Key: "$Type", Value: "Microflows$ExpressionSplitCondition"}, - {Key: "Expression", Value: sc.Expression}, - }}) - case *microflows.RuleSplitCondition: - // Mendix nests the rule reference under a RuleCall sub-document - // whose Microflow field holds the rule's qualified name - // (rules share the microflow namespace). ParameterMappings are - // scoped inside RuleCall too — see parser_microflow.go. - ruleCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$RuleCall"}, - {Key: "Microflow", Value: sc.RuleQualifiedName}, - } - if len(sc.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range sc.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$RuleCallParameterMapping"}, - {Key: "Parameter", Value: pm.ParameterName}, - {Key: "Argument", Value: pm.Argument}, - } - mappings = append(mappings, mapping) - } - ruleCall = append(ruleCall, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - ruleCall = append(ruleCall, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) - } - doc = append(doc, bson.E{Key: "SplitCondition", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(sc.ID))}, - {Key: "$Type", Value: "Microflows$RuleSplitCondition"}, - {Key: "RuleCall", Value: ruleCall}, - }}) - } - } - return doc - - case *microflows.ExclusiveMerge: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ExclusiveMerge"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.InheritanceSplit: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$InheritanceSplit"}, - {Key: "Caption", Value: o.Caption}, - {Key: "Documentation", Value: o.Documentation}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(o.ErrorHandlingType), "Rollback")}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - {Key: "SplitVariableName", Value: o.VariableName}, - } - - case *microflows.LoopedActivity: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$LoopedActivity"}, - {Key: "ErrorHandlingType", Value: string(o.ErrorHandlingType)}, - } - // Serialize LoopSource (IterableList or WhileLoopCondition) - if o.LoopSource != nil { - switch ls := o.LoopSource.(type) { - case *microflows.IterableList: - loopSource := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ls.ID))}, - {Key: "$Type", Value: "Microflows$IterableList"}, - {Key: "ListVariableName", Value: ls.ListVariableName}, - {Key: "VariableName", Value: ls.VariableName}, - } - doc = append(doc, bson.E{Key: "LoopSource", Value: loopSource}) - case *microflows.WhileLoopCondition: - loopSource := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ls.ID))}, - {Key: "$Type", Value: "Microflows$WhileLoopCondition"}, - {Key: "WhileExpression", Value: ls.WhileExpression}, - } - doc = append(doc, bson.E{Key: "LoopSource", Value: loopSource}) - } - } - // Serialize nested ObjectCollection - if o.ObjectCollection != nil { - doc = append(doc, bson.E{Key: "ObjectCollection", Value: serializeMicroflowObjectCollection(o.ObjectCollection)}) - } - doc = append(doc, - bson.E{Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - bson.E{Key: "Size", Value: sizeToString(o.Size)}, - ) - return doc - - case *microflows.BreakEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$BreakEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.ContinueEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ContinueEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.Annotation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Annotation"}, - {Key: "Caption", Value: o.Caption}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *model.UnknownElement: - // Write-through: serialize RawDoc back as-is so unknown activities - // are not silently dropped when the MPR is saved. - if o.RawDoc == nil { - return nil - } - return o.RawDoc - - default: - return nil - } -} - -// serializePoint serializes a Point to BSON (nested object format). -func serializePoint(pt model.Point) bson.D { - return bson.D{ - {Key: "$Type", Value: "Common$Point"}, - {Key: "X", Value: int64(pt.X)}, - {Key: "Y", Value: int64(pt.Y)}, - } -} - -// serializeSize serializes a Size to BSON (nested object format). -func serializeSize(sz model.Size) bson.D { - return bson.D{ - {Key: "$Type", Value: "Common$Size"}, - {Key: "Width", Value: int64(sz.Width)}, - {Key: "Height", Value: int64(sz.Height)}, - } -} - -// pointToString converts a Point to string format "X;Y" for microflows. -func pointToString(pt model.Point) string { - return fmt.Sprintf("%d;%d", pt.X, pt.Y) -} - -// sizeToString converts a Size to string format "Width;Height" for microflows. -func sizeToString(sz model.Size) string { - return fmt.Sprintf("%d;%d", sz.Width, sz.Height) -} - -// serializeStringTemplate serializes a Text to BSON as a Microflows$StringTemplate. -// This is used for LOG message templates, not Texts$Text. -func serializeStringTemplate(text *model.Text, params []string) bson.D { - // Get the text from the first translation (usually en_US) - var textValue string - for _, value := range text.Translations { - textValue = value - break - } - - // Build parameters array - var paramsVal any - if len(params) > 0 { - paramArr := bson.A{int32(3)} // Array with items marker - for _, p := range params { - paramArr = append(paramArr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - }) - } - paramsVal = paramArr - } else { - paramsVal = bson.A{int32(2)} // Empty array marker - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Parameters", Value: paramsVal}, - {Key: "Text", Value: textValue}, - } -} - -// serializeTextTemplate serializes a Text as a Microflows$TextTemplate with nested Texts$Text. -// This is required for ValidationFeedbackAction.FeedbackTemplate. -func serializeTextTemplate(text *model.Text, params []string) bson.D { - // Build parameters array - var paramsVal any - if len(params) > 0 { - paramArr := bson.A{int32(3)} // Array with items marker - for _, p := range params { - paramArr = append(paramArr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - }) - } - paramsVal = paramArr - } else { - paramsVal = bson.A{int32(2)} // Empty array marker - } - - // Build the nested Texts$Text object - textDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - } - if len(text.Translations) > 0 { - var transArray bson.A - transArray = append(transArray, int32(3)) // items marker (3 = has items) - // Sort language keys for deterministic output - langs := make([]string, 0, len(text.Translations)) - for lang := range text.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, lang := range langs { - transArray = append(transArray, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: lang}, - {Key: "Text", Value: text.Translations[lang]}, - }) - } - textDoc = append(textDoc, bson.E{Key: "Items", Value: transArray}) - } else { - textDoc = append(textDoc, bson.E{Key: "Items", Value: bson.A{int32(2)}}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TextTemplate"}, - {Key: "Parameters", Value: paramsVal}, - {Key: "Text", Value: textDoc}, - } -} - -func (w *Writer) serializeNanoflow(nf *microflows.Nanoflow) ([]byte, error) { - // Determine project major version for version-specific serialization. - majorVersion := version.DefaultVersion().MajorVersion - if pv := w.reader.ProjectVersion(); pv != nil { - majorVersion = pv.MajorVersion - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(nf.ID))}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: allowedModuleRolesArray(nf.AllowedModuleRoles)}, - {Key: "Documentation", Value: nf.Documentation}, - {Key: "Excluded", Value: nf.Excluded}, - } - - // Add Flows array (SequenceFlows and AnnotationFlows at root level) - flows := bson.A{int32(3)} // Array type marker - if nf.ObjectCollection != nil { - for _, flow := range nf.ObjectCollection.Flows { - flows = append(flows, serializeSequenceFlow(flow, majorVersion)) - } - for _, af := range nf.ObjectCollection.AnnotationFlows { - flows = append(flows, serializeAnnotationFlow(af, majorVersion)) - } - } - doc = append(doc, bson.E{Key: "Flows", Value: flows}) - - doc = append(doc, bson.E{Key: "MarkAsUsed", Value: nf.MarkAsUsed}) - - // Add return type - if nf.ReturnType != nil { - doc = append(doc, bson.E{Key: "MicroflowReturnType", Value: serializeMicroflowDataType(nf.ReturnType)}) - } - - doc = append(doc, bson.E{Key: "Name", Value: nf.Name}) - - // Add object collection (without flows — they're in Flows array) - if nf.ObjectCollection != nil { - doc = append(doc, bson.E{Key: "ObjectCollection", Value: serializeMicroflowObjectCollectionWithoutFlows(nf.ObjectCollection, nf.Parameters, majorVersion)}) - } - - // Parameters stored inside ObjectCollection.Objects, not as a separate key. - - return marshalUnitIDFirst(doc) -} - -// stringOrDefault returns the value if non-empty, otherwise the default. -func stringOrDefault(value, defaultValue string) string { - if value == "" { - return defaultValue - } - return value -} diff --git a/sdk/mpr/writer_microflow_action_items_test.go b/sdk/mpr/writer_microflow_action_items_test.go deleted file mode 100644 index 8fce7db631..0000000000 --- a/sdk/mpr/writer_microflow_action_items_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeCreateObjectActionItemsUseStorageListMarker(t *testing.T) { - action := µflows.CreateObjectAction{ - BaseElement: model.BaseElement{ID: "create-1"}, - EntityQualifiedName: "SampleModule.Order", - OutputVariable: "Order", - Commit: microflows.CommitTypeNo, - InitialMembers: []*microflows.MemberChange{ - { - BaseElement: model.BaseElement{ID: "member-1"}, - AttributeQualifiedName: "SampleModule.Order.Name", - Type: microflows.MemberChangeTypeSet, - Value: "'Sample'", - }, - }, - } - - doc := serializeMicroflowAction(action) - - items, ok := getBSONField(doc, "Items").(bson.A) - if !ok { - t.Fatalf("Items is %T, want bson.A", getBSONField(doc, "Items")) - } - if len(items) != 2 { - t.Fatalf("Items length = %d, want marker plus one item", len(items)) - } - if marker, ok := items[0].(int32); !ok || marker != 2 { - t.Fatalf("Items marker = %#v, want int32(2)", items[0]) - } -} - -func TestSerializeChangeObjectActionItemsUseStorageListMarkerAndDefaultErrorHandling(t *testing.T) { - action := µflows.ChangeObjectAction{ - BaseElement: model.BaseElement{ID: "change-1"}, - ChangeVariable: "Order", - Commit: microflows.CommitTypeNo, - Changes: []*microflows.MemberChange{ - { - BaseElement: model.BaseElement{ID: "member-1"}, - AttributeQualifiedName: "SampleModule.Order.Status", - Type: microflows.MemberChangeTypeSet, - Value: "'Processed'", - }, - }, - } - - doc := serializeMicroflowAction(action) - - if got := getBSONField(doc, "ErrorHandlingType"); got != "Rollback" { - t.Fatalf("ErrorHandlingType = %#v, want Rollback", got) - } - items, ok := getBSONField(doc, "Items").(bson.A) - if !ok { - t.Fatalf("Items is %T, want bson.A", getBSONField(doc, "Items")) - } - if len(items) != 2 { - t.Fatalf("Items length = %d, want marker plus one item", len(items)) - } - if marker, ok := items[0].(int32); !ok || marker != 2 { - t.Fatalf("Items marker = %#v, want int32(2)", items[0]) - } -} - -func TestSerializeCommitActionAlwaysWritesDefaultErrorHandling(t *testing.T) { - action := µflows.CommitObjectsAction{ - BaseElement: model.BaseElement{ID: "commit-1"}, - CommitVariable: "Order", - } - - doc := serializeMicroflowAction(action) - - if got := getBSONField(doc, "ErrorHandlingType"); got != "Rollback" { - t.Fatalf("ErrorHandlingType = %#v, want Rollback", got) - } -} diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go deleted file mode 100644 index 2299c55693..0000000000 --- a/sdk/mpr/writer_microflow_actions.go +++ /dev/null @@ -1,1841 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeMicroflowAction serializes a microflow action to BSON. -// -// IMPORTANT: Mendix uses different "storage names" vs "qualified names" for many types. -// The $Type field in BSON must use the STORAGE NAME, not the qualified name from the -// TypeScript SDK or metamodel documentation. Examples: -// -// Qualified Name (SDK/docs) Storage Name (BSON $Type) -// ------------------------- ------------------------- -// CreateObjectAction CreateChangeAction -// ChangeObjectAction ChangeAction -// DeleteObjectAction DeleteAction -// CommitObjectsAction CommitAction -// RollbackObjectAction RollbackAction -// AggregateListAction AggregateAction -// ListOperationAction ListOperationsAction -// ShowPageAction ShowFormAction (Page was originally called Form) -// ClosePageAction CloseFormAction (Page was originally called Form) -// -// Using the wrong type name causes "TypeCacheUnknownTypeException" when opening in Studio Pro. -// When adding new action types, check existing MPR files or reflection data for the storage name. -func serializeMicroflowAction(action microflows.MicroflowAction) bson.D { - switch a := action.(type) { - case *microflows.CastAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CastAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - {Key: "VariableName", Value: a.OutputVariable}, - } - - case *microflows.CreateVariableAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CreateVariableAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "VariableName", Value: a.VariableName}, - {Key: "InitialValue", Value: a.InitialValue}, - } - if a.DataType != nil { - doc = append(doc, bson.E{Key: "VariableType", Value: serializeMicroflowDataType(a.DataType)}) - } - return doc - - case *microflows.ChangeVariableAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ChangeVariableAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ChangeVariableName", Value: a.VariableName}, - {Key: "Value", Value: a.Value}, - } - - case *microflows.CreateObjectAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CreateChangeAction"}, // storageName differs from qualifiedName - {Key: "Commit", Value: string(a.Commit)}, - } - // Entity is BY_NAME_REFERENCE - use qualified name string - if a.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: a.EntityQualifiedName}) - } - doc = append(doc, bson.E{Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}) - // Serialize Items (ChangeActionItem) for InitialMembers. Mendix stores - // this list with storage-list marker 2, not with the item count. - items := bson.A{int32(2)} - for _, change := range a.InitialMembers { - item := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(change.ID))}, - {Key: "$Type", Value: "Microflows$ChangeActionItem"}, - } - // Association or Attribute as BY_NAME_REFERENCE (mutually exclusive) - if change.AssociationQualifiedName != "" { - item = append(item, bson.E{Key: "Association", Value: change.AssociationQualifiedName}) - } else { - item = append(item, bson.E{Key: "Association", Value: ""}) // Empty for attributes - if change.AttributeQualifiedName != "" { - item = append(item, bson.E{Key: "Attribute", Value: change.AttributeQualifiedName}) - } - } - item = append(item, bson.E{Key: "Type", Value: string(change.Type)}) - item = append(item, bson.E{Key: "Value", Value: change.Value}) - items = append(items, item) - } - doc = append(doc, bson.E{Key: "Items", Value: items}) - // RefreshInClient is required - doc = append(doc, bson.E{Key: "RefreshInClient", Value: a.RefreshInClient}) - // outputVariableName has storageName "VariableName" - doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) - return doc - - case *microflows.ChangeObjectAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ChangeAction"}, // storageName differs from qualifiedName - {Key: "ChangeVariableName", Value: a.ChangeVariable}, - {Key: "Commit", Value: string(a.Commit)}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - } - // Serialize Items (ChangeActionItem). Mendix stores this list with - // storage-list marker 2, not with the item count. - items := bson.A{int32(2)} - for _, change := range a.Changes { - item := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(change.ID))}, - {Key: "$Type", Value: "Microflows$ChangeActionItem"}, - } - // Association or Attribute as BY_NAME_REFERENCE (mutually exclusive) - if change.AssociationQualifiedName != "" { - item = append(item, bson.E{Key: "Association", Value: change.AssociationQualifiedName}) - } else { - item = append(item, bson.E{Key: "Association", Value: ""}) // Empty for attributes - if change.AttributeQualifiedName != "" { - item = append(item, bson.E{Key: "Attribute", Value: change.AttributeQualifiedName}) - } - } - item = append(item, bson.E{Key: "Type", Value: string(change.Type)}) - item = append(item, bson.E{Key: "Value", Value: change.Value}) - items = append(items, item) - } - doc = append(doc, bson.E{Key: "Items", Value: items}) - doc = append(doc, bson.E{Key: "RefreshInClient", Value: a.RefreshInClient}) - return doc - - case *microflows.CommitObjectsAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CommitAction"}, - {Key: "CommitVariableName", Value: a.CommitVariable}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "RefreshInClient", Value: a.RefreshInClient}, - {Key: "WithEvents", Value: a.WithEvents}, - } - - case *microflows.DeleteObjectAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$DeleteAction"}, - {Key: "DeleteVariableName", Value: a.DeleteVariable}, - {Key: "RefreshInClient", Value: a.RefreshInClient}, - } - // Studio Pro writes no ErrorHandlingType on a delete, so the key is added - // only when the author asked for one — an un-annotated delete keeps the - // document it has always had. - if a.ErrorHandlingType != "" { - doc = append(doc, bson.E{Key: "ErrorHandlingType", Value: string(a.ErrorHandlingType)}) - } - return doc - - case *microflows.RollbackObjectAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RollbackAction"}, - {Key: "RollbackVariableName", Value: a.RollbackVariable}, - {Key: "RefreshInClient", Value: a.RefreshInClient}, - } - - case *microflows.LogMessageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$LogMessageAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "IncludeLatestStackTrace", Value: false}, - {Key: "Level", Value: string(a.LogLevel)}, - {Key: "Node", Value: a.LogNodeName}, // Already stored as expression (e.g., "'TEST'") - } - if a.MessageTemplate != nil { - doc = append(doc, bson.E{Key: "MessageTemplate", Value: serializeStringTemplate(a.MessageTemplate, a.TemplateParameters)}) - } - return doc - - case *microflows.CallExternalAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CallExternalAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ConsumedODataService", Value: a.ConsumedODataService}, - {Key: "Name", Value: a.Name}, - {Key: "VariableName", Value: a.ResultVariableName}, - } - // Issue: Mendix's CallExternalAction.Check raises CE7269 ("return type - // for remote action has changed") when the stored VariableDataType - // doesn't match the cached schema's return type. Always emit - // VariableDataType when we know the schema kind — the executor - // resolves it from the consumed service's cached $metadata. - if a.ResultDataType != "" { - doc = append(doc, bson.E{Key: "VariableDataType", Value: serializeExternalActionReturnType(a.ResultDataType, a.ResultEntity)}) - } - // Serialize parameter mappings - if len(a.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(3)) // Array marker (storageListType 3) - for _, pm := range a.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$ExternalActionParameterMapping"}, - {Key: "ParameterName", Value: pm.ParameterName}, - {Key: "Argument", Value: pm.Argument}, - {Key: "CanBeEmpty", Value: pm.CanBeEmpty}, - } - // generated/metamodel declares ParameterType without omitempty. - // Omitting it is CE7252 + a CE0117 per argument. - if pm.ParameterDataType != "" { - mapping = append(mapping, bson.E{ - Key: "ParameterType", - Value: serializeExternalActionReturnType(pm.ParameterDataType, pm.ParameterEntity), - }) - } - mappings = append(mappings, mapping) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - doc = append(doc, bson.E{Key: "ParameterMappings", Value: bson.A{int32(3)}}) - } - return doc - - case *microflows.MicroflowCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - } - // Serialize nested MicroflowCall structure - if a.MicroflowCall != nil { - mfCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.MicroflowCall.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowCall"}, - {Key: "Microflow", Value: a.MicroflowCall.Microflow}, - } - // Serialize parameter mappings within MicroflowCall - if len(a.MicroflowCall.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range a.MicroflowCall.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowCallParameterMapping"}, - {Key: "Argument", Value: pm.Argument}, - {Key: "Parameter", Value: pm.Parameter}, - } - mappings = append(mappings, mapping) - } - mfCall = append(mfCall, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - mfCall = append(mfCall, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) // Empty array with marker - } - mfCall = append(mfCall, bson.E{Key: "QueueSettings", Value: serializeQueueSettings(a.MicroflowCall.QueueSettings)}) - doc = append(doc, bson.E{Key: "MicroflowCall", Value: mfCall}) - } - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: a.ResultVariableName}, - bson.E{Key: "UseReturnVariable", Value: a.UseReturnVariable}, - ) - return doc - - case *microflows.NanoflowCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$NanoflowCallAction"}, - // Mendix metamodel defaults to "Rollback" for all call actions, including nanoflow calls. - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - } - if a.NanoflowCall != nil { - nfCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.NanoflowCall.ID))}, - {Key: "$Type", Value: "Microflows$NanoflowCall"}, - {Key: "Nanoflow", Value: a.NanoflowCall.Nanoflow}, - } - if len(a.NanoflowCall.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) - for _, pm := range a.NanoflowCall.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$NanoflowCallParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - {Key: "Argument", Value: pm.Argument}, - } - mappings = append(mappings, mapping) - } - nfCall = append(nfCall, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - nfCall = append(nfCall, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) - } - doc = append(doc, bson.E{Key: "NanoflowCall", Value: nfCall}) - } - return doc - - case *microflows.JavaActionCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$JavaActionCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "JavaAction", Value: a.JavaAction}, - {Key: "QueueSettings", Value: serializeQueueSettings(a.QueueSettings)}, - {Key: "ResultVariableName", Value: a.ResultVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - } - // Serialize parameter mappings - if len(a.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range a.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$JavaActionParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - } - // Serialize Value (CodeActionParameterValue) - if pm.Value != nil { - mapping = append(mapping, bson.E{Key: "Value", Value: serializeCodeActionParameterValue(pm.Value)}) - } - mappings = append(mappings, mapping) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - doc = append(doc, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) // Empty array with marker - } - return doc - - case *microflows.JavaScriptActionCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$JavaScriptActionCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "JavaScriptAction", Value: a.JavaScriptAction}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - } - // Serialize parameter mappings - if len(a.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range a.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$JavaScriptActionParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - } - // Serialize ParameterValue (CodeActionParameterValue) — JS uses "ParameterValue" key, not "Value" - if pm.Value != nil { - mapping = append(mapping, bson.E{Key: "ParameterValue", Value: serializeCodeActionParameterValue(pm.Value)}) - } - mappings = append(mappings, mapping) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - doc = append(doc, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) // Empty array with marker - } - return doc - - case *microflows.RetrieveAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RetrieveAction"}, - // Only an explicit ON ERROR clause moves this off the literal that has - // always been written here (ako/CapTrackV3 FINDINGS §11). - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ResultVariableName", Value: a.OutputVariable}, // storageName differs from qualifiedName - } - if a.Source != nil { - switch src := a.Source.(type) { - case *microflows.DatabaseRetrieveSource: - doc = append(doc, bson.E{Key: "RetrieveSource", Value: serializeDatabaseRetrieveSource(src)}) - case *microflows.AssociationRetrieveSource: - doc = append(doc, bson.E{Key: "RetrieveSource", Value: serializeAssociationRetrieveSource(src)}) - } - } - return doc - - case *microflows.ListOperationAction: - return serializeListOperationAction(a) - - case *microflows.AggregateListAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$AggregateAction"}, // storageName differs from qualifiedName - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - doc = append(doc, bson.E{Key: "AggregateFunction", Value: string(a.Function)}) - doc = append(doc, bson.E{Key: "AggregateVariableName", Value: a.InputVariable}) // storageName for inputListVariableName - if a.UseExpression { - doc = append(doc, bson.E{Key: "UseExpression", Value: true}) - doc = append(doc, bson.E{Key: "Expression", Value: a.Expression}) - } - // Attribute is BY_NAME_REFERENCE, and is written even when unused: every - // Studio Pro reference document carries it as "". Omitting it made a - // freshly described Studio Pro aggregate rewrite on its first execution - // for no semantic reason. - doc = append(doc, bson.E{Key: "Attribute", Value: a.AttributeQualifiedName}) - // Reduce's fold. Written for the functions a reference document shows - // Mendix storing them on, and otherwise only to carry back what the - // stored document already had (#1004). - if a.Function.WritesReduceProperties() || a.ReduceInitialValue != "" || a.ReduceReturnType != nil { - doc = append(doc, bson.E{Key: "ReduceInitialValueExpression", Value: a.ReduceInitialValue}) - doc = append(doc, bson.E{Key: "ReduceReturnDataType", Value: serializeMicroflowDataType(a.ReduceReturnType)}) - } - doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) // storageName for outputVariableName - return doc - - case *microflows.CreateListAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CreateListAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - // Entity is BY_NAME_REFERENCE - if a.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: a.EntityQualifiedName}) - } - doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) // storageName for outputVariableName - return doc - - case *microflows.ChangeListAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ChangeListAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - {Key: "ChangeVariableName", Value: a.ChangeVariable}, - {Key: "Type", Value: string(a.Type)}, - } - if a.Value != "" { - doc = append(doc, bson.E{Key: "Value", Value: a.Value}) - } - return doc - - case *microflows.ShowPageAction: - // ShowFormAction uses FormSettings with Form as BY_NAME_REFERENCE (not Page as BY_ID_REFERENCE) - // This is the modern format used by Mendix 10+ - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ShowFormAction"}, // storageName differs from qualifiedName - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - } - - // FormSettings contains Form (BY_NAME_REFERENCE) and ParameterMappings - formSettingsID := a.FormSettingsID - if formSettingsID == "" { - formSettingsID = model.ID(generateUUID()) - } - - // Build ParameterMappings inside FormSettings. Mendix storage lists use - // a marker as the first element; it is not the number of mappings. - paramMappings := bson.A{int32(2)} - for _, pm := range a.PageParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Forms$PageParameterMapping"}, // Forms$, not Microflows$ - {Key: "Argument", Value: pm.Argument}, - {Key: "Parameter", Value: pm.Parameter}, // BY_NAME_REFERENCE - {Key: "Variable", Value: emptyPageVariable()}, - } - paramMappings = append(paramMappings, mapping) - } - - // Build FormSettings - formSettings := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(formSettingsID))}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: a.PageName}, // BY_NAME_REFERENCE (page qualified name) - {Key: "ParameterMappings", Value: paramMappings}, - {Key: "TitleOverride", Value: titleOverrideValue(a.OverridePageTitle)}, - } - doc = append(doc, bson.E{Key: "FormSettings", Value: formSettings}) - doc = append(doc, bson.E{Key: "NumberOfPagesToClose", Value: ""}) - - return doc - - case *microflows.ClosePageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CloseFormAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - // The storage name is "NumberOfPages" (matches the metamodel/codec). The - // old "NumberOfPagesToClose" was tolerated by Mendix <= 11.6 but rejected - // by 11.12 (CE0117 "Error(s) in expression" — the real NumberOfPages field - // is then absent and defaults to an empty expression). - {Key: "NumberOfPages", Value: int32(a.NumberOfPages)}, - } - return doc - - case *microflows.ShowHomePageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ShowHomePageAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - return doc - - case *microflows.ShowMessageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ShowMessageAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "Type", Value: string(a.Type)}, - {Key: "Blocking", Value: a.Blocking}, - {Key: "Template", Value: serializeTextTemplate(a.Template, a.TemplateParameters)}, - } - return doc - - case *microflows.DownloadFileAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$DownloadFileAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "FileDocumentVariableName", Value: a.FileDocument}, - {Key: "ShowInBrowser", Value: a.ShowInBrowser}, - } - - case *microflows.ValidationFeedbackAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ValidationFeedbackAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ValidationVariableName", Value: a.ObjectVariable}, - } - // Always write both Attribute and Association fields — they are mutually - // exclusive but Mendix expects both present (empty string when not set). - // Follows the same pattern as ChangeObjectAction serialization. - if a.AssociationName != "" { - doc = append(doc, bson.E{Key: "Association", Value: a.AssociationName}) - doc = append(doc, bson.E{Key: "Attribute", Value: ""}) - } else { - doc = append(doc, bson.E{Key: "Association", Value: ""}) - doc = append(doc, bson.E{Key: "Attribute", Value: a.AttributeName}) - } - // Serialize FeedbackTemplate as Microflows$TextTemplate - if a.Template != nil { - doc = append(doc, bson.E{Key: "FeedbackTemplate", Value: serializeTextTemplate(a.Template, a.TemplateParameters)}) - } - return doc - - case *microflows.RestCallAction: - return serializeRestCallAction(a) - - case *microflows.WebServiceCallAction: - return serializeWebServiceCallAction(a) - - case *microflows.RestOperationCallAction: - return serializeRestOperationCallAction(a) - - case *microflows.ExecuteDatabaseQueryAction: - return serializeExecuteDatabaseQueryAction(a) - - case *microflows.ImportXmlAction: - return serializeImportXmlAction(a) - - case *microflows.ExportXmlAction: - return serializeExportXmlAction(a) - - case *microflows.TransformJsonAction: - return serializeTransformJsonAction(a) - - // Workflow actions - case *microflows.WorkflowCallAction: - return serializeWorkflowCallAction(a) - case *microflows.GetWorkflowDataAction: - return serializeGetWorkflowDataAction(a) - case *microflows.GetWorkflowsAction: - return serializeGetWorkflowsAction(a) - case *microflows.GetWorkflowActivityRecordsAction: - return serializeGetWorkflowActivityRecordsAction(a) - case *microflows.WorkflowOperationAction: - return serializeWorkflowOperationAction(a) - case *microflows.SetTaskOutcomeAction: - return serializeSetTaskOutcomeAction(a) - case *microflows.OpenUserTaskAction: - return serializeOpenUserTaskAction(a) - case *microflows.NotifyWorkflowAction: - return serializeNotifyWorkflowAction(a) - case *microflows.OpenWorkflowAction: - return serializeOpenWorkflowAction(a) - case *microflows.LockWorkflowAction: - return serializeLockWorkflowAction(a) - case *microflows.UnlockWorkflowAction: - return serializeUnlockWorkflowAction(a) - - default: - return nil - } -} - -func emptyPageVariable() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "PageParameter", Value: ""}, - {Key: "SnippetParameter", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } -} - -// titleOverrideValue renders FormSettings.TitleOverride: the page's own title is -// used unless the action overrides it, and "no override" is nil — NOT an empty -// Microflows$TextTemplate. -// -// An empty template is not the absence of an override, it *is* an override, to the -// empty string: every popup opened by such an action showed a blank caption with -// only the close button (mendixlabs/mxcli#812). The writers had been emitting one -// unconditionally on a mistaken "must be non-nil" reading of PR #338 / issue #295 — -// which was about Forms$PageVariable, a different field. This repo's own -// .claude/skills/debug-bson.md already documented the correct Forms$FormSettings -// shape as `TitleOverride: nil`. -// -// The same bug hid a second one: an override the author *did* ask for -// (`show page M.P with title = 'X'`) was dropped, because the empty template was -// written regardless of OverridePageTitle. Both cases now round-trip. -func titleOverrideValue(override *model.Text) any { - if override == nil { - return nil - } - return serializeTextTemplate(override, nil) -} - -// emptyTextTemplate returns an empty Microflows$TextTemplate embedded object. -// Retained for callers that genuinely need an initialized template; do NOT use it -// for TitleOverride — see titleOverrideValue. -func emptyTextTemplate() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TextTemplate"}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Text", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(2)}}, - }}, - } -} - -// serializeRestCallAction serializes a RestCallAction to BSON. -// Storage name is "Microflows$RestCallAction" (same as qualified name). -func serializeRestCallAction(a *microflows.RestCallAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RestCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ErrorResultHandlingType", Value: "HttpResponse"}, - } - - // Serialize HttpConfiguration - if a.HttpConfiguration != nil { - httpConfig := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.HttpConfiguration.ID))}, - {Key: "$Type", Value: "Microflows$HttpConfiguration"}, - {Key: "ClientCertificate", Value: ""}, - {Key: "CustomLocation", Value: ""}, - } - // Serialize CustomLocationTemplate as StringTemplate - if a.HttpConfiguration.LocationTemplate != "" { - customLocTemplate := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: a.HttpConfiguration.LocationTemplate}, - } - // Add parameters if present - each must be wrapped in TemplateParameter object - if len(a.HttpConfiguration.LocationParams) > 0 { - var params bson.A - params = append(params, int32(2)) // Array marker - for _, p := range a.HttpConfiguration.LocationParams { - templateParam := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - } - params = append(params, templateParam) - } - customLocTemplate = append(customLocTemplate, bson.E{Key: "Parameters", Value: params}) - } else { - customLocTemplate = append(customLocTemplate, bson.E{Key: "Parameters", Value: bson.A{int32(2)}}) - } - httpConfig = append(httpConfig, bson.E{Key: "CustomLocationTemplate", Value: customLocTemplate}) - } - httpConfig = append(httpConfig, - bson.E{Key: "HttpAuthenticationPassword", Value: a.HttpConfiguration.Password}, - bson.E{Key: "HttpAuthenticationUserName", Value: a.HttpConfiguration.Username}, - ) - // Serialize HttpHeaderEntries - if len(a.HttpConfiguration.CustomHeaders) > 0 { - var headers bson.A - headers = append(headers, int32(2)) // Array marker - for _, h := range a.HttpConfiguration.CustomHeaders { - header := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$HttpHeaderEntry"}, - {Key: "Key", Value: h.Name}, - {Key: "Value", Value: h.Value}, - } - headers = append(headers, header) - } - httpConfig = append(httpConfig, bson.E{Key: "HttpHeaderEntries", Value: headers}) - } else { - httpConfig = append(httpConfig, bson.E{Key: "HttpHeaderEntries", Value: bson.A{int32(2)}}) - } - httpConfig = append(httpConfig, - bson.E{Key: "HttpMethod", Value: string(a.HttpConfiguration.HttpMethod)}, - bson.E{Key: "OverrideLocation", Value: true}, - bson.E{Key: "UseHttpAuthentication", Value: a.HttpConfiguration.UseAuthentication}, - ) - doc = append(doc, bson.E{Key: "HttpConfiguration", Value: httpConfig}) - } - - doc = append(doc, bson.E{Key: "ProxyConfiguration", Value: nil}) - - // Serialize RequestHandling - if a.RequestHandling != nil { - doc = append(doc, bson.E{Key: "RequestHandling", Value: serializeRestRequestHandling(a.RequestHandling)}) - } - - // RequestHandlingType and RequestProxyType are at action level. The type must - // agree with the sub-element: it was hardcoded to "Custom", which is wrong for - // a binary body. Only the Binary case is derived — the others are unchanged, - // having no measured Studio Pro reference. - requestHandlingType := restRequestHandlingTypeOf(a.RequestHandling) - doc = append(doc, - bson.E{Key: "RequestHandlingType", Value: requestHandlingType}, - bson.E{Key: "RequestProxyType", Value: "DefaultProxy"}, - ) - - // Serialize ResultHandling - resultHandlingType := "String" // default - if a.ResultHandling != nil { - doc = append(doc, bson.E{Key: "ResultHandling", Value: serializeRestResultHandling(a.ResultHandling, a.OutputVariable)}) - switch a.ResultHandling.(type) { - case *microflows.ResultHandlingString: - resultHandlingType = "String" - case *microflows.ResultHandlingHttpResponse: - resultHandlingType = "HttpResponse" - case *microflows.ResultHandlingMapping: - resultHandlingType = "Mapping" - case *microflows.ResultHandlingFileDocument: - resultHandlingType = "FileDocument" - case *microflows.ResultHandlingNone: - resultHandlingType = "None" - } - } - doc = append(doc, bson.E{Key: "ResultHandlingType", Value: resultHandlingType}) - - // Timeout - if a.TimeoutExpression != "" { - doc = append(doc, - bson.E{Key: "TimeOutExpression", Value: a.TimeoutExpression}, - bson.E{Key: "UseRequestTimeOut", Value: true}, - ) - } else { - doc = append(doc, - bson.E{Key: "TimeOutExpression", Value: "300"}, - bson.E{Key: "UseRequestTimeOut", Value: true}, - ) - } - - return doc -} - -func serializeWebServiceCallAction(a *microflows.WebServiceCallAction) bson.D { - if len(a.RawBSON) > 0 { - var raw bson.D - if err := bson.Unmarshal(a.RawBSON, &raw); err == nil { - return raw - } - } - - // ServiceName is the WSDL , which Mendix resolves the - // operation within — NOT the local part of the qualified document name. The - // executor reads the real one off the imported service document. Deriving it - // (the fallback here, and what this writer always did) is right only when the - // document happens to be named after the service; otherwise the call fails - // with CE0386 "Operation … does not exist in consumed web service …". - serviceName := a.ServiceName - if serviceName == "" { - serviceName = string(a.ServiceID) - if idx := strings.LastIndex(serviceName, "."); idx >= 0 { - serviceName = serviceName[idx+1:] - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CallWebServiceAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "HttpConfiguration", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$HttpConfiguration"}, - {Key: "ClientCertificate", Value: ""}, - {Key: "CustomLocation", Value: ""}, - {Key: "CustomLocationTemplate", Value: nil}, - {Key: "HttpAuthenticationPassword", Value: ""}, - {Key: "HttpAuthenticationUserName", Value: ""}, - {Key: "HttpHeaderEntries", Value: bson.A{int32(3)}}, - {Key: "HttpMethod", Value: "Post"}, - {Key: "OverrideLocation", Value: false}, - {Key: "UseHttpAuthentication", Value: false}, - }}, - // ImportedService is a BY_NAME_REFERENCE qualified name string, not a binary UUID. - {Key: "ImportedService", Value: string(a.ServiceID)}, - {Key: "IsValidationRequired", Value: false}, - } - - // NewResultHandling uses Microflows$ResultHandling (same type as REST result handling). - bind := a.OutputVariable != "" - resultHandling := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: bind}, - } - if a.ReceiveMappingID != "" { - // ReturnValueMapping is a BY_NAME_REFERENCE string, not a binary UUID. - resultHandling = append(resultHandling, bson.E{Key: "ImportMappingCall", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ImportMappingCall"}, - {Key: "Commit", Value: "YesWithoutEvents"}, - // Xml, not Json: a SOAP response IS XML. Studio Pro writes "Xml" - // here in both reference calls that carry an import mapping - // (ako/TestApp, Clients.GetOrders and GetCustomerOrders, 11.14.0). - // This is the receive side only — the REST and import-from-mapping - // ImportMappingCalls elsewhere in this file are unrelated. - {Key: "ContentType", Value: "Xml"}, - {Key: "ForceSingleOccurrence", Value: false}, - {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: true}, - }}, - {Key: "ReturnValueMapping", Value: string(a.ReceiveMappingID)}, - }}) - } else { - resultHandling = append(resultHandling, bson.E{Key: "ImportMappingCall", Value: nil}) - } - // VariableType is the type the call RETURNS — the entity the receive mapping - // produces. VoidType says it returns nothing, which contradicts the mapping - // (CE0243) and makes assigning the result an error too (CE0366). It stays the - // fallback for a mapping mxcli could not resolve. - variableType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - if a.ResultEntity != "" { - variableType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: a.ResultEntity}, - } - } - resultHandling = append(resultHandling, - bson.E{Key: "ResultVariableName", Value: a.OutputVariable}, - bson.E{Key: "VariableType", Value: variableType}, - ) - doc = append(doc, bson.E{Key: "NewResultHandling", Value: resultHandling}) - - doc = append(doc, bson.E{Key: "OperationName", Value: a.OperationName}) - doc = append(doc, bson.E{Key: "ProxyConfiguration", Value: nil}) - - // RequestBodyHandling holds EITHER the operation's arguments or an export - // mapping — one polymorphic child, never both, which is why the executor - // refuses a statement asking for each (MDL-SOAP01). - // - // This used to be an unconditional empty SimpleRequestHandling. Both halves - // of that were wrong against ako/TestApp: an operation taking parameters - // needs them (CE0178 "Body parameter mapping needs to be refreshed"), and a - // send mapping is a Microflows$MappingRequestHandling — NOT the - // Mendix$AdvancedRequestHandling this comment used to name, a type that - // appears in none of the three reference documents. Writing Simple regardless - // dropped the mapping silently and gave CE0369 "Cannot use simple request - // body, as the operation's body is complex". - doc = append(doc, bson.E{Key: "RequestBodyHandling", Value: webServiceRequestBody(a)}) - - // RequestHeaderHandling is always SimpleRequestHandling: MDL cannot author - // SOAP headers, and all three reference calls carry the bare form. - doc = append(doc, bson.E{Key: "RequestHeaderHandling", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$SimpleRequestHandling"}, - {Key: "NullValueOption", Value: "LeaveOutElement"}, - {Key: "ParameterMappings", Value: bson.A{int32(2)}}, - }}) - - doc = append(doc, bson.E{Key: "RequestProxyType", Value: "DefaultProxy"}) - doc = append(doc, bson.E{Key: "ServiceName", Value: serviceName}) - doc = append(doc, - bson.E{Key: "TimeOutExpression", Value: stringOrDefault(a.TimeoutExpression, "300")}, - bson.E{Key: "UseRequestTimeOut", Value: true}, - ) - return doc -} - -// webServiceRequestBody builds a SOAP call's RequestBodyHandling — the arguments -// form or the export-mapping form. Mirrors -// modelsdkbackend.webServiceRequestBodyToGen key for key. -func webServiceRequestBody(a *microflows.WebServiceCallAction) bson.D { - if a.SendMappingID != "" { - // MappingId / MappingVariableName are the STORAGE names. modelsdk/gen - // binds the same two properties as Mapping and - // MappingArgumentVariableName (its key audit lists both), and a document - // written under those is one mxbuild tolerates and Studio Pro cannot - // open — so the legacy writer, which names keys directly, is the easier - // of the two engines to get right here. - contentType := a.SendMappingContentType - if contentType == "" { - // What Studio Pro wrote on the one reference document - // (ako/TestApp Clients.SaveOrder) — surprising on an XML protocol, - // hence preserved on a rewrite rather than derived. - contentType = "Json" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$MappingRequestHandling"}, - {Key: "ContentType", Value: contentType}, - {Key: "MappingId", Value: string(a.SendMappingID)}, - {Key: "MappingVariableName", Value: a.SendMappingVariable}, - } - } - - // Marker 2, measured on all three reference calls. - mappings := bson.A{int32(2)} - for _, arg := range a.Arguments { - mappings = append(mappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$WebServiceOperationSimpleParameterMapping"}, - {Key: "Argument", Value: arg.Expression}, - {Key: "IsChecked", Value: arg.Checked}, - // "" in both reference mappings; what fills it is unmeasured, so it - // is written empty rather than guessed at from the argument's name. - {Key: "ParameterName", Value: ""}, - {Key: "ParameterPath", Value: arg.Path}, - }) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$SimpleRequestHandling"}, - {Key: "NullValueOption", Value: "LeaveOutElement"}, - {Key: "ParameterMappings", Value: mappings}, - } -} - -// serializeRestOperationCallAction serializes a Microflows$RestOperationCallAction to BSON. -// Note: RestOperationCallAction does not support custom ErrorHandlingType (CE6035). -func serializeRestOperationCallAction(a *microflows.RestOperationCallAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RestOperationCallAction"}, - {Key: "Operation", Value: a.Operation}, - } - - // OutputVariable - if a.OutputVariable != nil { - ov := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.OutputVariable.ID))}, - {Key: "$Type", Value: "Microflows$OutputVariable"}, - {Key: "VariableName", Value: a.OutputVariable.VariableName}, - } - doc = append(doc, bson.E{Key: "OutputVariable", Value: ov}) - } else { - doc = append(doc, bson.E{Key: "OutputVariable", Value: nil}) - } - - // BodyVariable - if a.BodyVariable != nil { - bv := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.BodyVariable.ID))}, - {Key: "$Type", Value: "Microflows$BodyVariable"}, - {Key: "VariableName", Value: a.BodyVariable.VariableName}, - } - doc = append(doc, bson.E{Key: "BodyVariable", Value: bv}) - } else { - doc = append(doc, bson.E{Key: "BodyVariable", Value: nil}) - } - - doc = append(doc, bson.E{Key: "BaseUrlParameterMapping", Value: nil}) - - // ParameterMappings (path params) - paramMappings := bson.A{int32(3)} - for _, pm := range a.ParameterMappings { - paramMappings = append(paramMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - // STORAGE-NAME OVERRIDE — see the note in - // mdl/backend/modelsdk/microflow_rest_write.go. There is no - // Microflows$ParameterMapping; writing it makes the project - // impossible to OPEN, not merely invalid. - {Key: "$Type", Value: "Microflows$RestOperationParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - {Key: "Value", Value: pm.Value}, - }) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: paramMappings}) - - // QueryParameterMappings - queryMappings := bson.A{int32(3)} - for _, qm := range a.QueryParameterMappings { - queryMappings = append(queryMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$QueryParameterMapping"}, - {Key: "QueryParameter", Value: qm.Parameter}, - {Key: "Value", Value: qm.Value}, - {Key: "Included", Value: qm.Included}, - }) - } - doc = append(doc, bson.E{Key: "QueryParameterMappings", Value: queryMappings}) - - return doc -} - -// serializeRestRequestHandling serializes RequestHandling to BSON. -// restRequestHandlingTypeOf is the action-level discriminator, which must agree -// with the RequestHandling sub-element. Measured against Studio Pro microflows -// (ako/TestApp, 11.13.0); Simple follows the same name rule but has no measured -// reference. Mirrors requestHandlingTypeOf in the modelsdk engine. -func restRequestHandlingTypeOf(rh microflows.RequestHandling) string { - switch rh.(type) { - case *microflows.MappingRequestHandling: - return "Mapping" - case *microflows.BinaryRequestHandling: - return "Binary" - case *microflows.FormDataRequestHandling: - return "FormData" - case *microflows.SimpleRequestHandling: - return "Simple" - default: - return "Custom" - } -} - -func serializeRestRequestHandling(rh microflows.RequestHandling) bson.D { - switch h := rh.(type) { - case *microflows.BinaryRequestHandling: - // Binary request body. Studio Pro stores the expression yielding the - // bytes — a FileDocument's Contents member — and pairs it with an - // action-level RequestHandlingType of "Binary". - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$BinaryRequestHandling"}, - {Key: "Expression", Value: h.Expression}, - } - case *microflows.CustomRequestHandling: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$CustomRequestHandling"}, - } - // Serialize Template as StringTemplate - template := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: h.Template}, - } - // Add parameters - each must be wrapped in TemplateParameter object - if len(h.TemplateParams) > 0 { - var params bson.A - params = append(params, int32(2)) // Array marker - for _, p := range h.TemplateParams { - templateParam := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - } - params = append(params, templateParam) - } - template = append(template, bson.E{Key: "Parameters", Value: params}) - } else { - template = append(template, bson.E{Key: "Parameters", Value: bson.A{int32(2)}}) - } - doc = append(doc, bson.E{Key: "Template", Value: template}) - return doc - - case *microflows.MappingRequestHandling: - // generated/metamodel gives this type exactly three properties: - // contentType (Json|Xml), mappingId, mappingVariableName. - // "ParameterVariable" is not one of them — an unknown property is the - // shape mxbuild tolerates and Studio Pro refuses to open — and an empty - // ContentType is not a member of the enum. - contentType := h.ContentType - if contentType == "" { - contentType = "Json" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$MappingRequestHandling"}, - {Key: "MappingId", Value: idToBsonBinary(string(h.MappingID))}, - {Key: "ContentType", Value: contentType}, - {Key: "MappingVariableName", Value: h.ParameterVariable}, - } - - case *microflows.SimpleRequestHandling: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$SimpleRequestHandling"}, - } - - default: - // Default to empty custom request handling - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$CustomRequestHandling"}, - {Key: "RequestHandlingType", Value: "Custom"}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: ""}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - }}, - {Key: "RequestProxyType", Value: "DefaultProxy"}, - } - } -} - -// serializeRestResultHandling serializes ResultHandling to BSON. -// Note: ResultHandlingType is serialized at the action level, not here. -func serializeRestResultHandling(rh microflows.ResultHandling, outputVar string) bson.D { - switch h := rh.(type) { - case *microflows.ResultHandlingString: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - } - if outputVar != "" { - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: outputVar}, - bson.E{Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - ) - } else { - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: ""}, - bson.E{Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - ) - } - return doc - - case *microflows.ResultHandlingMapping: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {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. - 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: forceSingleOccurrence}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "ParameterVariableName", Value: ""}, - {Key: "Range", Value: importMappingRange(h)}, - {Key: "ReturnValueMapping", Value: string(h.MappingID)}, - } - doc = append(doc, bson.E{Key: "ImportMappingCall", Value: importCall}) - // VariableType: ObjectType for single-object mappings, ListType for multi-object. - varTypeID := idToBsonBinary(GenerateID()) - var varType bson.D - if h.SingleObject { - varType = bson.D{ - {Key: "$ID", Value: varTypeID}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - } - } else { - varType = bson.D{ - {Key: "$ID", Value: varTypeID}, - {Key: "$Type", Value: "DataTypes$ListType"}, - } - } - if h.ResultEntityID != "" { - varType = append(varType, bson.E{Key: "Entity", Value: string(h.ResultEntityID)}) - } - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: h.ResultVariable}, - bson.E{Key: "VariableType", Value: varType}, - ) - return doc - - case *microflows.ResultHandlingHttpResponse: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: outputVar}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: "System.HttpResponse"}, - }}, - } - - case *microflows.ResultHandlingFileDocument: - // Same shape as HttpResponse, but the entity is authored rather than - // fixed: it is always a System.FileDocument specialization (CE0362 - // rejects the base). Issue #922. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: outputVar}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: h.EntityRef}, - }}, - } - - case *microflows.ResultHandlingNone: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: false}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: ""}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - }}, - } - - default: - // Default to string result handling - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: outputVar}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - } - } -} - -// serializeListOperationAction serializes a ListOperationAction to BSON. -func serializeListOperationAction(a *microflows.ListOperationAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ListOperationsAction"}, // storageName differs from qualifiedName - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - - // Serialize the operation - storage name is "NewOperation" - // - // The nil guard is not defensive tidiness: serializeListOperation returns - // nil for an operation it has no case for, and appending that writes an - // EMPTY sub-document, which Mendix's loader refuses outright — "Expected - // '$ID' as the first property of a storage object, but got 'NewOperation'" - // — so the project cannot be opened at all. Omitting the key instead leaves - // an activity with no action, which mxbuild reports as CE0008 "No action - // defined." naming the activity. A missing action is recoverable; an - // unloadable file is not. (issue #966, where the Range operation was the - // case that fell through) - if op := serializeListOperation(a.Operation); op != nil { - doc = append(doc, bson.E{Key: "NewOperation", Value: op}) - } - doc = append(doc, bson.E{Key: "ResultVariableName", Value: a.OutputVariable}) // storageName differs - return doc -} - -// serializeListOperation serializes a ListOperation to BSON. -// Storage names differ from qualified names in Mendix metamodel. -func serializeListOperation(op microflows.ListOperation) bson.D { - switch o := op.(type) { - case *microflows.HeadOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Head"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.TailOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Tail"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.FindOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$FindByExpression"}, // storageName differs - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.FilterOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$FilterByExpression"}, // storageName differs - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.FindByAttributeOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Find"}, - {Key: "Association", Value: o.Association}, - {Key: "Attribute", Value: o.Attribute}, - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, - } - case *microflows.FilterByAttributeOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Filter"}, - {Key: "Association", Value: o.Association}, - {Key: "Attribute", Value: o.Attribute}, - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, - } - case *microflows.SortOperation: - // Build sorting items - sortings := bson.A{int32(3)} // Array with items marker - for _, item := range o.Sorting { - sortItem := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(item.ID))}, - {Key: "$Type", Value: "Microflows$RetrieveSorting"}, // storageName for SortItem - {Key: "SortOrder", Value: string(item.Direction)}, - } - // AttributeRef is a nested DomainModels$AttributeRef object - if item.AttributeQualifiedName != "" { - attrRef := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: item.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string - } - if len(item.EntityRefSteps) > 0 { - attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(item.EntityRefSteps)}) - } - sortItem = append(sortItem, bson.E{Key: "AttributeRef", Value: attrRef}) - } - sortings = append(sortings, sortItem) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Sort"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - {Key: "Sortings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$SortingsList"}, // storageName for SortItemList - {Key: "Sortings", Value: sortings}, - }}, - } - case *microflows.UnionOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Union"}, - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.IntersectOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Intersect"}, - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.SubtractOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Subtract"}, - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.ContainsOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Contains"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ObjectVariable}, // storageName differs - } - case *microflows.EqualsOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Equals"}, // storageName for ListEquals - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.ListRangeOperation: - // `range($List, $offset, $amount)`. This case was absent, which made the - // Range the one list operation the legacy engine could PARSE (see - // parseListOperation) but not write — and the fall-through to nil is - // what produced an unloadable project, not merely a lost range. (#966) - // - // The bounds are nested in a Microflows$CustomRange child, the shape the - // parser beside this file already reads. Emitted only when there is a - // bound to carry: Mendix requires at least one (CE6520), so an empty - // child would be a well-formed way to store an invalid range. - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ListRange"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - if o.LimitExpression != "" || o.OffsetExpression != "" { - doc = append(doc, bson.E{Key: "CustomRange", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$CustomRange"}, - {Key: "LimitExpression", Value: o.LimitExpression}, - {Key: "OffsetExpression", Value: o.OffsetExpression}, - }}) - } - return doc - default: - return nil - } -} - -// serializeDatabaseRetrieveSource serializes a DatabaseRetrieveSource to BSON. -func serializeDatabaseRetrieveSource(source *microflows.DatabaseRetrieveSource) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(source.ID))}, - {Key: "$Type", Value: "Microflows$DatabaseRetrieveSource"}, - } - - // Entity is BY_NAME_REFERENCE - use qualified name string - if source.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: source.EntityQualifiedName}) - } - - // NewSortings (storageName) wraps a Microflows$SortingsList with Sortings array - sortItems := bson.A{int32(2)} // storageListType: 2 array marker - for _, sortItem := range source.Sorting { - sortItems = append(sortItems, serializeSortItem(sortItem)) - } - sortingsList := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$SortingsList"}, - {Key: "Sortings", Value: sortItems}, - } - doc = append(doc, bson.E{Key: "NewSortings", Value: sortingsList}) - - // Range for limiting results - always include for Studio Pro compatibility - if source.Range != nil { - doc = append(doc, bson.E{Key: "Range", Value: serializeRange(source.Range)}) - } else { - // Create default Range (retrieve all objects) - defaultRange := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: false}, - } - doc = append(doc, bson.E{Key: "Range", Value: defaultRange}) - } - - // XPath constraint - note: BSON field name uses lowercase 'p' (XpathConstraint) - if source.XPathConstraint != "" { - doc = append(doc, bson.E{Key: "XpathConstraint", Value: source.XPathConstraint}) - } - - return doc -} - -// serializeAssociationRetrieveSource serializes an AssociationRetrieveSource to BSON. -func serializeAssociationRetrieveSource(source *microflows.AssociationRetrieveSource) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(source.ID))}, - {Key: "$Type", Value: "Microflows$AssociationRetrieveSource"}, - } - if source.StartVariable != "" { - doc = append(doc, bson.E{Key: "StartVariableName", Value: source.StartVariable}) - } - // AssociationId is BY_NAME_REFERENCE - use qualified name string - if source.AssociationQualifiedName != "" { - doc = append(doc, bson.E{Key: "AssociationId", Value: source.AssociationQualifiedName}) - } - return doc -} - -// serializeRange serializes a Range to BSON. -// ConstantRange only has SingleObject; CustomRange has LimitExpression/OffsetExpression. -func serializeRange(r *microflows.Range) bson.D { - if r.RangeType == microflows.RangeTypeCustom { - // CustomRange: expression-based LIMIT/OFFSET - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(r.ID))}, - {Key: "$Type", Value: "Microflows$CustomRange"}, - } - if r.Limit != "" { - doc = append(doc, bson.E{Key: "LimitExpression", Value: r.Limit}) - } - if r.Offset != "" { - doc = append(doc, bson.E{Key: "OffsetExpression", Value: r.Offset}) - } - return doc - } - - // ConstantRange: SingleObject=true (LIMIT 1) or retrieve all - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(r.ID))}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: r.RangeType == microflows.RangeTypeFirst}, - } -} - -// serializeSortItem serializes a SortItem to BSON. -// Storage name is Microflows$RetrieveSorting (qualified: Microflows$SortItem). -func serializeSortItem(s *microflows.SortItem) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(s.ID))}, - {Key: "$Type", Value: "Microflows$RetrieveSorting"}, - } - - // AttributeRef is a DomainModels$AttributeRef object containing Attribute as BY_NAME_REFERENCE - if s.AttributeQualifiedName != "" { - attrRef := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: s.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string - } - if len(s.EntityRefSteps) > 0 { - attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(s.EntityRefSteps)}) - } - doc = append(doc, bson.E{Key: "AttributeRef", Value: attrRef}) - } else if s.AttributeID != "" { - // Legacy fallback: binary ID reference - doc = append(doc, bson.E{Key: "AttributeRef", Value: idToBsonBinary(string(s.AttributeID))}) - } - - doc = append(doc, bson.E{Key: "SortOrder", Value: string(s.Direction)}) - return doc -} - -func serializeIndirectEntityRef(steps []microflows.EntityRefStep) bson.D { - items := bson.A{int32(2)} - for _, step := range steps { - items = append(items, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$EntityRefStep"}, - {Key: "Association", Value: step.Association}, - {Key: "DestinationEntity", Value: step.DestinationEntity}, - }) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, - {Key: "Steps", Value: items}, - } -} - -// serializeCodeActionParameterValue serializes a CodeActionParameterValue to BSON. -func serializeCodeActionParameterValue(v microflows.CodeActionParameterValue) bson.D { - switch value := v.(type) { - case *microflows.StringTemplateParameterValue: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$StringTemplateParameterValue"}, - } - if value.TypedTemplate != nil { - tt := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.TypedTemplate.ID))}, - {Key: "$Type", Value: "Microflows$TypedTemplate"}, - {Key: "Arguments", Value: bson.A{int32(2)}}, // Empty array marker - {Key: "Text", Value: value.TypedTemplate.Text}, - } - doc = append(doc, bson.E{Key: "TypedTemplate", Value: tt}) - } - return doc - case *microflows.ExpressionBasedCodeActionParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$ExpressionBasedCodeActionParameterValue"}, - {Key: "Expression", Value: value.Expression}, - } - case *microflows.BasicCodeActionParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$BasicCodeActionParameterValue"}, - {Key: "Argument", Value: value.Argument}, - } - case *microflows.MicroflowParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowParameterValue"}, - {Key: "Microflow", Value: value.Microflow}, - } - case *microflows.EntityTypeCodeActionParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$EntityTypeCodeActionParameterValue"}, - {Key: "Entity", Value: value.Entity}, - } - } - return nil -} - -func serializeExecuteDatabaseQueryAction(a *microflows.ExecuteDatabaseQueryAction) bson.D { - // ConnectionParameterMappings - connMappings := bson.A{int32(2)} - for _, cm := range a.ConnectionParameterMappings { - cmDoc := bson.D{ - {Key: "$Type", Value: "DatabaseConnector$ConnectionParameterMapping"}, - {Key: "ParameterName", Value: cm.ParameterName}, - {Key: "Value", Value: cm.Value}, - } - if cm.ID != "" { - cmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(string(cm.ID))}}, cmDoc...) - } else { - cmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(generateUUID())}}, cmDoc...) - } - connMappings = append(connMappings, cmDoc) - } - - // ParameterMappings - paramMappings := bson.A{int32(2)} - for _, pm := range a.ParameterMappings { - pmDoc := bson.D{ - {Key: "$Type", Value: "DatabaseConnector$QueryParameterMapping"}, - {Key: "ParameterName", Value: pm.ParameterName}, - {Key: "Value", Value: pm.Value}, - } - if pm.ID != "" { - pmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(string(pm.ID))}}, pmDoc...) - } else { - pmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(generateUUID())}}, pmDoc...) - } - paramMappings = append(paramMappings, pmDoc) - } - - // Fields in alphabetical order (matches Studio Pro BSON layout) - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "DatabaseConnector$ExecuteDatabaseQueryAction"}, - {Key: "ConnectionParameterMappings", Value: connMappings}, - {Key: "DynamicQuery", Value: a.DynamicQuery}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "ParameterMappings", Value: paramMappings}, - {Key: "Query", Value: a.Query}, - } - - return doc -} - -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: forceSingleOccurrence}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "ParameterVariableName", Value: ""}, - {Key: "Range", Value: importMappingRange(a.ResultHandling)}, - {Key: "ReturnValueMapping", Value: string(a.ResultHandling.MappingID)}, - } - - // Build VariableType - var varType bson.D - if a.ResultHandling.SingleObject { - varType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: string(a.ResultHandling.ResultEntityID)}, - } - } else { - varType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ListType"}, - {Key: "Entity", Value: string(a.ResultHandling.ResultEntityID)}, - } - } - - bind := a.ResultHandling.ResultVariable != "" - - resultHandling := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ResultHandling.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: bind}, - {Key: "ImportMappingCall", Value: importCall}, - {Key: "ResultVariableName", Value: a.ResultHandling.ResultVariable}, - {Key: "VariableType", Value: varType}, - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ImportXmlAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "IsValidationRequired", Value: a.IsValidationRequired}, - {Key: "ResultHandling", Value: resultHandling}, - {Key: "XmlDocumentVariableName", Value: a.XmlDocumentVariable}, - } -} - -func serializeTransformJsonAction(a *microflows.TransformJsonAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$TransformJsonAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "InputVariableName", Value: a.InputVariableName}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "Transformation", Value: a.Transformation}, - } -} - -func serializeExportXmlAction(a *microflows.ExportXmlAction) bson.D { - // OutputMethod: ExportXmlAction$StringExport - outputMethod := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "ExportXmlAction$StringExport"}, - {Key: "OutputVariableName", Value: a.OutputVariable}, - } - - // ResultHandling: MappingRequestHandling - mappingID := "" - paramVar := "" - if a.RequestHandling != nil { - mappingID = string(a.RequestHandling.MappingID) - paramVar = a.RequestHandling.ParameterVariable - } - - resultHandling := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$MappingRequestHandling"}, - {Key: "ContentType", Value: "Json"}, - {Key: "MappingId", Value: mappingID}, - {Key: "MappingVariableName", Value: paramVar}, - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ExportXmlAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "IsValidationRequired", Value: a.IsValidationRequired}, - {Key: "OutputMethod", Value: outputMethod}, - {Key: "ResultHandling", Value: resultHandling}, - } -} - -// serializeExternalActionReturnType maps a Mendix kind name (resolved from a -// consumed OData service's cached $metadata) to a DataTypes$* BSON sub-doc -// suitable for ODataPublish$CallExternalAction.VariableDataType. Mendix's -// CE7269 fires when this field's $Type doesn't match what the cached schema -// declares for the action's return. -// An Object or List return also carries the entity it is typed on: both -// DataTypes$ObjectType and DataTypes$ListType store an Entity by qualified -// name, and one without it is as unaligned as no type at all. -func serializeExternalActionReturnType(kind, entity string) bson.D { - typeID := idToBsonBinary(generateUUID()) - bsonType := "DataTypes$VoidType" - switch kind { - case "Boolean": - bsonType = "DataTypes$BooleanType" - case "String": - bsonType = "DataTypes$StringType" - case "Integer", "Long": - bsonType = "DataTypes$IntegerType" - case "Decimal", "Float": - bsonType = "DataTypes$DecimalType" - case "DateTime": - bsonType = "DataTypes$DateTimeType" - case "Binary": - bsonType = "DataTypes$BinaryType" - case "Object": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: entity}, - } - case "List": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ListType"}, - {Key: "Entity", Value: entity}, - } - case "Void", "": - bsonType = "DataTypes$VoidType" - } - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: bsonType}, - } -} - -// importMappingRange builds the Range child of a Microflows$ImportMappingCall. -// -// Mendix has two variants and mxcli only ever wrote the first, so the "Custom" -// setting — a bounded list — was not merely undescribed but unrepresentable: -// -// Microflows$ConstantRange{SingleObject} All (false) / First (true) -// Microflows$CustomRange{LimitExpression, OffsetExpression} Custom -// -// A limit or an offset selects CustomRange; SingleObject has no meaning there, -// because a bounded range is always a list. (issue #881) -func importMappingRange(h *microflows.ResultHandlingMapping) bson.D { - if h.LimitExpression != "" || h.OffsetExpression != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$CustomRange"}, - {Key: "LimitExpression", Value: h.LimitExpression}, - {Key: "OffsetExpression", Value: h.OffsetExpression}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: microflows.RangeSingleObjectOf(h)}, - } -} - -// serializeQueueSettings renders the Queues$QueueSettings child that binds a call -// activity to a task queue, or nil for an unqueued call (which is what Studio Pro -// stores). Retry has no MDL surface and is always null here; a stored retry is -// never overwritten, because checkNoQueuedCalls refuses the rewrite instead. -func serializeQueueSettings(qs *microflows.QueueSettings) any { - if qs == nil || qs.Queue == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(qs.ID))}, - {Key: "$Type", Value: "Queues$QueueSettings"}, - {Key: "Queue", Value: qs.Queue}, - {Key: "Retry", Value: nil}, - } -} diff --git a/sdk/mpr/writer_microflow_flags_test.go b/sdk/mpr/writer_microflow_flags_test.go deleted file mode 100644 index 8d449b18cc..0000000000 --- a/sdk/mpr/writer_microflow_flags_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -// TestMicroflowApplyEntityAccessRoundTrip is the legacy half of the security -// fix, and exists to keep the two engines from drifting — the modelsdk twin is -// TestMicroflowRoundTrip_ApplyEntityAccess. -// -// This writer wrote `{Key: "ApplyEntityAccess", Value: false}` unconditionally, -// so a microflow that ran under the user's entity access rules came back running -// with full access. Nothing reported it: the model is valid either way. -func TestMicroflowApplyEntityAccessRoundTrip(t *testing.T) { - for _, want := range []bool{true, false} { - mf := µflows.Microflow{Name: "ACT_Secured", ApplyEntityAccess: want} - mf.ID = model.ID("mf-1") - - w := testWriter() - raw, err := w.serializeMicroflow(mf) - if err != nil { - t.Fatalf("serialize: %v", err) - } - var doc map[string]any - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got, ok := doc["ApplyEntityAccess"].(bool); !ok || got != want { - t.Errorf("written ApplyEntityAccess = %#v, want %v", doc["ApplyEntityAccess"], want) - } - - // And the parser has to read it back, or the value never reaches the - // writer on a rewrite in the first place. - back := ParseMicroflowFromRaw(doc, "mf-1", "mod-1") - if back.ApplyEntityAccess != want { - t.Errorf("parsed ApplyEntityAccess = %v, want %v", back.ApplyEntityAccess, want) - } - } -} diff --git a/sdk/mpr/writer_microflow_version_test.go b/sdk/mpr/writer_microflow_version_test.go deleted file mode 100644 index 60e772717f..0000000000 --- a/sdk/mpr/writer_microflow_version_test.go +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// bsonHasKey returns true when the top-level BSON document contains the key. -func bsonHasKey(doc bson.D, key string) bool { - for _, e := range doc { - if e.Key == key { - return true - } - } - return false -} - -// bsonGetKey returns the value of a key or nil if absent. -func bsonGetKey(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -func TestSerializeSequenceFlow_Mx9_UsesLegacyShape(t *testing.T) { - flow := µflows.SequenceFlow{ - BaseElement: model.BaseElement{ID: "flow-1"}, - OriginID: "orig-1", - DestinationID: "dest-1", - CaseValue: µflows.NoCase{BaseElement: model.BaseElement{ID: "case-1"}}, - } - - doc := serializeSequenceFlow(flow, 9) - - if !bsonHasKey(doc, "NewCaseValue") { - t.Error("Mx 9 sequence flow must include NewCaseValue") - } - if bsonHasKey(doc, "CaseValues") { - t.Error("Mx 9 sequence flow must NOT include CaseValues") - } - if !bsonHasKey(doc, "OriginBezierVector") || !bsonHasKey(doc, "DestinationBezierVector") { - t.Error("Mx 9 sequence flow must include top-level {Origin,Destination}BezierVector") - } - if bsonHasKey(doc, "Line") { - t.Error("Mx 9 sequence flow must NOT nest vectors under Line") - } -} - -func TestSerializeSequenceFlow_Mx10_UsesModernShape(t *testing.T) { - flow := µflows.SequenceFlow{ - BaseElement: model.BaseElement{ID: "flow-1"}, - OriginID: "orig-1", - DestinationID: "dest-1", - CaseValue: µflows.NoCase{BaseElement: model.BaseElement{ID: "case-1"}}, - } - - doc := serializeSequenceFlow(flow, 10) - - if !bsonHasKey(doc, "CaseValues") { - t.Error("Mx 10 sequence flow must include CaseValues") - } - if bsonHasKey(doc, "NewCaseValue") { - t.Error("Mx 10 sequence flow must NOT include legacy NewCaseValue") - } - if !bsonHasKey(doc, "Line") { - t.Error("Mx 10 sequence flow must nest vectors under Line") - } - if bsonHasKey(doc, "OriginBezierVector") || bsonHasKey(doc, "DestinationBezierVector") { - t.Error("Mx 10 sequence flow must NOT include top-level BezierVector fields") - } -} - -func TestSerializeEndEvent_EmptyReturnValueHasNoTrailingLineBreak(t *testing.T) { - end := µflows.EndEvent{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "end-empty"}, - Position: model.Point{X: 10, Y: 20}, - Size: model.Size{Width: 20, Height: 20}, - }, - ReturnValue: "", - } - - doc := serializeMicroflowObject(end) - if got := bsonGetKey(doc, "ReturnValue"); got != "" { - t.Fatalf("ReturnValue = %q, want empty string", got) - } -} - -func TestSerializeEndEvent_NonEmptyReturnValueHasNoSyntheticLineBreak(t *testing.T) { - end := µflows.EndEvent{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "end-result"}, - Position: model.Point{X: 10, Y: 20}, - Size: model.Size{Width: 20, Height: 20}, - }, - ReturnValue: "$Result", - } - - doc := serializeMicroflowObject(end) - if got := bsonGetKey(doc, "ReturnValue"); got != "$Result" { - t.Fatalf("ReturnValue = %q, want %q", got, "$Result") - } -} - -func TestSerializeAnnotationFlow_VersionShapes(t *testing.T) { - af := µflows.AnnotationFlow{ - BaseElement: model.BaseElement{ID: "af-1"}, - OriginID: "orig-1", - DestinationID: "dest-1", - } - - mx9 := serializeAnnotationFlow(af, 9) - if !bsonHasKey(mx9, "OriginBezierVector") || !bsonHasKey(mx9, "DestinationBezierVector") { - t.Error("Mx 9 annotation flow must use top-level BezierVector fields") - } - if bsonHasKey(mx9, "Line") { - t.Error("Mx 9 annotation flow must NOT nest under Line") - } - - mx10 := serializeAnnotationFlow(af, 10) - if !bsonHasKey(mx10, "Line") { - t.Error("Mx 10 annotation flow must nest vectors under Line") - } - if bsonHasKey(mx10, "OriginBezierVector") { - t.Error("Mx 10 annotation flow must NOT include top-level BezierVector") - } -} - -func TestSerializeMicroflowParameter_Mx9_OmitsMx10OnlyKeys(t *testing.T) { - p := µflows.MicroflowParameter{ - BaseElement: model.BaseElement{ID: "p-1"}, - Name: "Customer", - Type: µflows.StringType{}, - } - - mx9 := serializeMicroflowParameter(p, 0, 9) - if bsonHasKey(mx9, "DefaultValue") { - t.Error("Mx 9 parameter must NOT emit DefaultValue") - } - if bsonHasKey(mx9, "IsRequired") { - t.Error("Mx 9 parameter must NOT emit IsRequired") - } - - mx10 := serializeMicroflowParameter(p, 0, 10) - if !bsonHasKey(mx10, "DefaultValue") { - t.Error("Mx 10 parameter must emit DefaultValue") - } - if !bsonHasKey(mx10, "IsRequired") { - t.Error("Mx 10 parameter must emit IsRequired") - } -} - -func TestBuildSequenceFlowCase_NormalisesValueReceiver(t *testing.T) { - // A value-receiver NoCase must produce the same shape as a pointer. - fromValue := buildSequenceFlowCase(microflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}) - fromPointer := buildSequenceFlowCase(µflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}) - - if bsonGetKey(fromValue, "$Type") != bsonGetKey(fromPointer, "$Type") { - t.Error("value and pointer NoCase must produce identical $Type") - } -} - -func TestBuildSequenceFlowCase_ExpressionCase_UsesEnumerationCase(t *testing.T) { - doc := buildSequenceFlowCase(microflows.ExpressionCase{ - BaseElement: model.BaseElement{ID: "case-false"}, - Expression: "false", - }) - - if got := bsonGetKey(doc, "$Type"); got != "Microflows$EnumerationCase" { - t.Fatalf("$Type = %v, want Microflows$EnumerationCase", got) - } - if got := bsonGetKey(doc, "Value"); got != "false" { - t.Fatalf("Value = %v, want false", got) - } -} diff --git a/sdk/mpr/writer_microflow_workflow.go b/sdk/mpr/writer_microflow_workflow.go deleted file mode 100644 index 7c65312cd1..0000000000 --- a/sdk/mpr/writer_microflow_workflow.go +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -func serializeWorkflowCallAction(a *microflows.WorkflowCallAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$WorkflowCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - {Key: "Workflow", Value: a.Workflow}, - {Key: "WorkflowContextVariable", Value: a.WorkflowContextVariable}, - } -} - -func serializeGetWorkflowDataAction(a *microflows.GetWorkflowDataAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$GetWorkflowDataAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "Workflow", Value: a.Workflow}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeGetWorkflowsAction(a *microflows.GetWorkflowsAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$GetWorkflowsAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "WorkflowContextVariableName", Value: a.WorkflowContextVariableName}, - } -} - -func serializeGetWorkflowActivityRecordsAction(a *microflows.GetWorkflowActivityRecordsAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$GetWorkflowActivityRecordsAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeWorkflowOperationAction(a *microflows.WorkflowOperationAction) bson.D { - var opDoc bson.D - if a.Operation != nil { - switch op := a.Operation.(type) { - case *microflows.AbortOperation: - reasonDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: op.Reason}, - } - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$AbortOperation"}, - {Key: "Reason", Value: reasonDoc}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.ContinueOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$ContinueOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.PauseOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$PauseOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.RestartOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$RestartOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.RetryOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$RetryOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.UnpauseOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$UnpauseOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$WorkflowOperationAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "Operation", Value: opDoc}, - } -} - -func serializeSetTaskOutcomeAction(a *microflows.SetTaskOutcomeAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$SetTaskOutcomeAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutcomeValue", Value: a.OutcomeValue}, - {Key: "WorkflowTaskVariable", Value: a.WorkflowTaskVariable}, - } -} - -func serializeOpenUserTaskAction(a *microflows.OpenUserTaskAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$OpenUserTaskAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "UserTaskVariable", Value: a.UserTaskVariable}, - } -} - -func serializeNotifyWorkflowAction(a *microflows.NotifyWorkflowAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$NotifyWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeOpenWorkflowAction(a *microflows.OpenWorkflowAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$OpenWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeLockWorkflowAction(a *microflows.LockWorkflowAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$LockWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "PauseAllWorkflows", Value: a.PauseAllWorkflows}, - } - if !a.PauseAllWorkflows { - selDoc := serializeWorkflowSelection(a.Workflow, a.WorkflowVariable) - doc = append(doc, bson.E{Key: "WorkflowSelection", Value: selDoc}) - } - return doc -} - -func serializeUnlockWorkflowAction(a *microflows.UnlockWorkflowAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$UnlockWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ResumeAllPausedWorkflows", Value: a.ResumeAllPausedWorkflows}, - } - if !a.ResumeAllPausedWorkflows { - selDoc := serializeWorkflowSelection(a.Workflow, a.WorkflowVariable) - doc = append(doc, bson.E{Key: "WorkflowSelection", Value: selDoc}) - } - return doc -} - -func serializeWorkflowSelection(workflow, workflowVariable string) bson.D { - if workflow != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Workflows$WorkflowDefinitionNameSelection"}, - {Key: "Workflow", Value: workflow}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Workflows$WorkflowDefinitionObjectSelection"}, - {Key: "WorkflowDefinitionVariable", Value: workflowVariable}, - } -} diff --git a/sdk/mpr/writer_modules.go b/sdk/mpr/writer_modules.go deleted file mode 100644 index 10b2d2cde4..0000000000 --- a/sdk/mpr/writer_modules.go +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateModule creates a new module in the project. -// This also creates the associated domain model for the module. -func (w *Writer) CreateModule(module *model.Module) error { - if module.ID == "" { - module.ID = model.ID(generateUUID()) - } - module.TypeName = "Projects$ModuleImpl" - - // Get project root ID - modules are contained in the project root - projectRootID, err := w.reader.GetProjectRootID() - if err != nil { - return fmt.Errorf("failed to get project root: %w", err) - } - - // Serialize and insert module - contents, err := w.serializeModule(module) - if err != nil { - return fmt.Errorf("failed to serialize module: %w", err) - } - - if err := w.insertUnit(string(module.ID), projectRootID, "Modules", "Projects$ModuleImpl", contents); err != nil { - return fmt.Errorf("failed to insert module unit: %w", err) - } - - // Create empty domain model for the module - dmID := generateUUID() - dm := &domainmodel.DomainModel{ - ContainerID: module.ID, - } - dm.ID = model.ID(dmID) - dm.TypeName = "DomainModels$DomainModel" - - dmContents, err := w.serializeDomainModel(dm) - if err != nil { - return fmt.Errorf("failed to serialize domain model: %w", err) - } - - if err := w.insertUnit(dmID, string(module.ID), "DomainModel", "DomainModels$DomainModel", dmContents); err != nil { - return fmt.Errorf("failed to insert domain model unit: %w", err) - } - - // Create empty module security for the module - msID := generateUUID() - msContents, err := w.serializeModuleSecurity(msID) - if err != nil { - return fmt.Errorf("failed to serialize module security: %w", err) - } - - if err := w.insertUnit(msID, string(module.ID), "ModuleSecurity", "Security$ModuleSecurity", msContents); err != nil { - return fmt.Errorf("failed to insert module security unit: %w", err) - } - - // Create module settings for the module - settingsID := generateUUID() - settingsContents, err := w.serializeModuleSettings(settingsID) - if err != nil { - return fmt.Errorf("failed to serialize module settings: %w", err) - } - - if err := w.insertUnit(settingsID, string(module.ID), "ModuleSettings", "Projects$ModuleSettings", settingsContents); err != nil { - return fmt.Errorf("failed to insert module settings unit: %w", err) - } - - return nil -} - -// UpdateModule updates an existing module. -func (w *Writer) UpdateModule(module *model.Module) error { - contents, err := w.serializeModule(module) - if err != nil { - return fmt.Errorf("failed to serialize module: %w", err) - } - - return w.updateUnit(string(module.ID), contents) -} - -// DeleteModule deletes a module and all its child units (DomainModel, ModuleSecurity, -// ModuleSettings, Folders, Documents). This prevents orphaned units which cause -// Studio Pro to crash with KeyNotFoundException in UnitLoader.LoadChildUnits. -func (w *Writer) DeleteModule(id model.ID) error { - if err := w.deleteChildUnits(string(id)); err != nil { - return fmt.Errorf("failed to delete child units: %w", err) - } - return w.deleteUnit(string(id)) -} - -// DeleteModuleWithCleanup deletes a module and also removes its themesource directory. -// The moduleName is needed because the themesource directory name is derived from -// the module name (lowercased), not the module ID. -func (w *Writer) DeleteModuleWithCleanup(id model.ID, moduleName string) error { - if err := w.DeleteModule(id); err != nil { - return err - } - - // Remove the module's generated source directories. themesource and - // javasource use the lowercased module name; javascriptsource uses the - // original casing (with a lowercase fallback). Studio Pro deletes these when a - // module is removed; leaving them strands proxies/actions for a module that no - // longer exists in the model. - projectDir := filepath.Dir(w.reader.path) - removeModuleSourceDirs(projectDir, moduleName) - - return nil -} - -// removeModuleSourceDirs deletes the themesource/javasource/javascriptsource -// directories belonging to a module. Mirrored by the modelsdk backend's -// DeleteModuleWithCleanup. -func removeModuleSourceDirs(projectDir, moduleName string) { - lower := strings.ToLower(moduleName) - dirs := []string{ - filepath.Join(projectDir, "themesource", lower), - filepath.Join(projectDir, "javasource", lower), - filepath.Join(projectDir, "javascriptsource", moduleName), - filepath.Join(projectDir, "javascriptsource", lower), - } - for _, dir := range dirs { - if stat, err := os.Stat(dir); err == nil && stat.IsDir() { - os.RemoveAll(dir) - } - } -} - -// deleteChildUnits recursively deletes all units whose ContainerID matches the given parent. -func (w *Writer) deleteChildUnits(parentID string) error { - parentBlob := uuidToBlob(parentID) - if parentBlob == nil { - return fmt.Errorf("invalid parent ID: %s", parentID) - } - - // Find all child units - rows, err := w.reader.db.Query("SELECT UnitID FROM Unit WHERE ContainerID = ? AND UnitID != ContainerID", parentBlob) - if err != nil { - return err - } - defer rows.Close() - - var childIDs []string - for rows.Next() { - var childBlob []byte - if err := rows.Scan(&childBlob); err != nil { - return err - } - childIDs = append(childIDs, blobToUUID(childBlob)) - } - - // Recursively delete children of children first (depth-first) - for _, childID := range childIDs { - if err := w.deleteChildUnits(childID); err != nil { - return err - } - if err := w.deleteUnit(childID); err != nil { - return err - } - } - - return nil -} - -// CreateFolder creates a new folder in the project. -func (w *Writer) CreateFolder(folder *model.Folder) error { - if folder.ID == "" { - folder.ID = model.ID(generateUUID()) - } - folder.TypeName = "Projects$Folder" - - // Serialize and insert folder - contents, err := w.serializeFolder(folder) - if err != nil { - return fmt.Errorf("failed to serialize folder: %w", err) - } - - if err := w.insertUnit(string(folder.ID), string(folder.ContainerID), "Folders", "Projects$Folder", contents); err != nil { - return fmt.Errorf("failed to insert folder unit: %w", err) - } - - return nil -} - -// serializeFolder serializes a folder to BSON. -func (w *Writer) serializeFolder(folder *model.Folder) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(folder.ID))}, - {Key: "$Type", Value: "Projects$Folder"}, - {Key: "Name", Value: folder.Name}, - } - - return marshalUnitIDFirst(doc) -} - -// DeleteFolder deletes a folder unit if it is empty. -// Returns an error if the folder contains any child units. -func (w *Writer) DeleteFolder(id model.ID) error { - idStr := string(id) - blob := uuidToBlob(idStr) - if blob == nil { - return fmt.Errorf("invalid folder ID: %s", idStr) - } - - var count int - err := w.reader.db.QueryRow( - "SELECT COUNT(*) FROM Unit WHERE ContainerID = ? AND UnitID != ContainerID", - blob, - ).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check folder contents: %w", err) - } - if count > 0 { - return fmt.Errorf("folder is not empty: contains %d child unit(s)", count) - } - - return w.deleteUnit(idStr) -} - -// MoveFolder moves a folder to a new container (folder or module root). -func (w *Writer) MoveFolder(id model.ID, newContainerID model.ID) error { - return w.moveUnitByID(string(id), string(newContainerID)) -} - -func (w *Writer) serializeModuleSecurity(id string) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Security$ModuleSecurity"}, - {Key: "ModuleRoles", Value: bson.A{int32(1)}}, - } - return marshalUnitIDFirst(doc) -} - -func (w *Writer) serializeModuleSettings(id string) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Projects$ModuleSettings"}, - {Key: "BasedOnVersion", Value: ""}, - {Key: "ExportLevel", Value: "Source"}, - {Key: "ExtensionName", Value: ""}, - {Key: "JarDependencies", Value: bson.A{int32(2)}}, - {Key: "ProtectedModuleType", Value: "AddOn"}, - {Key: "SolutionIdentifier", Value: ""}, - {Key: "Version", Value: "1.0.0"}, - } - return marshalUnitIDFirst(doc) -} - -// UpdateModuleSettings persists the full Projects$ModuleSettings document, -// including all JarDependencies and their Exclusions. -func (w *Writer) UpdateModuleSettings(ms *types.ModuleSettings) error { - contents, err := w.serializeModuleSettingsFull(ms) - if err != nil { - return fmt.Errorf("failed to serialize module settings: %w", err) - } - return w.updateUnit(string(ms.ID), contents) -} - -// serializeModuleSettingsFull serializes a ModuleSettings with actual dependencies. -func (w *Writer) serializeModuleSettingsFull(ms *types.ModuleSettings) ([]byte, error) { - exportLevel := ms.ExportLevel - if exportLevel == "" { - exportLevel = "Source" - } - protectedType := ms.ProtectedModuleType - if protectedType == "" { - protectedType = "AddOn" - } - ver := ms.Version - if ver == "" { - ver = "1.0.0" - } - - deps := bson.A{int32(2)} // listType marker - for _, d := range ms.JarDependencies { - depID := string(d.ID) - if depID == "" { - depID = generateUUID() - } - depDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(depID)}, - {Key: "$Type", Value: "Projects$JarDependency"}, - {Key: "GroupId", Value: d.GroupID}, - {Key: "ArtifactId", Value: d.ArtifactID}, - {Key: "Version", Value: d.Version}, - {Key: "IsIncluded", Value: d.IsIncluded}, - } - if len(d.Exclusions) > 0 { - excArr := bson.A{int32(2)} - for _, e := range d.Exclusions { - excID := string(e.ID) - if excID == "" { - excID = generateUUID() - } - excArr = append(excArr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(excID)}, - {Key: "$Type", Value: "Projects$JarDependencyExclusion"}, - {Key: "GroupId", Value: e.GroupID}, - {Key: "ArtifactId", Value: e.ArtifactID}, - }) - } - depDoc = append(depDoc, bson.E{Key: "Exclusions", Value: excArr}) - } - deps = append(deps, depDoc) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ms.ID))}, - {Key: "$Type", Value: "Projects$ModuleSettings"}, - {Key: "BasedOnVersion", Value: ms.BasedOnVersion}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "ExtensionName", Value: ms.ExtensionName}, - {Key: "JarDependencies", Value: deps}, - {Key: "ProtectedModuleType", Value: protectedType}, - {Key: "SolutionIdentifier", Value: ms.SolutionIdentifier}, - {Key: "Version", Value: ver}, - } - return marshalUnitIDFirst(doc) -} - -func (w *Writer) serializeModule(module *model.Module) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(module.ID))}, - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "Name", Value: module.Name}, - {Key: "FromAppStore", Value: module.FromAppStore}, - {Key: "AppStoreGuid", Value: module.AppStoreGuid}, - {Key: "AppStorePackageIdString", Value: ""}, - {Key: "AppStoreVersion", Value: module.AppStoreVersion}, - {Key: "AppStoreVersionGuid", Value: ""}, - {Key: "IsThemeModule", Value: false}, - {Key: "NewSortIndex", Value: int64(0)}, - } - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/writer_navigation.go b/sdk/mpr/writer_navigation.go deleted file mode 100644 index 317322d738..0000000000 --- a/sdk/mpr/writer_navigation.go +++ /dev/null @@ -1,472 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// NavigationProfileSpec describes the desired state for a navigation profile. -// Aliased from mdl/types to avoid duplicate definitions. -type NavigationProfileSpec = types.NavigationProfileSpec -type NavOfflineEntitySpec = types.NavOfflineEntitySpec - -// NavHomePageSpec describes a home page entry. -type NavHomePageSpec = types.NavHomePageSpec - -// NavMenuItemSpec describes a menu item. -type NavMenuItemSpec = types.NavMenuItemSpec - -// UpdateNavigationProfile patches a navigation profile's home pages, login page, and menu. -// Typed-array markers for the lists these writers emit. -// -// The leading int32 of a Mendix array is a per-FIELD constant, not a function of -// the list's contents: Forms$FormSettings.ParameterMappings is 2 in 816 empty -// and 306 non-empty real occurrences alike. So each list below takes the value -// Studio Pro writes for that field, censused over 19,078 unit files in 54 -// projects on this machine: -// -// Forms$FormSettings.ParameterMappings 2 (1122 documents) -// Forms$FormAction.PagesForSpecializations 2 (357) -// Menus$MenuItemCollection.Items 3 (153) -// Menus$MenuItem.Items 3 (459) -// Texts$Text.Items 3 (169,486 vs 7 at 2) -// Navigation$NavigationProfile.HomeItems 2 (51) -// -// These writers previously emitted 1 for all of them. Note what that was NOT: -// 1 is a perfectly legitimate Mendix marker -- a Marketplace .mpk mxcli has -// never touched carries it on CustomWidgets$WidgetValueType.AllowedTypes (212k -// occurrences) and on Forms$Page.AllowedModuleRoles. debug-bson.md's rule that -// "any other value is invalid and Studio Pro ignores the array" is too strong -// and is corrected there. The defect is narrower: for THESE fields, no -// Studio Pro document uses 1, and mxcli's own menu-document codec path already -// writes 3 for the same Menus$ item collections, so the two paths disagreed. -// -// HomeItems needed a second source, because all 51 census observations are empty -// lists and navigation_profile_add.go wrote 3 there from a PED session that -// cannot be re-run here. ako/TestApp settles it: its Studio Pro-authored profile -// carries HomeItems [marker 2] holding two Navigation$RoleBasedHomePage -// elements -- a NON-empty list, which is the case the census could not reach. -// navigation_profile_add.go now writes 2 as well. -const ( - navMarkerItems = int32(3) - navMarkerParameterMappings = int32(2) - navMarkerHomeItems = int32(2) -) - -func (w *Writer) UpdateNavigationProfile(navDocID model.ID, profileName string, spec NavigationProfileSpec) error { - return w.readPatchWrite(navDocID, func(doc bson.D) (bson.D, error) { - profiles := getBsonArray(doc, "Profiles") - if profiles == nil { - return doc, fmt.Errorf("no Profiles array found in navigation document") - } - - found := false - for i, item := range profiles { - profDoc, ok := item.(bson.D) - if !ok { - continue - } - - // Match profile by name (case-insensitive) - name := "" - for _, f := range profDoc { - if f.Key == "Name" { - name, _ = f.Value.(string) - break - } - } - if !strings.EqualFold(name, profileName) { - continue - } - found = true - - // Determine if this is a native profile - isNative := false - for _, f := range profDoc { - if f.Key == "$Type" { - typeName, _ := f.Value.(string) - isNative = typeName == "Navigation$NativeNavigationProfile" - break - } - } - - if isNative { - profDoc = patchNativeProfile(profDoc, spec) - } else { - profDoc = patchWebProfile(profDoc, spec) - } - - profiles[i] = profDoc - break - } - - if !found { - return doc, fmt.Errorf("navigation profile not found: %s", profileName) - } - - return setBsonField(doc, "Profiles", profiles), nil - }) -} - -// patchWebProfile applies the spec to a web navigation profile. -func patchWebProfile(doc bson.D, spec NavigationProfileSpec) bson.D { - // --- HomePage (default home) --- - var defaultHome *NavHomePageSpec - var roleHomes []NavHomePageSpec - for _, hp := range spec.HomePages { - if hp.ForRole == "" { - h := hp - defaultHome = &h - } else { - roleHomes = append(roleHomes, hp) - } - } - - if defaultHome != nil { - doc = setBsonField(doc, "HomePage", buildHomePageBson(defaultHome)) - } else { - // Clear default home page - doc = setBsonField(doc, "HomePage", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$HomePage"}, - {Key: "Microflow", Value: ""}, - {Key: "Page", Value: ""}, - }) - } - - // --- HomeItems (role-based homes) --- - homeItems := bson.A{navMarkerHomeItems} - for _, rh := range roleHomes { - homeItems = append(homeItems, buildRoleBasedHomeBson(rh)) - } - doc = setBsonField(doc, "HomeItems", homeItems) - - // --- LoginPageSettings --- - if spec.LoginPage != "" { - doc = setBsonField(doc, "LoginPageSettings", buildFormSettingsBson(spec.LoginPage)) - } else { - doc = setBsonField(doc, "LoginPageSettings", buildFormSettingsBson("")) - } - - // --- NotFoundHomepage --- - if spec.NotFoundPage != "" { - doc = setBsonField(doc, "NotFoundHomepage", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - // Studio Pro's "Fallback page". The $Type is - // Navigation$NotFoundHomePage, not the Navigation$HomePage the home - // page slot takes -- measured on ako/TestApp, whose fallback page - // Studio Pro stored as Navigation$NotFoundHomePage/Page. - // - // The wrong $Type here is not cosmetic: Mendix cannot LOAD the - // project. Both `mx check` and `mxbuild --target=deploy` exit 1 with - // "Object of type '...Navigation.HomePage' cannot be converted to - // type '...Navigation.NotFoundHomePage'" (measured on 11.13, against - // a build of this file emitting the old spelling). Nothing caught it - // because nothing ever BUILT a project with a fallback page set -- - // the automated mx-check coverage runs doctype-tests/ only, and no - // script there sets one. - {Key: "$Type", Value: "Navigation$NotFoundHomePage"}, - {Key: "Microflow", Value: ""}, - {Key: "Page", Value: spec.NotFoundPage}, - }) - } else { - // Mendix uses null when not set - doc = setBsonField(doc, "NotFoundHomepage", nil) - } - - // --- Menu --- - if spec.HasMenu { - menuItems := bson.A{navMarkerItems} - for _, mi := range spec.MenuItems { - menuItems = append(menuItems, buildMenuItemBson(mi)) - } - doc = setBsonField(doc, "Menu", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Menus$MenuItemCollection"}, - {Key: "Items", Value: menuItems}, - }) - } - - // --- Offline synchronization --- - if spec.HasSync { - doc = setBsonField(doc, "OfflineEntityConfigs", - buildOfflineConfigsBson(getBsonArray(doc, "OfflineEntityConfigs"), spec.OfflineEntities)) - } - - // Kept identical to the modelsdk engine: nil leaves the stored flag alone, - // because neither generated source declares the property and a non-pointer - // would reset it on every rewrite that never mentions it. - if spec.ThrowSyncError != nil { - doc = setBsonField(doc, "ThrowPartialSyncError", *spec.ThrowSyncError) - } - - return doc -} - -// buildOfflineConfigsBson rebuilds OfflineEntityConfigs from the spec, carrying -// forward the properties MDL cannot express. -// -// Kept deliberately identical in behaviour to the modelsdk engine's -// navOfflineConfigs: CompatibilityMode is preserved per entity, and -// DownloadMode/ShouldDownload are not written at all — they occur zero times in -// ako/TestApp's configs, and a property absent from every real document is one -// Studio Pro fills in on load. A cross-engine test asserts the two agree, -// because two writers drifting apart is how an engine-specific defect hides. -func buildOfflineConfigsBson(stored bson.A, specs []NavOfflineEntitySpec) bson.A { - compat := map[string]bool{} - for _, item := range stored { - var cfg map[string]any - switch v := item.(type) { - case bson.D: - cfg = v.Map() - case map[string]any: - cfg = v - default: - continue // the leading typed-array marker - } - if e := extractString(cfg["Entity"]); e != "" { - compat[e] = extractBool(cfg["CompatibilityMode"], false) - } - } - - out := bson.A{navMarkerItems} - for _, sp := range specs { - out = append(out, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, - {Key: "CompatibilityMode", Value: compat[sp.Entity]}, - {Key: "Constraint", Value: sp.Constraint}, - {Key: "Entity", Value: sp.Entity}, - {Key: "SyncMode", Value: sp.SyncMode}, - }) - } - return out -} - -// patchNativeProfile applies the spec to a native navigation profile. -func patchNativeProfile(doc bson.D, spec NavigationProfileSpec) bson.D { - var defaultHome *NavHomePageSpec - var roleHomes []NavHomePageSpec - for _, hp := range spec.HomePages { - if hp.ForRole == "" { - h := hp - defaultHome = &h - } else { - roleHomes = append(roleHomes, hp) - } - } - - if defaultHome != nil { - page := "" - nanoflow := "" - if defaultHome.IsPage { - page = defaultHome.Target - } else { - nanoflow = defaultHome.Target - } - doc = setBsonField(doc, "NativeHomePage", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$NativeHomePage"}, - {Key: "HomePagePage", Value: page}, - {Key: "HomePageNanoflow", Value: nanoflow}, - }) - } - - // Role-based native home pages - roleItems := bson.A{navMarkerHomeItems} - for _, rh := range roleHomes { - page := "" - nanoflow := "" - if rh.IsPage { - page = rh.Target - } else { - nanoflow = rh.Target - } - roleItems = append(roleItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$RoleBasedNativeHomePage"}, - {Key: "UserRole", Value: rh.ForRole}, - {Key: "HomePagePage", Value: page}, - {Key: "HomePageNanoflow", Value: nanoflow}, - }) - } - doc = setBsonField(doc, "RoleBasedNativeHomePages", roleItems) - - return doc -} - -// buildHomePageBson builds a Navigation$HomePage BSON document. -func buildHomePageBson(hp *NavHomePageSpec) bson.D { - page := "" - mf := "" - if hp.IsPage { - page = hp.Target - } else { - mf = hp.Target - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$HomePage"}, - {Key: "Microflow", Value: mf}, - {Key: "Page", Value: page}, - } -} - -// buildRoleBasedHomeBson builds a Navigation$RoleBasedHomePage BSON document. -func buildRoleBasedHomeBson(rh NavHomePageSpec) bson.D { - page := "" - mf := "" - if rh.IsPage { - page = rh.Target - } else { - mf = rh.Target - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$RoleBasedHomePage"}, - {Key: "Microflow", Value: mf}, - {Key: "Page", Value: page}, - {Key: "UserRole", Value: rh.ForRole}, - } -} - -// buildFormSettingsBson builds a Forms$FormSettings BSON document with required fields. -func buildFormSettingsBson(formName string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: formName}, - {Key: "ParameterMappings", Value: bson.A{navMarkerParameterMappings}}, - // No override is an explicit null. An empty template overrides the page - // title with "" and produces CW0263 for every authored menu item (#812). - {Key: "TitleOverride", Value: nil}, - } -} - -// buildMenuItemBson builds a Menus$MenuItem BSON document recursively. -func buildMenuItemBson(mi NavMenuItemSpec) bson.D { - item := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Menus$MenuItem"}, - {Key: "Action", Value: buildMenuAction(mi)}, - {Key: "AlternativeText", Value: nil}, - {Key: "Caption", Value: buildCaptionBson(mi.Caption)}, - {Key: "Icon", Value: buildMenuIconBson(mi)}, - } - - // Sub-items - subItems := bson.A{navMarkerItems} - for _, sub := range mi.Items { - subItems = append(subItems, buildMenuItemBson(sub)) - } - item = append(item, bson.E{Key: "Items", Value: subItems}) - - return item -} - -// buildMenuIconBson builds a menu item's Icon, or nil when none is set. -// -// The metamodel calls this Pages$IconCollectionIcon, but the storage name is -// Forms$IconCollectionIcon — the same "Form was the original term for Page" -// rename CLAUDE.md documents for ShowFormAction. Verified against a Studio -// Pro-authored navigation document (ako/mxcli-ledger), whose menu icons are all -// Forms$IconCollectionIcon{Image: "Atlas_Core.Atlas.align-center"}, and matching -// the widget icon path already proven in issue #602. -// -// Two sibling variants exist in the same document — Forms$GlyphIcon{Code: int} -// and Forms$ImageIcon{Image: QN}. They used to be excluded because a name alone -// cannot tell an image icon from a collection icon without resolving which -// document it lands in, and guessing between polymorphic variants is the failure -// mode that produces a document mxbuild accepts and Studio Pro cannot open. -// -// Nothing is guessed now: the KIND is carried explicitly, from the author's own -// `icon image …` / `icon glyph …` or from the kind the reader saw in storage. So -// all three are emitted, and the branch is a dispatch rather than an inference. -// -// Excluding them was not neutral. `create or replace navigation` is a full -// replacement, so an icon the writer would not emit was an icon the statement -// DELETED — measured on testdata/expr-checker, exec of DESCRIBE's own output -// destroyed a glyph icon at exit 0. -func buildMenuIconBson(spec NavMenuItemSpec) interface{} { - kind := spec.IconKind - if kind == types.MenuIconNone && spec.Icon != "" { - // A spec built before the kind existed carries a name and nothing else, - // and that name has only ever meant an icon-collection icon. - kind = types.MenuIconCollection - } - storage := types.MenuIconStorageType(kind) - if storage == "" { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: storage}, - } - if kind == types.MenuIconGlyph { - // A glyph with no code identifies no glyph. Emit no icon rather than an - // element nobody can see. - if spec.IconCode == 0 { - return nil - } - return append(doc, bson.E{Key: "Code", Value: int32(spec.IconCode)}) - } - if spec.Icon == "" { - return nil - } - return append(doc, bson.E{Key: "Image", Value: spec.Icon}) -} - -// buildCaptionBson builds a Texts$Text BSON document with a single en_US translation. -func buildCaptionBson(text string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{ - navMarkerItems, - bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: text}, - }, - }}, - } -} - -// buildMenuAction builds the Action BSON for a menu item based on its target. -func buildMenuAction(mi NavMenuItemSpec) bson.D { - if mi.Page != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormAction"}, - {Key: "DisabledDuringExecution", Value: false}, - {Key: "FormSettings", Value: buildFormSettingsBson(mi.Page)}, - {Key: "NumberOfPagesToClose2", Value: ""}, - {Key: "PagesForSpecializations", Value: bson.A{navMarkerParameterMappings}}, - } - } - if mi.Microflow != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowAction"}, - {Key: "DisabledDuringExecution", Value: false}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Microflow", Value: mi.Microflow}, - }}, - } - } - // No action (sub-menu container or plain item) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - } -} diff --git a/sdk/mpr/writer_navigation_icon_test.go b/sdk/mpr/writer_navigation_icon_test.go deleted file mode 100644 index 970ec70b97..0000000000 --- a/sdk/mpr/writer_navigation_icon_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// navIconEntry returns the value of a key in a bson.D and whether it was -// present. Unlike the package's bsonLookup it separates an absent key from a -// null one — the distinction a null Icon turns on. -func navIconEntry(d bson.D, key string) (interface{}, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// The storage name is the whole point of this test. -// -// The metamodel calls the element Pages$IconCollectionIcon, but Mendix stores it -// as Forms$IconCollectionIcon — the "Form was the original term for Page" rename -// CLAUDE.md documents for ShowFormAction. Getting a polymorphic child's $Type -// wrong yields a document mxbuild accepts (its deserializer tolerates unknown -// properties) and Studio Pro cannot open, so the name is pinned against a -// Studio Pro-authored reference: every menu icon in ako/mxcli-ledger's -// navigation document is Forms$IconCollectionIcon{Image: "Atlas_Core.Atlas.…"}. -func TestBuildMenuIconBson_UsesTheFormsStorageName(t *testing.T) { - got := buildMenuIconBson(NavMenuItemSpec{Icon: "Atlas_Core.Atlas.align-center"}) - d, ok := got.(bson.D) - if !ok { - t.Fatalf("expected a bson.D, got %T", got) - } - typ, _ := navIconEntry(d, "$Type") - if typ != "Forms$IconCollectionIcon" { - t.Errorf("$Type = %v, want Forms$IconCollectionIcon (NOT the metamodel's Pages$…)", typ) - } - img, present := navIconEntry(d, "Image") - if !present { - t.Fatal("the icon carries its name in Image; the key is missing") - } - if img != "Atlas_Core.Atlas.align-center" { - t.Errorf("Image = %v", img) - } - if _, present := navIconEntry(d, "$ID"); !present { - t.Error("every stored element needs its own $ID") - } - // Studio Pro writes exactly these three keys. A fourth would be a property - // the type does not declare, which is what makes a document unopenable. - if len(d) != 3 { - t.Errorf("icon has %d keys, want exactly $ID/$Type/Image: %v", len(d), d) - } -} - -// No icon must stay a null, not an empty element: an IconCollectionIcon with a -// blank Image is a dangling reference, where absent is the modelled default. -func TestBuildMenuIconBson_EmptyNameStaysNull(t *testing.T) { - if got := buildMenuIconBson(NavMenuItemSpec{}); got != nil { - t.Errorf("buildMenuIconBson(empty spec) = %v, want nil", got) - } -} - -// The regression this fixes: buildMenuItemBson hardcoded Icon to nil, so an icon -// the author wrote was dropped on the way to the file. Assert on the encoded -// item, not just the helper, so the wiring is covered too. -func TestBuildMenuItemBson_CarriesTheIconThrough(t *testing.T) { - item := buildMenuItemBson(NavMenuItemSpec{ - Caption: "Dashboard", - Page: "M.Dash", - Icon: "Atlas_Core.Atlas.align-center", - }) - icon, present := navIconEntry(item, "Icon") - if !present { - t.Fatal("the Icon key must always be written, even when null") - } - d, ok := icon.(bson.D) - if !ok { - t.Fatalf("Icon = %#v; the authored icon was dropped on the way to BSON", icon) - } - if img, _ := navIconEntry(d, "Image"); img != "Atlas_Core.Atlas.align-center" { - t.Errorf("Image = %v", img) - } -} - -// A sub-menu is built by the same recursion, so its icon has to survive it. -func TestBuildMenuItemBson_CarriesTheIconThroughSubItems(t *testing.T) { - item := buildMenuItemBson(NavMenuItemSpec{ - Caption: "Reports", - Items: []NavMenuItemSpec{{ - Caption: "Monthly", Page: "M.Monthly", Icon: "Atlas_Core.Atlas.folder", - }}, - }) - items, _ := navIconEntry(item, "Items") - arr, ok := items.(bson.A) - if !ok || len(arr) != 2 { // [list-marker, one child] - t.Fatalf("Items = %#v", items) - } - sub, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("sub-item = %#v", arr[1]) - } - icon, _ := navIconEntry(sub, "Icon") - d, ok := icon.(bson.D) - if !ok { - t.Fatalf("sub-item Icon = %#v; the recursion dropped it", icon) - } - if img, _ := navIconEntry(d, "Image"); img != "Atlas_Core.Atlas.folder" { - t.Errorf("sub-item Image = %v", img) - } -} - -// An item written without an icon keeps the null it had before this change. -func TestBuildMenuItemBson_NoIconStillWritesNull(t *testing.T) { - item := buildMenuItemBson(NavMenuItemSpec{Caption: "Dashboard", Page: "M.Dash"}) - icon, present := navIconEntry(item, "Icon") - if !present { - t.Fatal("the Icon key must be written even with no icon") - } - if icon != nil { - t.Errorf("Icon = %#v, want nil", icon) - } -} - -// Studio Pro stores the absence of a page-title override as an explicit null. -// An empty TextTemplate is a real override to "" and raises CW0263. -func TestBuildFormSettingsBson_NoTitleOverrideStaysNull(t *testing.T) { - settings := buildFormSettingsBson("M.Dash") - title, present := navIconEntry(settings, "TitleOverride") - if !present { - t.Fatal("TitleOverride key missing; Studio Pro writes an explicit null") - } - if title != nil { - t.Fatalf("TitleOverride = %#v, want nil", title) - } -} - -// The read side has to recognise all three variants, because a project authored -// in Studio Pro contains all three. The fixtures are the literal shapes dumped -// from ako/mxcli-ledger's navigation document. -func TestParseNavMenuItem_ReadsEachIconVariant(t *testing.T) { - for _, tc := range []struct { - name string - icon map[string]any - wantType, want string - }{ - { - name: "icon collection", - icon: map[string]any{"$Type": "Forms$IconCollectionIcon", "Image": "Atlas_Core.Atlas.align-center"}, - wantType: "Forms$IconCollectionIcon", want: "Atlas_Core.Atlas.align-center", - }, - { - name: "image", - icon: map[string]any{"$Type": "Forms$ImageIcon", "Image": "System.Images.Close"}, - wantType: "Forms$ImageIcon", want: "System.Images.Close", - }, - { - // A glyph carries a numeric Code and no name at all. Reporting an - // empty Icon here is load-bearing: it is what stops DESCRIBE from - // emitting an ICON clause that would convert the variant on replay. - name: "glyph", - icon: map[string]any{"$Type": "Forms$GlyphIcon", "Code": int32(9999)}, - wantType: "Forms$GlyphIcon", want: "", - }, - } { - t.Run(tc.name, func(t *testing.T) { - mi := parseNavMenuItem(map[string]any{ - "Caption": map[string]any{}, - "Icon": tc.icon, - "Action": map[string]any{ - "$Type": "Forms$FormAction", - "FormSettings": map[string]any{"Form": "M.Dash"}, - }, - }) - if mi == nil { - t.Fatal("the item did not parse") - } - if mi.IconType != tc.wantType { - t.Errorf("IconType = %q, want %q", mi.IconType, tc.wantType) - } - if mi.Icon != tc.want { - t.Errorf("Icon = %q, want %q", mi.Icon, tc.want) - } - }) - } -} - -// A menu item with no icon must read back as no icon, not as an empty-named one. -func TestParseNavMenuItem_NoIconReadsAsNone(t *testing.T) { - mi := parseNavMenuItem(map[string]any{ - "Caption": map[string]any{}, - "Action": map[string]any{ - "$Type": "Forms$FormAction", "FormSettings": map[string]any{"Form": "M.Dash"}, - }, - }) - if mi == nil { - t.Fatal("the item did not parse") - } - if mi.Icon != "" || mi.IconType != "" { - t.Errorf("Icon/IconType = (%q, %q), want both empty", mi.Icon, mi.IconType) - } -} - -// All three icon elements are written now. Only the collection variant used to -// be, and because `create or replace navigation` is a full replacement, an icon -// the writer would not emit was an icon the statement DELETED — measured on -// testdata/expr-checker, exec of DESCRIBE's own output destroyed a glyph icon at -// exit 0. -func TestBuildMenuIconBson_Glyph(t *testing.T) { - got, ok := buildMenuIconBson(NavMenuItemSpec{IconKind: types.MenuIconGlyph, IconCode: 57345}).(bson.D) - if !ok { - t.Fatal("a glyph icon produced no document") - } - m := bsonDToMap(got) - if m["$Type"] != "Forms$GlyphIcon" { - t.Errorf("$Type = %v, want Forms$GlyphIcon", m["$Type"]) - } - if m["Code"] != int32(57345) { - t.Errorf("Code = %#v, want int32(57345) — Mendix stores the character code as an int32", m["Code"]) - } - if _, hasImage := m["Image"]; hasImage { - t.Error("a glyph icon must not carry an Image; it has no qualified name") - } -} - -func TestBuildMenuIconBson_Image(t *testing.T) { - got, ok := buildMenuIconBson(NavMenuItemSpec{IconKind: types.MenuIconImage, Icon: "MyMod.Images.logo"}).(bson.D) - if !ok { - t.Fatal("an image icon produced no document") - } - m := bsonDToMap(got) - if m["$Type"] != "Forms$ImageIcon" { - t.Errorf("$Type = %v, want Forms$ImageIcon", m["$Type"]) - } - if m["Image"] != "MyMod.Images.logo" { - t.Errorf("Image = %v", m["Image"]) - } -} - -// The control that keeps the dispatch honest: a name with NO kind still means an -// icon-collection icon. Every script written before the kind existed carries -// exactly that, so treating it as "unknown" would silently drop every icon in -// the corpus. -func TestBuildMenuIconBson_BareNameIsStillACollectionIcon(t *testing.T) { - got, ok := buildMenuIconBson(NavMenuItemSpec{Icon: "Atlas_Core.Atlas.home"}).(bson.D) - if !ok { - t.Fatal("a bare name produced no document") - } - if bsonDToMap(got)["$Type"] != "Forms$IconCollectionIcon" { - t.Errorf("$Type = %v, want Forms$IconCollectionIcon", bsonDToMap(got)["$Type"]) - } -} - -// A glyph with no code identifies no glyph, and an element with no Code renders -// as a blank where an icon should be. Emit nothing instead. -func TestBuildMenuIconBson_GlyphWithoutACodeIsNoIcon(t *testing.T) { - if got := buildMenuIconBson(NavMenuItemSpec{IconKind: types.MenuIconGlyph}); got != nil { - t.Errorf("got %v, want nil", got) - } -} - -func bsonDToMap(d bson.D) map[string]any { - m := make(map[string]any, len(d)) - for _, e := range d { - m[e.Key] = e.Value - } - return m -} diff --git a/sdk/mpr/writer_navigation_notfound_test.go b/sdk/mpr/writer_navigation_notfound_test.go deleted file mode 100644 index 7f977d4259..0000000000 --- a/sdk/mpr/writer_navigation_notfound_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// navNotFoundEntry returns the value of a key and whether it was present, -// separating an absent key from an explicitly null one. -func navNotFoundEntry(d bson.D, key string) (interface{}, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// notFoundHomepageOf patches a bare web profile with the given spec and returns -// the NotFoundHomepage it wrote. -func notFoundHomepageOf(t *testing.T, spec NavigationProfileSpec) (interface{}, bool) { - t.Helper() - return navNotFoundEntry(patchWebProfile(bson.D{}, spec), "NotFoundHomepage") -} - -// Studio Pro's "Fallback page" is its own type. The NotFoundHomepage property is -// declared Navigation$NotFoundHomePage, NOT the Navigation$HomePage the home-page -// slot takes, and .NET refuses the assignment on load: -// -// System.ArgumentException: Object of type -// 'Mendix.Modeler.WebUI.Navigation.HomePage' cannot be converted to type -// 'Mendix.Modeler.WebUI.Navigation.NotFoundHomePage'. -// -// The project is then unopenable — and because the failure happens while LOADING -// the model, `mx check` prints that trace INSTEAD OF its "The app contains: N -// errors" line, so a caller reading the count rather than the exit status sees a -// run that reported nothing at all (mendixlabs/mxcli#1000). -// -// This is the writer the default engine uses, and the one that produced the -// unopenable project in #1000's report. It had no test: reverting just this -// $Type left the whole suite green. -func TestPatchWebProfile_NotFoundPageUsesItsOwnType(t *testing.T) { - nfp, present := notFoundHomepageOf(t, NavigationProfileSpec{ - NotFoundPage: "MyFirstModule.NotFound", - }) - if !present { - t.Fatal("NotFoundHomepage key missing") - } - d, ok := nfp.(bson.D) - if !ok { - t.Fatalf("NotFoundHomepage = %#v, want a document", nfp) - } - if typ, _ := navNotFoundEntry(d, "$Type"); typ != "Navigation$NotFoundHomePage" { - t.Errorf("$Type = %v, want Navigation$NotFoundHomePage (NOT Navigation$HomePage)", typ) - } - if page, _ := navNotFoundEntry(d, "Page"); page != "MyFirstModule.NotFound" { - t.Errorf("Page = %v", page) - } - if _, present := navNotFoundEntry(d, "$ID"); !present { - t.Error("every stored element needs its own $ID") - } -} - -// The two slots are adjacent, take the same Page/Microflow pair, and differ only -// in $Type — so a fix applied one `sed` too wide silently converts the home page -// as well. HomePage keeps Navigation$HomePage. -func TestPatchWebProfile_HomePageKeepsTheHomePageType(t *testing.T) { - doc := patchWebProfile(bson.D{}, NavigationProfileSpec{ - HomePages: []NavHomePageSpec{{IsPage: true, Target: "MyFirstModule.Home"}}, - NotFoundPage: "MyFirstModule.NotFound", - }) - hp, present := navNotFoundEntry(doc, "HomePage") - if !present { - t.Fatal("HomePage key missing") - } - d, ok := hp.(bson.D) - if !ok { - t.Fatalf("HomePage = %#v, want a document", hp) - } - if typ, _ := navNotFoundEntry(d, "$Type"); typ != "Navigation$HomePage" { - t.Errorf("HomePage $Type = %v, want Navigation$HomePage", typ) - } -} - -// No fallback page is an explicit null, not an element with a blank Page: a -// NotFoundHomePage pointing at "" is a dangling reference where absent is the -// modelled default. -func TestPatchWebProfile_NoNotFoundPageStaysNull(t *testing.T) { - nfp, present := notFoundHomepageOf(t, NavigationProfileSpec{}) - if !present { - t.Fatal("the NotFoundHomepage key must be written even when unset") - } - if nfp != nil { - t.Errorf("NotFoundHomepage = %#v, want nil", nfp) - } -} diff --git a/sdk/mpr/writer_navigation_offline_test.go b/sdk/mpr/writer_navigation_offline_test.go deleted file mode 100644 index 73c5e4489e..0000000000 --- a/sdk/mpr/writer_navigation_offline_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// The legacy engine must behave identically to modelsdk here. Two writers that -// drift apart is how an engine-specific defect hides: a project written on one -// engine and rewritten on the other would lose the property on exactly one of -// the two paths, and nothing reports it. -func TestLegacyOfflineWriteCarriesCompatibilityMode(t *testing.T) { - stored := bson.A{ - navMarkerItems, - bson.D{ - {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, - {Key: "CompatibilityMode", Value: true}, - {Key: "Entity", Value: "Rules.RuleAction"}, - {Key: "SyncMode", Value: "All"}, - }, - } - out := buildOfflineConfigsBson(stored, []NavOfflineEntitySpec{ - {Entity: "Rules.RuleAction", SyncMode: "Never"}, - }) - if len(out) != 2 { - t.Fatalf("expected marker + 1 config, got %d", len(out)) - } - got := out[1].(bson.D).Map() - if got["CompatibilityMode"] != true { - t.Error("legacy dropped CompatibilityMode on a rewrite that never mentioned it") - } - if got["SyncMode"] != "Never" { - t.Errorf("SyncMode = %v, want Never", got["SyncMode"]) - } -} - -// The reader hands back map[string]any rather than bson.D on some paths, and a -// carry that only understood one shape would silently default to false for the -// other — which is the shape the legacy parser actually produces. -func TestLegacyOfflineWriteReadsEitherStoredShape(t *testing.T) { - for name, stored := range map[string]bson.A{ - "bson.D": {navMarkerItems, bson.D{ - {Key: "CompatibilityMode", Value: true}, {Key: "Entity", Value: "Mod.E"}}}, - "map": {navMarkerItems, map[string]any{ - "CompatibilityMode": true, "Entity": "Mod.E"}}, - } { - t.Run(name, func(t *testing.T) { - out := buildOfflineConfigsBson(stored, []NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "All"}}) - if got := out[1].(bson.D).Map(); got["CompatibilityMode"] != true { - t.Errorf("carry failed for a stored config shaped as %s", name) - } - }) - } -} - -func TestLegacyOfflineWriteEmitsTheSamePropertiesAsModelsdk(t *testing.T) { - out := buildOfflineConfigsBson(bson.A{navMarkerItems}, - []NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "All"}}) - if out[0] != navMarkerItems { - t.Errorf("marker = %v, want %v", out[0], navMarkerItems) - } - got := out[1].(bson.D).Map() - for _, absent := range []string{"DownloadMode", "ShouldDownload"} { - if _, present := got[absent]; present { - t.Errorf("%s must not be written", absent) - } - } - if len(got) != 6 { - t.Errorf("wrote %d properties (%v), want $ID + $Type + the four Studio Pro writes", len(got), got) - } -} diff --git a/sdk/mpr/writer_odata.go b/sdk/mpr/writer_odata.go deleted file mode 100644 index 96e6fe1d5d..0000000000 --- a/sdk/mpr/writer_odata.go +++ /dev/null @@ -1,636 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Consumed OData Service (OData Client) — Rest$ConsumedODataService -// ============================================================================ - -// CreateConsumedODataService creates a new consumed OData service (client) document. -func (w *Writer) CreateConsumedODataService(svc *model.ConsumedODataService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "Rest$ConsumedODataService" - - contents, err := w.serializeConsumedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed OData service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "Rest$ConsumedODataService", contents) -} - -// UpdateConsumedODataService updates an existing consumed OData service. -func (w *Writer) UpdateConsumedODataService(svc *model.ConsumedODataService) error { - contents, err := w.serializeConsumedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed OData service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeleteConsumedODataService deletes a consumed OData service by ID. -func (w *Writer) DeleteConsumedODataService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeConsumedODataService converts a ConsumedODataService to BSON bytes. -func (w *Writer) serializeConsumedODataService(svc *model.ConsumedODataService) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "Rest$ConsumedODataService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Version", Value: svc.Version}, - {Key: "ServiceName", Value: svc.ServiceName}, - {Key: "ODataVersion", Value: svc.ODataVersion}, - {Key: "MetadataUrl", Value: svc.MetadataUrl}, - {Key: "TimeoutExpression", Value: svc.TimeoutExpression}, - {Key: "ProxyType", Value: svc.ProxyType}, - {Key: "Description", Value: svc.Description}, - {Key: "Validated", Value: svc.Validated}, - {Key: "Excluded", Value: svc.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Metadata", Value: svc.Metadata}, - {Key: "MetadataHash", Value: svc.MetadataHash}, - {Key: "MetadataReferences", Value: bson.A{int32(0)}}, // empty BSON array marker - {Key: "ValidatedEntities", Value: bson.A{int32(0)}}, // empty BSON array marker - {Key: "LastUpdated", Value: ""}, - {Key: "UseQuerySegment", Value: false}, - {Key: "MinimumMxVersion", Value: ""}, - {Key: "RecommendedMxVersion", Value: ""}, - } - - // Microflow reference (BY_NAME). Mendix renamed this storage field across - // versions: `ConfigurationMicroflow` (10.12–11.10) → `ConfigurationEntity- - // Microflow` (11.10+). Writing the wrong key makes Studio Pro ignore it and - // fall back to "Constants only" (issue #728), so gate on the project version. - configKey, headersKey := "ConfigurationMicroflow", "ConfigurationMicroflow" - if w.reader != nil { - if pv := w.reader.ProjectVersion(); pv != nil { - configKey = model.ODataConfigMicroflowBSONKey(pv.MajorVersion, pv.MinorVersion) - headersKey = model.ODataHeadersMicroflowBSONKey(pv.MajorVersion, pv.MinorVersion) - } - } - if svc.ConfigurationMicroflow != "" { - doc = append(doc, bson.E{Key: configKey, Value: svc.ConfigurationMicroflow}) - } - if svc.HeadersMicroflow != "" { - doc = append(doc, bson.E{Key: headersKey, Value: svc.HeadersMicroflow}) - } - if svc.ErrorHandlingMicroflow != "" { - doc = append(doc, bson.E{Key: "ErrorHandlingMicroflow", Value: svc.ErrorHandlingMicroflow}) - } - - // Proxy constant references (BY_NAME) - if svc.ProxyHost != "" { - doc = append(doc, bson.E{Key: "ProxyHost", Value: svc.ProxyHost}) - } - if svc.ProxyPort != "" { - doc = append(doc, bson.E{Key: "ProxyPort", Value: svc.ProxyPort}) - } - if svc.ProxyUsername != "" { - doc = append(doc, bson.E{Key: "ProxyUsername", Value: svc.ProxyUsername}) - } - if svc.ProxyPassword != "" { - doc = append(doc, bson.E{Key: "ProxyPassword", Value: svc.ProxyPassword}) - } - - // Mendix Catalog integration (optional) - if svc.ApplicationId != "" { - doc = append(doc, bson.E{Key: "ApplicationId", Value: svc.ApplicationId}) - } - if svc.EndpointId != "" { - doc = append(doc, bson.E{Key: "EndpointId", Value: svc.EndpointId}) - } - if svc.CatalogUrl != "" { - doc = append(doc, bson.E{Key: "CatalogUrl", Value: svc.CatalogUrl}) - } - if svc.EnvironmentType != "" { - doc = append(doc, bson.E{Key: "EnvironmentType", Value: svc.EnvironmentType}) - } - - // HTTP configuration (required nested part) - doc = append(doc, bson.E{Key: "HttpConfiguration", Value: serializeHttpConfiguration(svc.HttpConfiguration)}) - - return marshalUnitIDFirst(doc) -} - -// serializeHttpConfiguration converts an HttpConfiguration to a BSON map. -// If cfg is nil, a minimal default configuration is created. -func serializeHttpConfiguration(cfg *model.HttpConfiguration) bson.D { - cfgID := generateUUID() - if cfg != nil && cfg.ID != "" { - cfgID = string(cfg.ID) - } - - // Field defaults; overridden below when cfg is provided. These are - // resolved before building the ordered document so that providing a - // cfg replaces (rather than duplicates) the default entries. - useHttpAuthentication := false - httpAuthenticationUserName := "" - httpAuthenticationPassword := "" - httpMethod := "Post" - overrideLocation := false - customLocation := "" - clientCertificate := "" - var httpHeaderEntries bson.A - - if cfg != nil { - useHttpAuthentication = cfg.UseAuthentication - httpAuthenticationUserName = cfg.Username - httpAuthenticationPassword = cfg.Password - if cfg.HttpMethod != "" { - httpMethod = cfg.HttpMethod - } - overrideLocation = cfg.OverrideLocation - customLocation = cfg.CustomLocation - clientCertificate = cfg.ClientCertificate - - // Serialize header entries - if len(cfg.HeaderEntries) > 0 { - headers := bson.A{int32(3)} - for _, h := range cfg.HeaderEntries { - hID := string(h.ID) - if hID == "" { - hID = generateUUID() - } - headers = append(headers, bson.D{ - {Key: "$ID", Value: idToBsonBinary(hID)}, - {Key: "$Type", Value: "Microflows$HttpHeaderEntry"}, - {Key: "Key", Value: h.Key}, - {Key: "Value", Value: h.Value}, - }) - } - httpHeaderEntries = headers - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(cfgID)}, - {Key: "$Type", Value: "Microflows$HttpConfiguration"}, - {Key: "UseHttpAuthentication", Value: useHttpAuthentication}, - {Key: "HttpAuthenticationUserName", Value: httpAuthenticationUserName}, - {Key: "HttpAuthenticationPassword", Value: httpAuthenticationPassword}, - {Key: "HttpMethod", Value: httpMethod}, - {Key: "OverrideLocation", Value: overrideLocation}, - {Key: "CustomLocation", Value: customLocation}, - {Key: "ClientCertificate", Value: clientCertificate}, - } - - if httpHeaderEntries != nil { - doc = append(doc, bson.E{Key: "HttpHeaderEntries", Value: httpHeaderEntries}) - } - - return doc -} - -// ============================================================================ -// Published OData Service — ODataPublish$PublishedODataService2 -// ============================================================================ - -// CreatePublishedODataService creates a new published OData service document. -func (w *Writer) CreatePublishedODataService(svc *model.PublishedODataService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "ODataPublish$PublishedODataService2" - - contents, err := w.serializePublishedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published OData service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "ODataPublish$PublishedODataService2", contents) -} - -// UpdatePublishedODataService updates an existing published OData service. -func (w *Writer) UpdatePublishedODataService(svc *model.PublishedODataService) error { - contents, err := w.serializePublishedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published OData service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeletePublishedODataService deletes a published OData service by ID. -func (w *Writer) DeletePublishedODataService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializePublishedODataService converts a PublishedODataService to BSON bytes. -func (w *Writer) serializePublishedODataService(svc *model.PublishedODataService) ([]byte, error) { - // Authentication types array (versioned: starts with int32(3)) - authTypes := bson.A{int32(3)} - for _, at := range svc.AuthenticationTypes { - authTypes = append(authTypes, at) - } - - // AllowedModuleRoles: BY_NAME references, storage marker 1 — the same array - // shape the working GRANT path writes (makeMendixStringArray). - // - // This document is serialized wholesale and written with updateUnit, so a - // field the serializer omits is not left alone: it is deleted. Omitting it - // silently revoked a service's access on every `create or modify`, and the - // next build failed with "At least one allowed role must be selected for the - // published OData service to be accessible." Grants are made by a separate - // statement (`grant access on odata service …`) and cannot be re-stated in - // the create script, so nothing in the script could put them back - // (mxcli-formula1 §26). - // Published microflows — OData actions. Mendix turns each into an - // ActionImport in $metadata; without them a parameterised resource has to be - // modelled as an entity set echoing its own arguments back as columns - // (mxcli-formula1 §47). - publishedMicroflows := bson.A{int32(3)} - for _, pm := range svc.Microflows { - publishedMicroflows = append(publishedMicroflows, serializePublishedMicroflow(pm)) - } - - allowedRoles := bson.A{int32(1)} - for _, name := range svc.AllowedModuleRoles { - allowedRoles = append(allowedRoles, name) - } - - // Serialize entity types and build ID map for entity set pointers. - // Issue #595: key by qualified entity name (et.Entity), not ExposedName. - // PublishedEntitySet.EntityTypeName holds the qualified name, so keying - // by ExposedName made the lookup return "" and EntityTypePointer was - // never written. Studio Pro's EntitySet.Check then NREs dereferencing - // the missing pointer and aborts the whole project checker. - // - // Versioned BSON arrays in Mendix start with an int32 storage marker - // (typically 3). Without it Mendix treats the array as malformed and - // silently drops elements after the first — observed in CE6585 firing - // on the second entity in a multi-entity service. - entityTypeIDMap := make(map[string]string) // qualified entity name -> entity type ID - entityTypes := bson.A{int32(3)} - for _, et := range svc.EntityTypes { - etID := string(et.ID) - if etID == "" { - etID = generateUUID() - et.ID = model.ID(etID) - } - entityTypeIDMap[et.Entity] = etID - entityTypes = append(entityTypes, serializePublishedEntityType(et)) - } - - // Serialize entity sets with BY_ID pointers to entity types - entitySets := bson.A{int32(3)} - for _, es := range svc.EntitySets { - esID := string(es.ID) - if esID == "" { - esID = generateUUID() - es.ID = model.ID(esID) - } - // Resolve EntityTypeName to EntityType ID - entityTypeID := entityTypeIDMap[es.EntityTypeName] - entitySets = append(entitySets, serializePublishedEntitySet(es, entityTypeID)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "ODataPublish$PublishedODataService2"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Path", Value: svc.Path}, - {Key: "Namespace", Value: svc.Namespace}, - {Key: "ServiceName", Value: svc.ServiceName}, - {Key: "Version", Value: svc.Version}, - {Key: "ODataVersion", Value: svc.ODataVersion}, - {Key: "Summary", Value: svc.Summary}, - {Key: "Description", Value: svc.Description}, - {Key: "PublishAssociations", Value: svc.PublishAssociations}, - {Key: "SupportsGraphQL", Value: svc.SupportsGraphQL}, - {Key: "UseGeneralization", Value: svc.UseGeneralization}, - {Key: "AuthenticationMicroflow", Value: svc.AuthMicroflow}, - {Key: "AllowedModuleRoles", Value: allowedRoles}, - {Key: "AuthenticationTypes", Value: authTypes}, - {Key: "EntityTypes", Value: entityTypes}, - {Key: "EntitySets", Value: entitySets}, - {Key: "Excluded", Value: svc.Excluded}, - // Empty collection markers required by Studio Pro 11.10. Without - // these fields Mendix can resolve the first entity's key but fails - // to resolve the second's (CE6585) — observed when comparing a - // Studio Pro-authored multi-entity service against ours. - {Key: "Enumerations", Value: bson.A{int32(3)}}, - {Key: "Microflows", Value: publishedMicroflows}, - {Key: "IncludeMetadataByDefault", Value: true}, - {Key: "ReplaceIllegalChars", Value: false}, - {Key: "SupportsGraphQL", Value: false}, - } - return marshalUnitIDFirst(doc) -} - -// boolOrTrue resolves a tri-state query option: nil keeps Mendix's default of -// true, and only an explicit false turns the capability off. -func boolOrTrue(p *bool) bool { return p == nil || *p } - -// serializePublishedEntityType converts a PublishedEntityType to a BSON map. -func serializePublishedEntityType(et *model.PublishedEntityType) bson.D { - // Serialize child members. Pass the owning entity's qualified name so - // the writer can emit fully-qualified Attribute / Association BSON - // references (Module.Entity.AttributeName) — Studio Pro and mx check - // require these to be qualified, and using bare names made the second - // entity's members silently fail to link in a multi-entity service. - // Like EntityTypes / EntitySets, ChildMembers is a Mendix versioned - // array and must start with the int32(3) storage marker. - members := bson.A{int32(3)} - for _, m := range et.Members { - members = append(members, serializePublishedMember(m, et.Entity)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(et.ID))}, - {Key: "$Type", Value: "ODataPublish$EntityType"}, - {Key: "Entity", Value: et.Entity}, - {Key: "ExposedName", Value: et.ExposedName}, - {Key: "Summary", Value: et.Summary}, - {Key: "Description", Value: et.Description}, - {Key: "ChildMembers", Value: members}, - } -} - -// serializePublishedEntitySet converts a PublishedEntitySet to a BSON map. -func serializePublishedEntitySet(es *model.PublishedEntitySet, entityTypeID string) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(es.ID))}, - {Key: "$Type", Value: "ODataPublish$EntitySet"}, - {Key: "ExposedName", Value: es.ExposedName}, - {Key: "AlternativeExposedName", Value: ""}, - {Key: "UsePaging", Value: es.UsePaging}, - {Key: "PageSize", Value: int64(es.PageSize)}, - // QueryOptions is required by Studio Pro's BSON shape for the - // entity set to be considered valid. Without it the second - // published entity in a multi-entity service fails to resolve - // its key (CE6585) — see Studio Pro reference dump. - // nil means "not specified" and keeps Mendix's own default of true; only - // an explicit false turns one off. These were hardcoded true, so - // `publish entity … (TopSupported: No)` parsed, described back as No, and - // was published as Yes — mxcli asserting a capability the author had - // explicitly disowned. For a microflow-backed resource the claim is - // especially load-bearing: Mendix applies no query options itself, so the - // annotation is the only thing a client has to go on (mxcli-formula1 §20). - {Key: "QueryOptions", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "ODataPublish$QueryOptions"}, - {Key: "Countable", Value: boolOrTrue(es.Countable)}, - {Key: "SkipSupported", Value: boolOrTrue(es.SkipSupported)}, - {Key: "TopSupported", Value: boolOrTrue(es.TopSupported)}, - }}, - } - - // EntityTypePointer is a BY_ID reference - if entityTypeID != "" { - doc = append(doc, bson.E{Key: "EntityTypePointer", Value: idToBsonBinary(entityTypeID)}) - } - - // Serialize mode objects - if es.ReadMode != "" { - doc = append(doc, bson.E{Key: "ReadMode", Value: serializeReadMode(es.ReadMode)}) - } - if es.InsertMode != "" { - doc = append(doc, bson.E{Key: "InsertMode", Value: serializeChangeMode(es.InsertMode)}) - } - if es.UpdateMode != "" { - doc = append(doc, bson.E{Key: "UpdateMode", Value: serializeChangeMode(es.UpdateMode)}) - } - if es.DeleteMode != "" { - doc = append(doc, bson.E{Key: "DeleteMode", Value: serializeChangeMode(es.DeleteMode)}) - } - - return doc -} - -// serializePublishedMember converts a PublishedMember to a BSON map. -// `ownerQN` is the qualified name (Module.Entity) of the EntityType this -// member belongs to. Mendix expects PublishedAttribute.Attribute and -// PublishedAssociationEnd.Association BSON values to be fully qualified — -// "Module.Entity.AttributeName" for attributes and "Module.AssociationName" -// for associations. If the AST already supplied a qualified name (contains -// a dot), it's used as-is; otherwise the owner is prepended. -func serializePublishedMember(m *model.PublishedMember, ownerQN string) bson.D { - memberID := string(m.ID) - if memberID == "" { - memberID = generateUUID() - } - - // Base fields written by Studio Pro for both attribute and association - // members. Description/Summary stay empty in this writer; CanBeEmpty - // defaults to !IsPartOfKey (keys are required to have a value) which - // matches Studio Pro's convention and is required for Mendix to - // recognise the attribute as a valid OData key (CE6585). - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(memberID)}, - {Key: "ExposedName", Value: m.ExposedName}, - {Key: "CanBeEmpty", Value: !m.IsPartOfKey}, - {Key: "Description", Value: ""}, - {Key: "Summary", Value: ""}, - } - - switch m.Kind { - case "attribute": - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedAttribute"}) - doc = append(doc, bson.E{Key: "Attribute", Value: qualifyMemberName(m.Name, ownerQN)}) - // EdmType is the published OData type; without it Studio Pro reports - // CE5016 ("published as ."). Verified against Studio Pro's corrected BSON. - doc = append(doc, bson.E{Key: "EdmType", Value: m.EdmType}) - doc = append(doc, bson.E{Key: "Filterable", Value: m.Filterable}) - doc = append(doc, bson.E{Key: "Sortable", Value: m.Sortable}) - doc = append(doc, bson.E{Key: "IsPartOfKey", Value: m.IsPartOfKey}) - doc = append(doc, bson.E{Key: "EnumerationAsString", Value: m.EnumerationAsString}) - doc = append(doc, bson.E{Key: "StringAsGuid", Value: false}) - case "association": - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedAssociationEnd"}) - // Associations live at module scope (Module.AssocName), so prepend - // only the module portion of the owner. - doc = append(doc, bson.E{Key: "Association", Value: qualifyAssociationName(m.Name, ownerQN)}) - // AssociationEnd carries the target entity and a separate - // ExposedAssociationName (typically the bare assoc name). Both - // are required by Studio Pro's BSON shape. - doc = append(doc, bson.E{Key: "Entity", Value: m.AssociationTargetEntity}) - // IsMany is the exposed navigation's multiplicity; without it Studio Pro - // reports CE5022 ("changed multiplicity"). Verified against Studio Pro BSON. - doc = append(doc, bson.E{Key: "IsMany", Value: m.IsMany}) - doc = append(doc, bson.E{Key: "ExposedAssociationName", Value: m.ExposedAssociationName}) - case "id": - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedId"}) - doc = append(doc, bson.E{Key: "Attribute", Value: qualifyMemberName(m.Name, ownerQN)}) - doc = append(doc, bson.E{Key: "Filterable", Value: m.Filterable}) - doc = append(doc, bson.E{Key: "Sortable", Value: m.Sortable}) - doc = append(doc, bson.E{Key: "IsPartOfKey", Value: m.IsPartOfKey}) - default: - // Default to attribute for unknown kinds - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedAttribute"}) - doc = append(doc, bson.E{Key: "Attribute", Value: qualifyMemberName(m.Name, ownerQN)}) - doc = append(doc, bson.E{Key: "Filterable", Value: m.Filterable}) - doc = append(doc, bson.E{Key: "Sortable", Value: m.Sortable}) - doc = append(doc, bson.E{Key: "IsPartOfKey", Value: m.IsPartOfKey}) - doc = append(doc, bson.E{Key: "EnumerationAsString", Value: false}) - doc = append(doc, bson.E{Key: "StringAsGuid", Value: false}) - } - - return doc -} - -// qualifyMemberName prepends the owning entity's qualified name (Module.Entity) -// to a bare attribute name. If `name` already contains a dot (already qualified) -// or `ownerQN` is empty, the original is returned unchanged. -func qualifyMemberName(name, ownerQN string) string { - if name == "" || ownerQN == "" || strings.Contains(name, ".") { - return name - } - return ownerQN + "." + name -} - -// qualifyAssociationName prepends the owning entity's module to a bare -// association name. Associations live at module scope, so only the module -// portion of `ownerQN` is used. -func qualifyAssociationName(name, ownerQN string) string { - if name == "" || ownerQN == "" || strings.Contains(name, ".") { - return name - } - if idx := strings.IndexByte(ownerQN, '.'); idx > 0 { - return ownerQN[:idx] + "." + name - } - return name -} - -// serializeReadMode converts a read mode string to a BSON mode object. -// Accepts both parsed format ("ReadFromDatabase") and MDL format ("SOURCE"). -func serializeReadMode(mode string) bson.D { - modeID := idToBsonBinary(generateUUID()) - - switch { - case strings.EqualFold(mode, "ReadFromDatabase") || strings.EqualFold(mode, "SOURCE"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ReadSource"}, - } - case strings.HasPrefix(mode, "CallMicroflow:"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToRead"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "CallMicroflow:")}, - } - case strings.HasPrefix(mode, "MICROFLOW "): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToRead"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "MICROFLOW ")}, - } - default: - // Unknown mode — store as ReadSource - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ReadSource"}, - } - } -} - -// serializeChangeMode converts a change mode string to a BSON mode object. -// Accepts both parsed format ("ChangeFromDatabase", "NotSupported") and MDL format ("SOURCE", "NOT_SUPPORTED"). -func serializeChangeMode(mode string) bson.D { - modeID := idToBsonBinary(generateUUID()) - - switch { - case strings.EqualFold(mode, "ChangeFromDatabase") || strings.EqualFold(mode, "SOURCE"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ChangeSource"}, - } - case strings.EqualFold(mode, "NotSupported") || strings.EqualFold(mode, "NOT_SUPPORTED"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ChangeNotSupported"}, - } - case strings.HasPrefix(mode, "CallMicroflow:"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToChange"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "CallMicroflow:")}, - } - case strings.HasPrefix(mode, "MICROFLOW "): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToChange"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "MICROFLOW ")}, - } - default: - // Unknown mode — store as ChangeNotSupported - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ChangeNotSupported"}, - } - } -} - -// serializePublishedMicroflow serializes an ODataPublish$PublishedMicroflow. -// -// Property names come from the generated metamodel; the MicroflowParameter ref -// is Module.Microflow.Param, the shape the published-REST writer already ships. -// DataTypes$* elements are built by the same rules serializeMicroflowDataType -// uses for a microflow's own return type. -func serializePublishedMicroflow(pm *model.PublishedMicroflow) bson.D { - params := bson.A{int32(3)} - for _, p := range pm.Parameters { - params = append(params, serializePublishedMicroflowParameter(p)) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "ODataPublish$PublishedMicroflow"}, - {Key: "ExposedName", Value: pm.ExposedName}, - {Key: "AlternativeExposedName", Value: ""}, - {Key: "Microflow", Value: pm.Microflow}, - {Key: "Parameters", Value: params}, - {Key: "ReturnType", Value: serializeODataDataType(pm.ReturnTypeKind, pm.ReturnTypeRef)}, - {Key: "Summary", Value: pm.Summary}, - {Key: "Description", Value: pm.Description}, - } -} - -func serializePublishedMicroflowParameter(p *model.PublishedMicroflowParameter) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "ODataPublish$PublishedMicroflowParameter"}, - {Key: "ExposedName", Value: p.ExposedName}, - {Key: "MicroflowParameter", Value: p.MicroflowParameter}, - {Key: "DataType", Value: serializeODataDataType(p.DataTypeKind, p.DataTypeRef)}, - {Key: "CanBeEmpty", Value: p.CanBeEmpty}, - {Key: "Summary", Value: p.Summary}, - {Key: "Description", Value: p.Description}, - } -} - -// serializeODataDataType builds a DataTypes$* element from a kind and, for the -// three kinds that name something, its qualified name. Object/List carry -// `Entity`, Enumeration carries `Enumeration` — verified against -// serializeMicroflowDataType, which writes the same family for microflow return -// types and is already proven in the build. -func serializeODataDataType(kind, ref string) interface{} { - if kind == "" { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$" + kind + "Type"}, - } - switch kind { - case "Object", "List": - return append(doc, bson.E{Key: "Entity", Value: ref}) - case "Enumeration": - return append(doc, bson.E{Key: "Enumeration", Value: ref}) - } - return doc -} diff --git a/sdk/mpr/writer_odata_test.go b/sdk/mpr/writer_odata_test.go deleted file mode 100644 index 8d2b3ea112..0000000000 --- a/sdk/mpr/writer_odata_test.go +++ /dev/null @@ -1,528 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func TestSerializeConsumedODataService(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedODataService{ - BaseElement: model.BaseElement{ - ID: "test-consumed-id", - TypeName: "Rest$ConsumedODataService", - }, - ContainerID: "test-module-id", - Name: "SalesforceAPI", - Documentation: "Connects to Salesforce", - Version: "1.0", - ODataVersion: "OData4", - MetadataUrl: "https://api.salesforce.com/odata/v4/$metadata", - TimeoutExpression: "300", - ProxyType: "DefaultProxy", - Description: "Salesforce OData API", - Validated: true, - } - - data, err := w.serializeConsumedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - // Deserialize and verify fields - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - assertField(t, raw, "$Type", "Rest$ConsumedODataService") - assertField(t, raw, "Name", "SalesforceAPI") - assertField(t, raw, "Documentation", "Connects to Salesforce") - assertField(t, raw, "Version", "1.0") - assertField(t, raw, "ODataVersion", "OData4") - assertField(t, raw, "MetadataUrl", "https://api.salesforce.com/odata/v4/$metadata") - assertField(t, raw, "TimeoutExpression", "300") - assertField(t, raw, "ProxyType", "DefaultProxy") - assertField(t, raw, "Description", "Salesforce OData API") - - if v, ok := raw["Validated"].(bool); !ok || !v { - t.Errorf("Validated: expected true, got %v", raw["Validated"]) - } -} - -func TestSerializeConsumedODataServiceWithHttpConfig(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedODataService{ - BaseElement: model.BaseElement{ - ID: "test-consumed-full-id", - TypeName: "Rest$ConsumedODataService", - }, - ContainerID: "test-module-id", - Name: "FullAPI", - ODataVersion: "OData4", - MetadataUrl: "https://api.example.com/odata/$metadata", - TimeoutExpression: "300", - ConfigurationMicroflow: "MyModule.ConfigureMF", - ErrorHandlingMicroflow: "MyModule.HandleErrorMF", - ProxyHost: "MyModule.ProxyHostConst", - HttpConfiguration: &model.HttpConfiguration{ - BaseElement: model.BaseElement{ - ID: "test-http-cfg-id", - TypeName: "Microflows$HttpConfiguration", - }, - UseAuthentication: true, - Username: "'admin'", - Password: "'secret'", - HttpMethod: "Get", - OverrideLocation: true, - CustomLocation: "'https://api.example.com/odata'", - ClientCertificate: "my-cert", - HeaderEntries: []*model.HttpHeaderEntry{ - { - BaseElement: model.BaseElement{ID: "header-1"}, - Key: "X-Api-Key", - Value: "'abc123'", - }, - }, - }, - } - - data, err := w.serializeConsumedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Microflow reference. Studio Pro stores both the "Configuration - // microflow" and "Headers microflow" dropdown options in the single - // `ConfigurationMicroflow` BSON field — it picks the dropdown label - // from the microflow's return type, not from which field carries the - // reference. Older mxcli fixes tried `ConfigurationEntityMicroflow` / - // `HeaderListMicroflow` / a separate `HeadersMicroflow`; Studio Pro - // silently ignores all three, leaving the dropdown stuck on - // "Constants only". - assertField(t, raw, "ConfigurationMicroflow", "MyModule.ConfigureMF") - assertField(t, raw, "ErrorHandlingMicroflow", "MyModule.HandleErrorMF") - assertField(t, raw, "ProxyHost", "MyModule.ProxyHostConst") - if _, exists := raw["HeadersMicroflow"]; exists { - t.Errorf("HeadersMicroflow leaked into BSON — Studio Pro stores both microflow dropdown options under ConfigurationMicroflow") - } - - // HTTP Configuration - httpCfg, ok := raw["HttpConfiguration"].(map[string]any) - if !ok { - t.Fatalf("HttpConfiguration: expected map, got %T", raw["HttpConfiguration"]) - } - assertField(t, httpCfg, "$Type", "Microflows$HttpConfiguration") - - if v, ok := httpCfg["UseHttpAuthentication"].(bool); !ok || !v { - t.Errorf("UseHttpAuthentication: expected true, got %v", httpCfg["UseHttpAuthentication"]) - } - assertField(t, httpCfg, "HttpAuthenticationUserName", "'admin'") - assertField(t, httpCfg, "HttpAuthenticationPassword", "'secret'") - assertField(t, httpCfg, "HttpMethod", "Get") - assertField(t, httpCfg, "CustomLocation", "'https://api.example.com/odata'") - assertField(t, httpCfg, "ClientCertificate", "my-cert") - - if v, ok := httpCfg["OverrideLocation"].(bool); !ok || !v { - t.Errorf("OverrideLocation: expected true, got %v", httpCfg["OverrideLocation"]) - } - - // Header entries - headers := extractBsonArray(httpCfg["HttpHeaderEntries"]) - if len(headers) != 1 { - t.Fatalf("HttpHeaderEntries: expected 1, got %d", len(headers)) - } - h0, ok := headers[0].(map[string]any) - if !ok { - t.Fatalf("HttpHeaderEntries[0]: expected map, got %T", headers[0]) - } - assertField(t, h0, "Key", "X-Api-Key") - assertField(t, h0, "Value", "'abc123'") -} - -func TestSerializePublishedODataService(t *testing.T) { - w := &Writer{} - svc := &model.PublishedODataService{ - BaseElement: model.BaseElement{ - ID: "test-published-id", - TypeName: "ODataPublish$PublishedODataService2", - }, - ContainerID: "test-module-id", - Name: "CustomerAPI", - Path: "/odata/customers", - Version: "1.0.0", - ODataVersion: "OData4", - Namespace: "MyApp.Customers", - ServiceName: "Customer Service", - Summary: "API for customers", - PublishAssociations: true, - AuthenticationTypes: []string{"Basic", "Session"}, - EntityTypes: []*model.PublishedEntityType{ - { - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Entity: "MyModule.Customer", - ExposedName: "Customers", - Members: []*model.PublishedMember{ - { - BaseElement: model.BaseElement{ID: "m-1"}, - Kind: "attribute", - Name: "Name", - ExposedName: "CustomerName", - Filterable: true, - Sortable: true, - }, - { - BaseElement: model.BaseElement{ID: "m-2"}, - Kind: "id", - Name: "ID", - ExposedName: "Id", - IsPartOfKey: true, - }, - }, - }, - }, - EntitySets: []*model.PublishedEntitySet{ - { - BaseElement: model.BaseElement{ID: "es-1"}, - ExposedName: "Customers", - EntityTypeName: "MyModule.Customer", - ReadMode: "ReadFromDatabase", - InsertMode: "ChangeFromDatabase", - UpdateMode: "ChangeFromDatabase", - DeleteMode: "NotSupported", - UsePaging: true, - PageSize: 100, - }, - }, - } - - data, err := w.serializePublishedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Top-level fields - assertField(t, raw, "$Type", "ODataPublish$PublishedODataService2") - assertField(t, raw, "Name", "CustomerAPI") - assertField(t, raw, "Path", "/odata/customers") - assertField(t, raw, "Version", "1.0.0") - assertField(t, raw, "ODataVersion", "OData4") - assertField(t, raw, "Namespace", "MyApp.Customers") - assertField(t, raw, "ServiceName", "Customer Service") - - if v, ok := raw["PublishAssociations"].(bool); !ok || !v { - t.Errorf("PublishAssociations: expected true, got %v", raw["PublishAssociations"]) - } - - // Authentication types (versioned array: [int32(3), "Basic", "Session"]) - authArr := extractBsonArray(raw["AuthenticationTypes"]) - if len(authArr) != 2 { - t.Errorf("AuthenticationTypes: expected 2 items, got %d", len(authArr)) - } - if len(authArr) >= 2 { - if authArr[0] != "Basic" { - t.Errorf("AuthenticationTypes[0]: expected Basic, got %v", authArr[0]) - } - if authArr[1] != "Session" { - t.Errorf("AuthenticationTypes[1]: expected Session, got %v", authArr[1]) - } - } - - // Entity types array - entityTypes := extractBsonArray(raw["EntityTypes"]) - if len(entityTypes) != 1 { - t.Fatalf("EntityTypes: expected 1, got %d", len(entityTypes)) - } - etMap, ok := entityTypes[0].(map[string]any) - if !ok { - t.Fatalf("EntityTypes[0]: expected map, got %T", entityTypes[0]) - } - assertField(t, etMap, "$Type", "ODataPublish$EntityType") - assertField(t, etMap, "Entity", "MyModule.Customer") - assertField(t, etMap, "ExposedName", "Customers") - - // Child members - members := extractBsonArray(etMap["ChildMembers"]) - if len(members) != 2 { - t.Fatalf("ChildMembers: expected 2, got %d", len(members)) - } - m0, ok := members[0].(map[string]any) - if !ok { - t.Fatalf("ChildMembers[0]: expected map, got %T", members[0]) - } - assertField(t, m0, "$Type", "ODataPublish$PublishedAttribute") - // Attribute is the fully-qualified Module.Entity.AttributeName — Studio - // Pro requires qualified references, and using bare names made the - // second entity in a multi-entity service silently fail to resolve. - assertField(t, m0, "Attribute", "MyModule.Customer.Name") - assertField(t, m0, "ExposedName", "CustomerName") - if v, ok := m0["Filterable"].(bool); !ok || !v { - t.Errorf("Member Filterable: expected true, got %v", m0["Filterable"]) - } - - m1, ok := members[1].(map[string]any) - if !ok { - t.Fatalf("ChildMembers[1]: expected map, got %T", members[1]) - } - assertField(t, m1, "$Type", "ODataPublish$PublishedId") - if v, ok := m1["IsPartOfKey"].(bool); !ok || !v { - t.Errorf("Member IsPartOfKey: expected true, got %v", m1["IsPartOfKey"]) - } - - // Entity sets - entitySets := extractBsonArray(raw["EntitySets"]) - if len(entitySets) != 1 { - t.Fatalf("EntitySets: expected 1, got %d", len(entitySets)) - } - esMap, ok := entitySets[0].(map[string]any) - if !ok { - t.Fatalf("EntitySets[0]: expected map, got %T", entitySets[0]) - } - assertField(t, esMap, "$Type", "ODataPublish$EntitySet") - assertField(t, esMap, "ExposedName", "Customers") - - // Issue #595: EntityTypePointer must reference the owning EntityType. - // Without it, Studio Pro's EntitySet.Check NREs (it can't navigate from - // the set to its type). The map lookup in serializePublishedODataService - // was previously keyed by ExposedName instead of the qualified entity - // name, so the resolved ID was always empty and the pointer was omitted. - etID := etMap["$ID"].(primitive.Binary) - esPointer, ok := esMap["EntityTypePointer"].(primitive.Binary) - if !ok { - t.Fatalf("EntityTypePointer: expected primitive.Binary, got %T (%v)", esMap["EntityTypePointer"], esMap["EntityTypePointer"]) - } - if string(esPointer.Data) != string(etID.Data) { - t.Errorf("EntityTypePointer = %x, want %x (entity type $ID)", esPointer.Data, etID.Data) - } - - if v, ok := esMap["UsePaging"].(bool); !ok || !v { - t.Errorf("UsePaging: expected true, got %v", esMap["UsePaging"]) - } - - // Mode objects - readMode, ok := esMap["ReadMode"].(map[string]any) - if !ok { - t.Fatalf("ReadMode: expected map, got %T", esMap["ReadMode"]) - } - assertField(t, readMode, "$Type", "ODataPublish$ReadSource") - - deleteMode, ok := esMap["DeleteMode"].(map[string]any) - if !ok { - t.Fatalf("DeleteMode: expected map, got %T", esMap["DeleteMode"]) - } - assertField(t, deleteMode, "$Type", "ODataPublish$ChangeNotSupported") -} - -func TestSerializeModeRoundTrip(t *testing.T) { - tests := []struct { - name string - mode string - isRead bool - expected string - }{ - {"ReadSource", "ReadFromDatabase", true, "ODataPublish$ReadSource"}, - {"ReadSourceMDL", "SOURCE", true, "ODataPublish$ReadSource"}, - {"ChangeSource", "ChangeFromDatabase", false, "ODataPublish$ChangeSource"}, - {"ChangeSourceMDL", "SOURCE", false, "ODataPublish$ChangeSource"}, - {"NotSupported", "NotSupported", false, "ODataPublish$ChangeNotSupported"}, - {"NotSupportedMDL", "NOT_SUPPORTED", false, "ODataPublish$ChangeNotSupported"}, - {"CallMicroflowRead", "CallMicroflow:MyModule.ReadMF", true, "ODataPublish$CallMicroflowToRead"}, - {"CallMicroflowChange", "CallMicroflow:MyModule.WriteMF", false, "ODataPublish$CallMicroflowToChange"}, - {"MicroflowMDLRead", "MICROFLOW MyModule.ReadMF", true, "ODataPublish$CallMicroflowToRead"}, - {"MicroflowMDLChange", "MICROFLOW MyModule.WriteMF", false, "ODataPublish$CallMicroflowToChange"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var result bson.M - if tc.isRead { - result = dToM(serializeReadMode(tc.mode)) - } else { - result = dToM(serializeChangeMode(tc.mode)) - } - - typeName, ok := result["$Type"].(string) - if !ok { - t.Fatalf("$Type: expected string, got %T", result["$Type"]) - } - if typeName != tc.expected { - t.Errorf("$Type: expected %s, got %s", tc.expected, typeName) - } - - // Verify $ID is present - if _, ok := result["$ID"]; !ok { - t.Error("$ID: expected to be present") - } - }) - } -} - -// assertField checks a string field in a BSON map. -func assertField(t *testing.T, m map[string]any, key, expected string) { - t.Helper() - val, ok := m[key] - if !ok { - t.Errorf("field %q: missing", key) - return - } - s, ok := val.(string) - if !ok { - t.Errorf("field %q: expected string, got %T", key, val) - return - } - if s != expected { - t.Errorf("field %q: expected %q, got %q", key, expected, s) - } -} - -// mxcli-formula1 §26: `create or modify odata service` silently revoked the -// service's access, and the next build failed with "At least one allowed role -// must be selected for the published OData service to be accessible." -// -// The grants were read correctly and carried through the executor — and then -// dropped here. This document is serialized wholesale and written with -// updateUnit, so a field the serializer omits is not left alone, it is deleted. -// Because grants are made by a separate statement (`grant access on odata -// service …`) and cannot be re-stated in the create script, nothing in the -// script could put them back. -func TestSerializePublishedODataService_KeepsAllowedModuleRoles(t *testing.T) { - w := &Writer{} - svc := &model.PublishedODataService{ - BaseElement: model.BaseElement{ID: "svc-roles"}, - Name: "CustomerAPI", - ServiceName: "CustomerAPI", - AllowedModuleRoles: []string{"MyModule.User", "MyModule.Admin"}, - } - - data, err := w.serializePublishedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Storage marker 1 (BY_NAME references) — the same array shape the working - // GRANT path writes via makeMendixStringArray, and extractBsonArray only - // strips markers 2 and 3, so the marker is still element 0 here. - roles := extractBsonArray(raw["AllowedModuleRoles"]) - if len(roles) != 3 { - t.Fatalf("AllowedModuleRoles: expected marker + 2 grants, got %v — the service is now inaccessible and the build fails", roles) - } - if m, _ := roles[0].(int32); m != 1 { - t.Errorf("storage marker = %v, want 1 (BY_NAME)", roles[0]) - } - for i, want := range []string{"MyModule.User", "MyModule.Admin"} { - if got, _ := roles[i+1].(string); got != want { - t.Errorf("role %d = %q, want %q", i, got, want) - } - } -} - -// A service with no grants must still carry the field, as an empty versioned -// array — the absence of the key and an empty list are different documents. -func TestSerializePublishedODataService_EmptyRolesStillWritesTheField(t *testing.T) { - w := &Writer{} - data, err := w.serializePublishedODataService(&model.PublishedODataService{ - BaseElement: model.BaseElement{ID: "svc-noroles"}, - Name: "Bare", - }) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if _, present := raw["AllowedModuleRoles"]; !present { - t.Error("AllowedModuleRoles must be present even when empty") - } - if arr := extractBsonArray(raw["AllowedModuleRoles"]); len(arr) != 1 { - t.Errorf("expected the bare marker and no grants, got %v", arr) - } -} - -// The query-option annotations were hardcoded true, so `publish entity … -// (TopSupported: No)` parsed, described back as No, and published as Yes. -// -// For a microflow-backed resource this claim is load-bearing rather than -// decorative: Mendix applies no query options itself, so the annotation is the -// only thing a client has to go on — and a client that believes $top works, when -// nothing implements it, silently reads a whole collection as though it were a -// page (mxcli-formula1 §20). -func TestSerializePublishedODataService_HonoursQueryOptionOptOut(t *testing.T) { - no := false - yes := true - w := &Writer{} - svc := &model.PublishedODataService{ - BaseElement: model.BaseElement{ID: "svc-qo"}, - Name: "LiveAPI", - EntitySets: []*model.PublishedEntitySet{ - { - BaseElement: model.BaseElement{ID: "es-off"}, - ExposedName: "Drivers", - EntityTypeName: "M.Driver", - Countable: &no, - SkipSupported: &no, - TopSupported: &no, - }, - { - BaseElement: model.BaseElement{ID: "es-mixed"}, - ExposedName: "Races", - EntityTypeName: "M.Race", - Countable: &yes, - // SkipSupported/TopSupported unspecified: Mendix's default of true. - }, - }, - } - - data, err := w.serializePublishedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - sets := extractBsonArray(raw["EntitySets"]) - if len(sets) != 2 { - t.Fatalf("expected 2 entity sets, got %d", len(sets)) - } - opts := func(i int) map[string]any { - set, _ := sets[i].(map[string]any) - qo, _ := set["QueryOptions"].(map[string]any) - if qo == nil { - t.Fatalf("entity set %d has no QueryOptions", i) - } - return qo - } - - for _, key := range []string{"Countable", "SkipSupported", "TopSupported"} { - if v, _ := opts(0)[key].(bool); v { - t.Errorf("Drivers.%s = true, but the author said No — mxcli is advertising a capability nothing implements", key) - } - } - // Unspecified must still mean Mendix's default, not false. - for _, key := range []string{"Countable", "SkipSupported", "TopSupported"} { - if v, _ := opts(1)[key].(bool); !v { - t.Errorf("Races.%s = false; unspecified must keep Mendix's default of true", key) - } - } -} diff --git a/sdk/mpr/writer_order.go b/sdk/mpr/writer_order.go deleted file mode 100644 index c337ec10c6..0000000000 --- a/sdk/mpr/writer_order.go +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/bsonutil" - - "go.mongodb.org/mongo-driver/bson" -) - -// marshalUnitIDFirst normalizes a unit document so every nested storage object -// leads with "$ID" (and "$Type" second), then marshals it — the 11.12-safe -// replacement for a bare bson.Marshal(doc) at a unit-serialization boundary. -// -// Mendix 11.12+ rejects any storage object whose first BSON property is not -// "$ID". Several legacy writers preserve round-trip fidelity by carrying parsed -// subtrees as Go maps and marshalling them back, but bson.Marshal emits map keys -// in random order — so "$ID" only lands first by luck. bsonutil.HoistStorageID -// lifts "$ID"/"$Type" to the front while preserving the original order of every -// other key (a blind sort corrupts template-derived pluggable-widget trees). -func marshalUnitIDFirst(doc any) ([]byte, error) { - return bson.Marshal(bsonutil.HoistStorageID(doc)) -} diff --git a/sdk/mpr/writer_pages.go b/sdk/mpr/writer_pages.go deleted file mode 100644 index b04abdd02d..0000000000 --- a/sdk/mpr/writer_pages.go +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "sort" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreatePage creates a new page. -func (w *Writer) CreatePage(page *pages.Page) error { - if page.ID == "" { - page.ID = model.ID(generateUUID()) - } - page.TypeName = "Forms$Page" - - contents, err := w.serializePage(page) - if err != nil { - return fmt.Errorf("failed to serialize page: %w", err) - } - - return w.insertUnit(string(page.ID), string(page.ContainerID), "Documents", "Forms$Page", contents) -} - -// UpdatePage updates an existing page. -func (w *Writer) UpdatePage(page *pages.Page) error { - contents, err := w.serializePage(page) - if err != nil { - return fmt.Errorf("failed to serialize page: %w", err) - } - - return w.updateUnit(string(page.ID), contents) -} - -// DeletePage deletes a page. -func (w *Writer) DeletePage(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MovePage moves a page to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MovePage(page *pages.Page) error { - return w.moveUnitByID(string(page.ID), string(page.ContainerID)) -} - -// errLayoutAuthoringIsModelsdkOnly is returned by both legacy layout writers. -// -// The legacy serializer wrote four top-level keys — a string $ID (Studio Pro -// stores binary), Name, Documentation, and a LayoutType on the layout element, -// which is not where Mendix keeps it. There was no Content wrapper at all, so -// the widget tree had nowhere to go. Nothing ever called it: CREATE LAYOUT did -// not exist until the modelsdk codec could produce the real document. Refusing -// is the honest outcome — the alternative is a unit `mx check` may well accept -// and Studio Pro cannot render. -var errLayoutAuthoringIsModelsdkOnly = fmt.Errorf( - "authoring layouts needs the modelsdk engine (MXCLI_ENGINE=modelsdk); the legacy writer cannot produce a layout's Content wrapper") - -// CreateLayout is refused on the legacy engine. See -// errLayoutAuthoringIsModelsdkOnly. -func (w *Writer) CreateLayout(_ *pages.Layout) error { - return errLayoutAuthoringIsModelsdkOnly -} - -// UpdateLayout is refused on the legacy engine. See -// errLayoutAuthoringIsModelsdkOnly. -func (w *Writer) UpdateLayout(_ *pages.Layout) error { - return errLayoutAuthoringIsModelsdkOnly -} - -// DeleteLayout deletes a layout. -func (w *Writer) DeleteLayout(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// CreateSnippet creates a new snippet. -func (w *Writer) CreateSnippet(snippet *pages.Snippet) error { - if snippet.ID == "" { - snippet.ID = model.ID(generateUUID()) - } - snippet.TypeName = "Forms$Snippet" - - contents, err := w.serializeSnippet(snippet) - if err != nil { - return fmt.Errorf("failed to serialize snippet: %w", err) - } - - return w.insertUnit(string(snippet.ID), string(snippet.ContainerID), "Documents", "Forms$Snippet", contents) -} - -// DeleteSnippet deletes a snippet. -func (w *Writer) DeleteSnippet(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// UpdateSnippet updates an existing snippet. -func (w *Writer) UpdateSnippet(snippet *pages.Snippet) error { - contents, err := w.serializeSnippet(snippet) - if err != nil { - return fmt.Errorf("failed to serialize snippet: %w", err) - } - - return w.updateUnit(string(snippet.ID), contents) -} - -// MoveSnippet moves a snippet to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MoveSnippet(snippet *pages.Snippet) error { - return w.moveUnitByID(string(snippet.ID), string(snippet.ContainerID)) -} - -// popupDimension returns the pop-up width/height as the int64 BSON value Studio -// Pro uses. Studio Pro's own default is 0 (auto-size), so 0 is a valid value and -// is written through verbatim (issue #713); only a stray negative is clamped to 0. -func popupDimension(n int) int64 { - if n < 0 { - return 0 - } - return int64(n) -} - -func (w *Writer) serializePage(page *pages.Page) ([]byte, error) { - // Build document with Mendix 10+ format (Forms$Page) - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(page.ID))}, - {Key: "$Type", Value: "Forms$Page"}, - {Key: "AllowedModuleRoles", Value: allowedModuleRolesArray(page.AllowedRoles)}, - {Key: "Appearance", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$Appearance"}, - {Key: "Class", Value: page.Class}, - {Key: "DesignProperties", Value: bson.A{int32(3)}}, - {Key: "DynamicClasses", Value: ""}, - {Key: "Style", Value: page.Style}, - }}, - {Key: "Autofocus", Value: "DesktopOnly"}, - {Key: "CanvasHeight", Value: int64(600)}, - {Key: "CanvasWidth", Value: int64(1200)}, - {Key: "Documentation", Value: page.Documentation}, - {Key: "Excluded", Value: page.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - } - - // Add FormCall (LayoutCall) if present - if page.LayoutCall != nil { - // Build arguments array - // Format: [3] for empty, [2, {arg1}, {arg2}...] for non-empty - // Each argument is a bson.D document - args := bson.A{int32(3)} // Start with empty marker - hasItems := false - for _, arg := range page.LayoutCall.Arguments { - // Parameter uses a qualified name string (e.g., "Atlas_Core.Atlas_TopBar.Main") - // not a binary ID - argDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(arg.ID))}, - {Key: "$Type", Value: "Forms$FormCallArgument"}, - {Key: "Parameter", Value: string(arg.ParameterID)}, // Qualified name string - } - // Add widgets if present - if len(arg.Widgets) > 0 { - argDoc = append(argDoc, bson.E{Key: "Widgets", Value: serializeWidgetArray(arg.Widgets)}) - } else { - argDoc = append(argDoc, bson.E{Key: "Widgets", Value: bson.A{int32(3)}}) - } - if !hasItems { - // First item: change version marker from 3 to 2 - args = bson.A{int32(2)} - hasItems = true - } - // Append the argument document directly - args = append(args, argDoc) - } - - formCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(page.LayoutCall.ID))}, - {Key: "$Type", Value: "Forms$LayoutCall"}, - {Key: "Arguments", Value: args}, - {Key: "Form", Value: page.LayoutCall.LayoutName}, // Qualified name string, not binary ID - } - doc = append(doc, bson.E{Key: "FormCall", Value: formCall}) - } - - doc = append(doc, bson.E{Key: "MarkAsUsed", Value: page.MarkAsUsed}) - doc = append(doc, bson.E{Key: "Name", Value: page.Name}) - - // Add Parameters array - // Format: [3] for empty, [3, {param1}, {param2}...] for non-empty - // Each parameter is a bson.D document (which serializes as array of key-value pairs) - params := bson.A{int32(3)} // Start with version marker - for _, p := range page.Parameters { - paramID := string(p.ID) - if paramID == "" { - paramID = generateUUID() - } - - // Build ParameterType — entity params use DataTypes$ObjectType, - // primitive params use DataTypes$StringType, DataTypes$IntegerType, etc. - paramTypeID := generateUUID() - var paramType bson.D - if p.TypeName != "" { - // Primitive type parameter - paramType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramTypeID)}, - {Key: "$Type", Value: p.TypeName}, - } - } else { - // Entity type parameter (default) - paramType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramTypeID)}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: p.EntityName}, - } - } - - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramID)}, - {Key: "$Type", Value: "Forms$PageParameter"}, - {Key: "DefaultValue", Value: p.DefaultValue}, - {Key: "IsRequired", Value: p.IsRequired}, - {Key: "Name", Value: p.Name}, - {Key: "ParameterType", Value: paramType}, - } - // Append the parameter document directly (bson.D serializes as array of {Key, Value}) - params = append(params, paramDoc) - } - doc = append(doc, bson.E{Key: "Parameters", Value: params}) - - doc = append(doc, bson.E{Key: "PopupCloseAction", Value: ""}) - doc = append(doc, bson.E{Key: "PopupHeight", Value: popupDimension(page.PopupHeight)}) - doc = append(doc, bson.E{Key: "PopupResizable", Value: page.PopupResizable}) - doc = append(doc, bson.E{Key: "PopupWidth", Value: popupDimension(page.PopupWidth)}) - - // Add Title - // Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays - // Items go directly after the version marker, NOT nested in another array - if page.Title != nil { - titleItems := bson.A{int32(3)} // Start with empty marker - if len(page.Title.Translations) > 0 { - titleItems = bson.A{int32(2)} // version 2 for non-empty - langs := make([]string, 0, len(page.Title.Translations)) - for lang := range page.Title.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, langCode := range langs { - titleItems = append(titleItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: langCode}, - {Key: "Text", Value: page.Title.Translations[langCode]}, - }) - } - } - titleDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(page.Title.ID))}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: titleItems}, - } - doc = append(doc, bson.E{Key: "Title", Value: titleDoc}) - } else { - // Empty title - doc = append(doc, bson.E{Key: "Title", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}) - } - - doc = append(doc, bson.E{Key: "Url", Value: page.URL}) - doc = append(doc, bson.E{Key: "Variables", Value: serializeLocalVariables(page.Variables)}) - - return bson.Marshal(doc) -} - -func (w *Writer) serializeSnippet(snippet *pages.Snippet) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(snippet.ID))}, - {Key: "$Type", Value: "Forms$Snippet"}, - {Key: "CanvasHeight", Value: int64(600)}, - {Key: "CanvasWidth", Value: int64(800)}, - {Key: "Documentation", Value: snippet.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Name", Value: snippet.Name}, - } - - // Add parameters - params := bson.A{int32(3)} // Version prefix - for _, param := range snippet.Parameters { - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(param.ID))}, - {Key: "$Type", Value: "Forms$SnippetParameter"}, - {Key: "Name", Value: param.Name}, - } - if param.EntityName != "" { - paramDoc = append(paramDoc, bson.E{Key: "ParameterType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: param.EntityName}, - }}) - } - params = append(params, paramDoc) - } - doc = append(doc, bson.E{Key: "Parameters", Value: params}) - - // Add fields to match Studio Pro format - doc = append(doc, bson.E{Key: "Type", Value: ""}) - doc = append(doc, bson.E{Key: "Variables", Value: serializeLocalVariables(snippet.Variables)}) - doc = append(doc, bson.E{Key: "Excluded", Value: false}) - - // Use "Widgets" (plural) array, matching Studio Pro format - doc = append(doc, bson.E{Key: "Widgets", Value: serializeWidgetArray(snippet.Widgets)}) - - return bson.Marshal(doc) -} - -// serializeLocalVariables serializes page/snippet local variables to BSON array format. -// Returns [3] for empty, [3, {var1}, {var2}...] for non-empty. -func serializeLocalVariables(vars []*pages.LocalVariable) bson.A { - result := bson.A{int32(3)} // Version marker - for _, v := range vars { - varID := string(v.ID) - if varID == "" { - varID = generateUUID() - } - - varTypeID := generateUUID() - varType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(varTypeID)}, - {Key: "$Type", Value: v.VariableType}, - } - // For ObjectType, include the Entity field - if v.VariableType == "DataTypes$ObjectType" { - varType = append(varType, bson.E{Key: "Entity", Value: v.Name}) - } - // An enumeration points at one by name. Without this the type was written - // with nothing to resolve, so it was flattened to a String instead (#977). - if v.VariableType == "DataTypes$EnumerationType" && v.EnumerationRef != "" { - varType = append(varType, bson.E{Key: "Enumeration", Value: v.EnumerationRef}) - } - - varDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(varID)}, - {Key: "$Type", Value: "Forms$LocalVariable"}, - {Key: "DefaultValue", Value: v.DefaultValue}, - {Key: "Name", Value: v.Name}, - {Key: "VariableType", Value: varType}, - } - result = append(result, varDoc) - } - return result -} diff --git a/sdk/mpr/writer_pages_placeholder_test.go b/sdk/mpr/writer_pages_placeholder_test.go deleted file mode 100644 index 07e903058f..0000000000 --- a/sdk/mpr/writer_pages_placeholder_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// mendixlabs/mxcli#760: every mxcli-authored page gained a container nobody asked -// for. The builder wrapped each non-empty layout placeholder in a synthetic -// Forms$DivContainer named "conditionalVisibilityWidget", so creating a single -// button produced a button *and* a container. -// -// The wrapper was never a BSON requirement. Forms$FormCallArgument carries a -// `Widgets` array and a Studio Pro page fills it with its top-level widgets -// directly — verified against Mendix's own output: Administration.Account_Overview in -// a `mx create-project` app has two top-level widgets in one placeholder and zero -// wrappers. The wrapper existed only because pages.LayoutCallArgument declared a -// single `Widget` field. -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func placeholderArg(widgets ...pages.Widget) *pages.Page { - return &pages.Page{ - BaseElement: model.BaseElement{ID: "page1"}, - Name: "P", - LayoutCall: &pages.LayoutCall{ - BaseElement: model.BaseElement{ID: "lc1"}, - LayoutName: "Atlas_Core.Atlas_Default", - Arguments: []*pages.LayoutCallArgument{{ - BaseElement: model.BaseElement{ID: "arg1"}, - ParameterID: model.ID("Atlas_Core.Atlas_Default.Main"), - Widgets: widgets, - }}, - }, - } -} - -func argWidgets(t *testing.T, page *pages.Page) primitive.A { - t.Helper() - w := &Writer{} - raw, err := w.serializePage(page) - if err != nil { - t.Fatalf("serializePage: %v", err) - } - var m map[string]any - if err := bson.Unmarshal(raw, &m); err != nil { - t.Fatalf("unmarshal: %v", err) - } - fc := toMap(m["FormCall"]) - if fc == nil { - t.Fatal("FormCall missing") - } - args, _ := fc["Arguments"].(primitive.A) - if len(args) < 2 { - t.Fatalf("Arguments = %v, want a marker plus one argument", args) - } - arg := toMap(args[1]) - ws, _ := arg["Widgets"].(primitive.A) - return ws -} - -func btn(name string) *pages.ActionButton { - return &pages.ActionButton{BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: model.ID(name), TypeName: "Forms$ActionButton"}, - Name: name, - }} -} - -// TestLayoutPlaceholder_WidgetsSerializedDirectly is the regression: whatever widgets -// a placeholder holds must reach BSON as-is, with no synthetic container inserted. -func TestLayoutPlaceholder_WidgetsSerializedDirectly(t *testing.T) { - tests := []struct { - name string - widgets []pages.Widget - want int - }{ - {"single widget", []pages.Widget{btn("b1")}, 1}, - // The case the wrapper was introduced for: the array holds them side by side. - {"several widgets", []pages.Widget{btn("b1"), btn("b2"), btn("b3")}, 3}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ws := argWidgets(t, placeholderArg(tc.widgets...)) - // First element is the array version marker. - if got := len(ws) - 1; got != tc.want { - t.Fatalf("placeholder holds %d widget(s), want %d — a wrapper would collapse them to 1 (#760)", got, tc.want) - } - for _, w := range ws[1:] { - m := toMap(w) - if m == nil { - continue - } - if ty := extractString(m["$Type"]); ty == "Forms$DivContainer" { - t.Errorf("a synthetic DivContainer wrapper is back (#760): %v", m["Name"]) - } - } - }) - } -} - -// An empty placeholder must still emit the empty Widgets array Mendix expects. -func TestLayoutPlaceholder_EmptyStillEmitsWidgets(t *testing.T) { - ws := argWidgets(t, placeholderArg()) - if len(ws) != 1 { - t.Fatalf("empty placeholder Widgets = %v, want just the array marker", ws) - } -} diff --git a/sdk/mpr/writer_placement.go b/sdk/mpr/writer_placement.go deleted file mode 100644 index fa4ef8b89e..0000000000 --- a/sdk/mpr/writer_placement.go +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// MoveDocument reparents a top-level document unit, whatever its type. -// The idempotence and write accounting live in moveUnitByID. -func (w *Writer) MoveDocument(unitID, containerID model.ID) error { - if unitID == "" || containerID == "" { - return fmt.Errorf("MoveDocument: unit and container are both required") - } - return w.moveUnitByID(string(unitID), string(containerID)) -} - -// FindDocumentUnit locates a document by module and name through the unit -// table, whatever its type. Mirrors the modelsdk engine's implementation. -// -// Only units contained as "Documents" are considered: a module also holds its -// domain model, security and settings, and folders share the table, so the -// containment filter is what stops a folder named like a document from being -// returned as one. -func (w *Writer) FindDocumentUnit(moduleName, name string) (*types.DocumentUnit, error) { - modules, err := w.reader.ListModules() - if err != nil { - return nil, fmt.Errorf("FindDocumentUnit: list modules: %w", err) - } - var moduleID string - for _, m := range modules { - if m.Name == moduleName { - moduleID = string(m.ID) - break - } - } - if moduleID == "" { - return nil, nil - } - containers := buildContainerSet(w.reader, moduleID) - - var found *types.DocumentUnit - err = w.eachDocumentUnit(func(doc *types.DocumentUnit) bool { - if doc.Name != name || !containers[string(doc.ContainerID)] { - return true - } - found = doc - return false - }) - if err != nil { - return nil, fmt.Errorf("FindDocumentUnit: %w", err) - } - return found, nil -} - -// ListDocumentUnits returns every top-level document in the project. -func (w *Writer) ListDocumentUnits() ([]*types.DocumentUnit, error) { - var out []*types.DocumentUnit - if err := w.eachDocumentUnit(func(doc *types.DocumentUnit) bool { - out = append(out, doc) - return true - }); err != nil { - return nil, fmt.Errorf("ListDocumentUnits: %w", err) - } - return out, nil -} - -// eachDocumentUnit walks every "Documents" unit, decoding just enough of each -// to name it, and stops early when visit returns false. A unit whose contents -// will not decode is skipped rather than failing the whole walk. -func (w *Writer) eachDocumentUnit(visit func(*types.DocumentUnit) bool) error { - units, err := w.reader.listUnitsByType("") - if err != nil { - return fmt.Errorf("list units: %w", err) - } - for _, unit := range units { - if unit.ContainmentName != "Documents" { - continue - } - contents, err := w.reader.resolveContents(unit.ID, unit.Contents) - if err != nil || len(contents) == 0 { - continue - } - var raw bson.D - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - name := "" - for _, elem := range raw { - if elem.Key == "Name" { - if s, ok := elem.Value.(string); ok { - name = s - } - break - } - } - if name == "" { - continue - } - if !visit(&types.DocumentUnit{ - ID: model.ID(unit.ID), - ContainerID: model.ID(unit.ContainerID), - Name: name, - Type: unit.Type, - Kind: types.DocumentKind(unit.Type), - }) { - return nil - } - } - return nil -} diff --git a/sdk/mpr/writer_refs.go b/sdk/mpr/writer_refs.go deleted file mode 100644 index f7d7d1b1dc..0000000000 --- a/sdk/mpr/writer_refs.go +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// UpdateQualifiedNameInAllUnits replaces all occurrences of oldName with newName -// in string values across all BSON documents in the project. Handles both exact -// matches and prefix matches (e.g., "Module.Name.Param" when renaming "Module.Name"). -// Returns the number of documents that were updated. -func (w *Writer) UpdateQualifiedNameInAllUnits(oldName, newName string) (int, error) { - units, err := w.reader.listUnitsByType("") - if err != nil { - return 0, err - } - - updated := 0 - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - if replaceStringsInMap(raw, oldName, newName) { - contents, err := marshalUnitIDFirst(raw) - if err != nil { - continue - } - if err := w.updateUnit(u.ID, contents); err != nil { - return updated, err - } - updated++ - } - } - - return updated, nil -} - -// replaceStringsInMap recursively walks a map and replaces string values that -// match oldName exactly or have oldName as a prefix (followed by "."). -// Returns true if any replacement was made. -func replaceStringsInMap(m map[string]any, oldName, newName string) bool { - changed := false - for k, v := range m { - if replaced, ok := replaceInValue(v, oldName, newName); ok { - m[k] = replaced - changed = true - } - } - return changed -} - -// replaceInValue recursively processes a value and returns the replacement and -// whether any change was made. -func replaceInValue(v any, oldName, newName string) (any, bool) { - switch val := v.(type) { - case string: - if newStr, ok := replaceQualifiedName(val, oldName, newName); ok { - return newStr, true - } - case map[string]any: - if replaceStringsInMap(val, oldName, newName) { - return val, true - } - case primitive.M: - m := map[string]any(val) - if replaceStringsInMap(m, oldName, newName) { - return val, true - } - case primitive.A: - changed := false - for i, elem := range val { - if replaced, ok := replaceInValue(elem, oldName, newName); ok { - val[i] = replaced - changed = true - } - } - if changed { - return val, true - } - case []any: - changed := false - for i, elem := range val { - if replaced, ok := replaceInValue(elem, oldName, newName); ok { - val[i] = replaced - changed = true - } - } - if changed { - return val, true - } - case primitive.D: - changed := false - for i, elem := range val { - if replaced, ok := replaceInValue(elem.Value, oldName, newName); ok { - val[i].Value = replaced - changed = true - } - } - if changed { - return val, true - } - } - return v, false -} - -// replaceQualifiedName checks if s matches oldName exactly or as a prefix -// (e.g., "OldModule.Microflow.Param") and returns the replacement. -func replaceQualifiedName(s, oldName, newName string) (string, bool) { - if s == oldName { - return newName, true - } - // Prefix match: "OldModule.Microflow.Param" → "NewModule.Microflow.Param" - if strings.HasPrefix(s, oldName+".") { - return newName + s[len(oldName):], true - } - return "", false -} diff --git a/sdk/mpr/writer_rename.go b/sdk/mpr/writer_rename.go deleted file mode 100644 index 671ecfddcd..0000000000 --- a/sdk/mpr/writer_rename.go +++ /dev/null @@ -1,240 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "github.com/mendixlabs/mxcli/mdl/types" - "strings" - - "go.mongodb.org/mongo-driver/bson" -) - -// RenameHit describes a document that contains references to a renamed element. -type RenameHit struct { - UnitID string // Document UUID - UnitType string // e.g., "Microflows$Microflow" - Name string // Document name (if found) - Count int // Number of string replacements in this document -} - -// RenameReferences scans all documents in the project and replaces qualified name -// strings matching oldName with newName. Returns the list of affected documents. -// -// Matching rules: -// - Exact match: "Module.OldName" → "Module.NewName" -// - Prefix match: "Module.OldName.Attr" → "Module.NewName.Attr" -// -// If dryRun is true, no modifications are written — only the hit list is returned. -func (w *Writer) RenameReferences(oldName, newName string, dryRun bool) ([]RenameHit, error) { - // List all units (empty type prefix = all) - units, err := w.reader.listUnitsByType("") - if err != nil { - return nil, fmt.Errorf("failed to list units: %w", err) - } - - var hits []RenameHit - - for _, unit := range units { - contents, err := w.reader.resolveContents(unit.ID, unit.Contents) - if err != nil { - continue - } - if len(contents) == 0 { - continue - } - - var raw bson.D - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - count := 0 - updated := replaceStringsInDoc(raw, oldName, newName, &count) - - if count > 0 { - // Extract document name for reporting - docName := "" - for _, elem := range updated { - if elem.Key == "Name" { - if s, ok := elem.Value.(string); ok { - docName = s - } - } - } - - hits = append(hits, RenameHit{ - UnitID: unit.ID, - UnitType: unit.Type, - Name: docName, - Count: count, - }) - - if !dryRun { - newContents, err := marshalUnitIDFirst(updated) - if err != nil { - return hits, fmt.Errorf("failed to marshal updated document %s: %w", unit.ID, err) - } - if err := w.updateUnit(unit.ID, newContents); err != nil { - return hits, fmt.Errorf("failed to write updated document %s: %w", unit.ID, err) - } - } - } - } - - return hits, nil -} - -// RenameDocumentByName finds a document by module and name, then updates its Name field. -// This works for any document type (microflow, nanoflow, page, constant, enumeration, etc.) -// by doing a raw BSON scan of all units in the module. -func (w *Writer) RenameDocumentByName(moduleName, oldName, newName string) error { - // Find all modules to get the module ID - modules, err := w.reader.ListModules() - if err != nil { - return fmt.Errorf("failed to list modules: %w", err) - } - - var moduleID string - for _, m := range modules { - if m.Name == moduleName { - moduleID = string(m.ID) - break - } - } - if moduleID == "" { - return fmt.Errorf("module not found: %s", moduleName) - } - - // Build container hierarchy to find documents in this module (including folders) - hierarchy := buildContainerSet(w.reader, moduleID) - - // Scan all units looking for the document with matching Name - units, err := w.reader.listUnitsByType("") - if err != nil { - return fmt.Errorf("failed to list units: %w", err) - } - - for _, unit := range units { - // Check if this unit belongs to the target module (direct or via folder) - if !hierarchy[unit.ContainerID] { - continue - } - - contents, err := w.reader.resolveContents(unit.ID, unit.Contents) - if err != nil || len(contents) == 0 { - continue - } - - var raw bson.D - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - // Check if this document has Name == oldName - for i, elem := range raw { - if elem.Key == "Name" { - if s, ok := elem.Value.(string); ok && s == oldName { - raw[i].Value = newName - newContents, err := marshalUnitIDFirst(raw) - if err != nil { - return fmt.Errorf("failed to marshal: %w", err) - } - return w.updateUnit(unit.ID, newContents) - } - } - } - } - - return fmt.Errorf("document '%s.%s' not found", moduleName, oldName) -} - -// buildContainerSet returns a set of container IDs that belong to a module -// (the module ID itself plus all folder IDs nested under it). -func buildContainerSet(r *Reader, moduleID string) map[string]bool { - set := map[string]bool{moduleID: true} - - folders, err := r.ListFolders() - if err != nil { - return set - } - - // Iteratively expand: if a folder's container is in the set, add the folder - changed := true - for changed { - changed = false - for _, f := range folders { - if set[string(f.ContainerID)] && !set[string(f.ID)] { - set[string(f.ID)] = true - changed = true - } - } - } - - return set -} - -// replaceStringsInDoc recursively walks a bson.D document and replaces string -// values that match oldName exactly or start with oldName + ".". -func replaceStringsInDoc(doc bson.D, oldName, newName string, count *int) bson.D { - result := make(bson.D, len(doc)) - for i, elem := range doc { - // A view entity's OQL holds the qualified name EMBEDDED in the query, so - // the whole-string match in replaceStringsInValue never sees it and the - // view kept pointing at the old name after a rename (CE0174 "Cannot - // resolve object name"). Same fix in the modelsdk engine's - // replaceQNInDocCounted; the rewrite itself is shared. - if elem.Key == oqlPropertyKey { - if q, ok := elem.Value.(string); ok { - rewritten, n := types.RewriteOQLQualifiedName(q, oldName, newName) - *count += n - result[i] = bson.E{Key: elem.Key, Value: rewritten} - continue - } - } - result[i] = bson.E{ - Key: elem.Key, - Value: replaceStringsInValue(elem.Value, oldName, newName, count), - } - } - return result -} - -// oqlPropertyKey is where DomainModels$OqlViewEntitySource keeps the query. -const oqlPropertyKey = "Oql" - -// replaceStringsInValue replaces qualified name strings in any BSON value type. -func replaceStringsInValue(val any, oldName, newName string, count *int) any { - switch v := val.(type) { - case string: - if v == oldName { - *count++ - return newName - } - if strings.HasPrefix(v, oldName+".") { - *count++ - return newName + v[len(oldName):] - } - return v - - case bson.D: - return replaceStringsInDoc(v, oldName, newName, count) - - case bson.A: - result := make(bson.A, len(v)) - for i, item := range v { - result[i] = replaceStringsInValue(item, oldName, newName, count) - } - return result - - case []any: - result := make([]any, len(v)) - for i, item := range v { - result[i] = replaceStringsInValue(item, oldName, newName, count) - } - return result - - default: - return v - } -} diff --git a/sdk/mpr/writer_rest.go b/sdk/mpr/writer_rest.go deleted file mode 100644 index f0b7f4ad0c..0000000000 --- a/sdk/mpr/writer_rest.go +++ /dev/null @@ -1,635 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateConsumedRestService creates a new consumed REST service document. -func (w *Writer) CreateConsumedRestService(svc *model.ConsumedRestService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "Rest$ConsumedRestService" - - contents, err := w.serializeConsumedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed REST service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "Rest$ConsumedRestService", contents) -} - -// UpdateConsumedRestService updates an existing consumed REST service. -func (w *Writer) UpdateConsumedRestService(svc *model.ConsumedRestService) error { - contents, err := w.serializeConsumedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed REST service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeleteConsumedRestService deletes a consumed REST service by ID. -func (w *Writer) DeleteConsumedRestService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeConsumedRestService converts a ConsumedRestService to BSON bytes. -func (w *Writer) serializeConsumedRestService(svc *model.ConsumedRestService) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "Rest$ConsumedRestService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Excluded", Value: svc.Excluded}, - // ExportLevel: whether the document is exposed to other modules/projects. - // Studio Pro defaults to "Hidden". Missing this field has been observed - // to cause runtime auth issues (#200). - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "BaseUrlParameter", Value: nil}, - } - - // OpenApiFile: only present when the service was created from an OpenAPI spec. - // Field name and subfield are PascalCase to match Studio Pro serialization. - // Do NOT write a null entry for manually-created services — Studio Pro omits this field entirely. - if svc.OpenApiContent != "" { - doc = append(doc, bson.E{Key: "OpenApiFile", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$OpenApiFile"}, - {Key: "Content", Value: svc.OpenApiContent}, - }}) - } - - // BaseUrl as Rest$ValueTemplate - doc = append(doc, bson.E{Key: "BaseUrl", Value: serializeValueTemplate(svc.BaseUrl)}) - - // AuthenticationScheme: polymorphic (null or Rest$BasicAuthenticationScheme) - if svc.Authentication == nil { - doc = append(doc, bson.E{Key: "AuthenticationScheme", Value: nil}) - } else { - doc = append(doc, bson.E{Key: "AuthenticationScheme", Value: serializeRestAuthScheme(svc.Authentication)}) - } - - // Operations: versioned array - ops := bson.A{int32(2)} - for _, op := range svc.Operations { - ops = append(ops, serializeRestOperation(op)) - } - doc = append(doc, bson.E{Key: "Operations", Value: ops}) - - return marshalUnitIDFirst(doc) -} - -// serializeValueTemplate creates a Rest$ValueTemplate BSON object. -func serializeValueTemplate(value string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ValueTemplate"}, - {Key: "Value", Value: value}, - } -} - -// serializeRestAuthScheme converts authentication config to a BSON map. -func serializeRestAuthScheme(auth *model.RestAuthentication) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$BasicAuthenticationScheme"}, - } - - doc = append(doc, bson.E{Key: "Username", Value: serializeRestValue(auth.Username)}) - doc = append(doc, bson.E{Key: "Password", Value: serializeRestValue(auth.Password)}) - - return doc -} - -// serializeRestValue creates a polymorphic Rest$Value (StringValue or ConstantValue). -// Values starting with "$" are treated as constant references; others as string literals. -func serializeRestValue(value string) bson.D { - if strings.HasPrefix(value, "$") { - // Constant reference — the BSON field is "Value" (QualifiedName of the constant). - constRef := strings.TrimPrefix(value, "$") - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ConstantValue"}, - {Key: "Value", Value: constRef}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$StringValue"}, - {Key: "Value", Value: value}, - } -} - -// serializeRestOperation converts a RestClientOperation to a BSON map. -func serializeRestOperation(op *model.RestClientOperation) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperation"}, - {Key: "Name", Value: op.Name}, - } - - // Timeout: Studio Pro always writes this field; default is 300 seconds. - timeout := int64(op.Timeout) - if timeout <= 0 { - timeout = 300 - } - doc = append(doc, bson.E{Key: "Timeout", Value: timeout}) - - // Tags: versioned string array; used by Studio Pro as resource group labels. - tags := bson.A{int32(1)} - for _, t := range op.Tags { - tags = append(tags, t) - } - doc = append(doc, bson.E{Key: "Tags", Value: tags}) - - // Method: polymorphic (WithBody or WithoutBody) - doc = append(doc, bson.E{Key: "Method", Value: serializeRestMethod(op)}) - - // Path as Rest$ValueTemplate - doc = append(doc, bson.E{Key: "Path", Value: serializeValueTemplate(op.Path)}) - - // Headers: versioned array of Rest$HeaderWithValueTemplate - headers := bson.A{int32(2)} - hasAccept := false - for _, h := range op.Headers { - headers = append(headers, serializeRestHeader(h)) - if strings.EqualFold(h.Name, "Accept") { - hasAccept = true - } - } - // Mendix requires an Accept header on every consumed REST operation (CE7062) - if !hasAccept { - headers = append(headers, serializeRestHeader(&model.RestClientHeader{Name: "Accept", Value: "*/*"})) - } - doc = append(doc, bson.E{Key: "Headers", Value: headers}) - - // Parameters: versioned array of Rest$RestOperationParameter (path params) - params := bson.A{int32(2)} - for _, p := range op.Parameters { - params = append(params, serializeRestParameter(p)) - } - doc = append(doc, bson.E{Key: "Parameters", Value: params}) - - // QueryParameters: versioned array of Rest$QueryParameter - queryParams := bson.A{int32(2)} - for _, q := range op.QueryParameters { - queryParams = append(queryParams, serializeRestQueryParameter(q)) - } - doc = append(doc, bson.E{Key: "QueryParameters", Value: queryParams}) - - // ResponseHandling: polymorphic - if op.ResponseType == "MAPPING" && op.ResponseEntity != "" && len(op.ResponseMappings) > 0 { - doc = append(doc, bson.E{Key: "ResponseHandling", Value: serializeRestImplicitMappingResponse(op.ResponseEntity, op.ResponseMappings)}) - } else { - doc = append(doc, bson.E{Key: "ResponseHandling", Value: serializeRestResponseHandling(op.ResponseType)}) - } - - return doc -} - -// serializeRestMethod creates the polymorphic Method field. -// Methods with bodies (POST, PUT, PATCH) use Rest$RestOperationMethodWithBody; -// others use Rest$RestOperationMethodWithoutBody. -func serializeRestMethod(op *model.RestClientOperation) bson.D { - httpMethod := httpMethodToMendix(op.HttpMethod) - - if op.BodyType != "" { - // Method with explicit body - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationMethodWithBody"}, - {Key: "HttpMethod", Value: httpMethod}, - } - if op.BodyType == "EXPORT_MAPPING" && len(op.BodyMappings) > 0 { - doc = append(doc, bson.E{Key: "Body", Value: serializeRestImplicitMappingBody(op.BodyVariable, op.BodyMappings)}) - } else { - doc = append(doc, bson.E{Key: "Body", Value: serializeRestBody(op.BodyType, op.BodyVariable)}) - } - return doc - } - - // POST, PUT, PATCH must include a body even if not explicitly specified (CE7064) - methodUpper := strings.ToUpper(op.HttpMethod) - if methodUpper == "POST" || methodUpper == "PUT" || methodUpper == "PATCH" { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationMethodWithBody"}, - {Key: "HttpMethod", Value: httpMethod}, - } - doc = append(doc, bson.E{Key: "Body", Value: serializeRestBody("JSON", op.BodyVariable)}) - return doc - } - - // Method without body - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationMethodWithoutBody"}, - {Key: "HttpMethod", Value: httpMethod}, - } -} - -// serializeRestBody creates a polymorphic Body field. -// Uses Rest$JsonBody instead of Rest$ImplicitMappingBody to avoid CE7247/CE0061 -// (ImplicitMappingBody requires entity mapping which isn't supported yet). -// -// bodyExpr is a Mendix expression (typically "$variableName") that produces -// the JSON or file body at call time. It is stored verbatim in the BSON Value -// field so a roundtrip preserves it. -func serializeRestBody(bodyType, bodyExpr string) bson.D { - switch strings.ToUpper(bodyType) { - case "JSON": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$JsonBody"}, - {Key: "Value", Value: bodyExpr}, - } - case "FILE", "TEMPLATE": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$StringBody"}, - {Key: "ValueTemplate", Value: serializeValueTemplate(bodyExpr)}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$JsonBody"}, - {Key: "Value", Value: bodyExpr}, - } - } -} - -// serializeRestImplicitMappingBody creates a Rest$ImplicitMappingBody with an inline -// export mapping tree (ExportMappings$ObjectMappingElement). Used for Body: MAPPING Entity { ... }. -func serializeRestImplicitMappingBody(entity string, mappings []*model.RestResponseMapping) bson.D { - rootElement := serializeInlineMappingElement(entity, "", "", "(Object)", mappings, "ExportMappings", "Parameter") - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ImplicitMappingBody"}, - {Key: "RootMappingElement", Value: rootElement}, - {Key: "TestValue", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$StringValue"}, - {Key: "Value", Value: ""}, - }}, - } -} - -// serializeRestImplicitMappingResponse creates a Rest$ImplicitMappingResponseHandling with an -// inline import mapping tree (ImportMappings$ObjectMappingElement). Used for Response: MAPPING Entity { ... }. -func serializeRestImplicitMappingResponse(entity string, mappings []*model.RestResponseMapping) bson.D { - rootElement := serializeInlineMappingElement(entity, "", "", "(Object)", mappings, "ImportMappings", "Create") - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ImplicitMappingResponseHandling"}, - {Key: "ContentType", Value: "application/json"}, - {Key: "RootMappingElement", Value: rootElement}, - {Key: "StatusCode", Value: int32(200)}, - } -} - -// nestedInlineHandling is the ObjectHandling a nested inline mapping element -// gets. It is NOT the same in both directions, and the export answer is not a -// preference: mxbuild refuses to LOAD a project whose export object element is -// "Create" — "Export Object Mappings cannot have ObjectHandling set to -// 'Create'", thrown as an AggregateException before any check runs, so the -// project cannot be opened at all. The serializer used to hardcode "Create" for -// every nested child regardless of namespace. -// -// "Find" is what Studio Pro stores on a nested object element of an export -// mapping DOCUMENT; the demo corpus contains no inline export body to pin it -// against directly (0 Rest$ImplicitMappingBody in 9 packages), so the document -// form is the reference and mxbuild is the control. -func nestedInlineHandling(namespace string) string { - if namespace == "ExportMappings" { - return "Find" - } - return "Create" -} - -// nestedInlineBackup pairs with it: an export element has nothing to create, so -// Studio Pro stores "Error" (see the ObjectHandlingBackup enum, #261). -func nestedInlineBackup(namespace string) string { - if namespace == "ExportMappings" { - return "Error" - } - return "Create" -} - -// serializeInlineMappingElement creates a single ObjectMappingElement with children for inline REST mappings. -// namespace is "ImportMappings" or "ExportMappings". objectHandling is "Create" or "Parameter". -func serializeInlineMappingElement(entity, association, exposedName, jsonPath string, mappings []*model.RestResponseMapping, namespace, objectHandling string) bson.D { - children := bson.A{int32(2)} - for _, m := range mappings { - if m.Entity != "" { - // Nested object mapping - childJsonPath := model.InlineMappingPath(jsonPath, m.ExposedName) - child := serializeInlineMappingElement(m.Entity, m.Association, - model.InlineMappingExposedName(m.ExposedName), childJsonPath, m.Children, - namespace, nestedInlineHandling(namespace)) - children = append(children, child) - } else { - // Value mapping - valueJsonPath := m.JsonPath - if valueJsonPath == "" { - valueJsonPath = model.InlineMappingPath(jsonPath, m.ExposedName) - } - attrQN := entity + "." + m.Attribute - children = append(children, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: namespace + "$ValueMappingElement"}, - {Key: "Attribute", Value: attrQN}, - {Key: "ExposedName", Value: model.InlineMappingExposedName(m.ExposedName)}, - {Key: "JsonPath", Value: valueJsonPath}, - {Key: "XmlPath", Value: ""}, - {Key: "IsKey", Value: false}, - {Key: "Type", Value: bson.D{{Key: "$ID", Value: idToBsonBinary(generateUUID())}, {Key: "$Type", Value: "DataTypes$StringType"}}}, - {Key: "MinOccurs", Value: int32(0)}, - {Key: "MaxOccurs", Value: int32(1)}, - {Key: "Nillable", Value: true}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: "Value"}, - {Key: "Documentation", Value: ""}, - {Key: "Converter", Value: ""}, - {Key: "FractionDigits", Value: int32(-1)}, - {Key: "TotalDigits", Value: int32(-1)}, - {Key: "MaxLength", Value: int32(0)}, - {Key: "IsContent", Value: false}, - {Key: "IsXmlAttribute", Value: false}, - {Key: "OriginalValue", Value: ""}, - {Key: "XmlPrimitiveType", Value: "String"}, - }) - } - } - - minOccurs := int32(1) - if association != "" { - minOccurs = 0 - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: namespace + "$ObjectMappingElement"}, - {Key: "Entity", Value: entity}, - {Key: "ExposedName", Value: exposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: ""}, - {Key: "ObjectHandling", Value: objectHandling}, - {Key: "ObjectHandlingBackup", Value: nestedInlineBackup(namespace)}, - {Key: "ObjectHandlingBackupAllowOverride", Value: false}, - {Key: "Association", Value: association}, - {Key: "Children", Value: children}, - {Key: "MinOccurs", Value: minOccurs}, - {Key: "MaxOccurs", Value: int32(1)}, - {Key: "Nillable", Value: true}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: "Object"}, - {Key: "Documentation", Value: ""}, - {Key: "CustomHandlerCall", Value: nil}, - } -} - -// serializeRestHeader creates a Rest$HeaderWithValueTemplate BSON object. -func serializeRestHeader(h *model.RestClientHeader) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$HeaderWithValueTemplate"}, - {Key: "Name", Value: h.Name}, - {Key: "Value", Value: serializeValueTemplate(h.Value)}, - } -} - -// serializeRestParameter creates a Rest$OperationParameter BSON object. -// This is the correct type for consumed REST operation parameters -// (distinct from Rest$RestOperationParameter used in published REST services). -func serializeRestParameter(p *model.RestClientParameter) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$OperationParameter"}, - {Key: "Name", Value: p.Name}, - {Key: "DataType", Value: serializeRestDataType(p.DataType)}, - } -} - -// serializeRestQueryParameter creates a Rest$QueryParameter BSON object. -func serializeRestQueryParameter(p *model.RestClientParameter) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$QueryParameter"}, - {Key: "Name", Value: p.Name}, - {Key: "ParameterUsage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RequiredQueryParameterUsage"}, - }}, - } -} - -// serializeRestResponseHandling creates a polymorphic ResponseHandling BSON object. -// Uses Rest$NoResponseHandling for all types to avoid CE0061 (ImplicitMappingResponseHandling -// requires entity mapping which isn't supported yet). ContentType is set to enable roundtripping. -func serializeRestResponseHandling(responseType string) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$NoResponseHandling"}, - } - switch strings.ToUpper(responseType) { - case "JSON": - doc = append(doc, bson.E{Key: "ContentType", Value: "application/json"}) - case "STRING": - doc = append(doc, bson.E{Key: "ContentType", Value: "text/plain"}) - case "FILE": - doc = append(doc, bson.E{Key: "ContentType", Value: "application/octet-stream"}) - } - return doc -} - -// serializeRestDataType converts a simple type name to a BSON DataType object. -// REST operation parameters use the DataTypes$ namespace with simple type names -// (e.g., DataTypes$IntegerType, not DataTypes$IntegerAttributeType). -func serializeRestDataType(typeName string) bson.D { - bsonType := "DataTypes$StringType" - switch typeName { - case "Integer": - bsonType = "DataTypes$IntegerType" - case "Long": - bsonType = "DataTypes$IntegerType" // Long maps to IntegerType in DataTypes - case "Decimal": - bsonType = "DataTypes$DecimalType" - case "Boolean": - bsonType = "DataTypes$BooleanType" - case "String": - bsonType = "DataTypes$StringType" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: bsonType}, - } -} - -// CreatePublishedRestService creates a new published REST service document. -func (w *Writer) CreatePublishedRestService(svc *model.PublishedRestService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "Rest$PublishedRestService" - - contents, err := w.serializePublishedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published REST service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "Rest$PublishedRestService", contents) -} - -// DeletePublishedRestService deletes a published REST service by ID. -func (w *Writer) DeletePublishedRestService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// UpdatePublishedRestService re-serializes an existing published REST -// service. Used by ALTER PUBLISHED REST SERVICE. -func (w *Writer) UpdatePublishedRestService(svc *model.PublishedRestService) error { - contents, err := w.serializePublishedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published REST service: %w", err) - } - return w.updateUnit(string(svc.ID), contents) -} - -func (w *Writer) serializePublishedRestService(svc *model.PublishedRestService) ([]byte, error) { - resources := bson.A{int32(2)} - for _, res := range svc.Resources { - ops := bson.A{int32(2)} - for _, op := range res.Operations { - opDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Rest$PublishedRestServiceOperation"}, - {Key: "HttpMethod", Value: httpMethodToMendix(op.HTTPMethod)}, - {Key: "Path", Value: op.Path}, - {Key: "Microflow", Value: op.Microflow}, - {Key: "Summary", Value: op.Summary}, - {Key: "Deprecated", Value: op.Deprecated}, - {Key: "Commit", Value: "Yes"}, - {Key: "Documentation", Value: ""}, - {Key: "ExportMapping", Value: ""}, - {Key: "ImportMapping", Value: ""}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "Parameters", Value: serializePublishedRestParams(op.Path, op.Microflow, op.Parameters)}, - } - ops = append(ops, opDoc) - } - resDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Rest$PublishedRestServiceResource"}, - {Key: "Name", Value: res.Name}, - {Key: "Documentation", Value: ""}, - {Key: "Operations", Value: ops}, - } - resources = append(resources, resDoc) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "Rest$PublishedRestService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: svc.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Path", Value: svc.Path}, - {Key: "Version", Value: svc.Version}, - {Key: "ServiceName", Value: svc.ServiceName}, - {Key: "AllowedRoles", Value: makeMendixStringArray(svc.AllowedRoles)}, - {Key: "AuthenticationTypes", Value: bson.A{int32(2)}}, - {Key: "AuthenticationMicroflow", Value: ""}, - {Key: "CorsConfiguration", Value: nil}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Resources", Value: resources}, - } - - return marshalUnitIDFirst(doc) -} - -// serializePublishedRestParams builds the Parameters array for a published REST operation. -// It auto-extracts path parameters from {paramName} placeholders in the path string, -// then appends any explicitly declared parameters. -// -// Each parameter must include: -// - Type: a structured DataTypes$StringType object (not a bare string) -// - ParameterType: "Path" (vs Query/Header/Body) -// - MicroflowParameter: qualified name of the matching microflow parameter, -// so Mendix wires the path value to the handler. Without this, mx check -// reports CE6538 "Parameter is not passed to a microflow parameter" and -// CE0350 "Microflow has a parameter that is not a parameter of the operation". -func serializePublishedRestParams(path string, microflowQN string, _ []string) bson.A { - params := bson.A{int32(2)} - // Extract {paramName} from path - for _, name := range extractPathParams(path) { - // MicroflowParameter format: "Module.Microflow.parameterName" - mfParam := "" - if microflowQN != "" { - mfParam = microflowQN + "." + name - } - params = append(params, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationParameter"}, - {Key: "Name", Value: name}, - {Key: "Type", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - {Key: "ParameterType", Value: "Path"}, - {Key: "MicroflowParameter", Value: mfParam}, - {Key: "Description", Value: ""}, - }) - } - return params -} - -// extractPathParams returns parameter names from {param} placeholders in a path. -func extractPathParams(path string) []string { - var names []string - for { - start := strings.Index(path, "{") - if start < 0 { - break - } - end := strings.Index(path[start:], "}") - if end < 0 { - break - } - names = append(names, path[start+1:start+end]) - path = path[start+end+1:] - } - return names -} - -// httpMethodToMendix converts uppercase HTTP method names to Mendix casing. -func httpMethodToMendix(method string) string { - switch strings.ToUpper(method) { - case "GET": - return "Get" - case "POST": - return "Post" - case "PUT": - return "Put" - case "PATCH": - return "Patch" - case "DELETE": - return "Delete" - case "HEAD": - return "Head" - case "OPTIONS": - return "Options" - default: - return method - } -} diff --git a/sdk/mpr/writer_rest_httpresponse_test.go b/sdk/mpr/writer_rest_httpresponse_test.go deleted file mode 100644 index 6488587e18..0000000000 --- a/sdk/mpr/writer_rest_httpresponse_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeRestResultHandlingHttpResponseUsesObjectType(t *testing.T) { - handling := µflows.ResultHandlingHttpResponse{ - BaseElement: model.BaseElement{ID: "result-1"}, - VariableName: "HttpResponse", - } - - doc := serializeRestResultHandling(handling, "HttpResponse") - - if got := getBSONField(doc, "ResultVariableName"); got != "HttpResponse" { - t.Fatalf("ResultVariableName = %#v, want HttpResponse", got) - } - variableType, ok := getBSONField(doc, "VariableType").(bson.D) - if !ok { - t.Fatalf("VariableType is %T, want bson.D", getBSONField(doc, "VariableType")) - } - if got := getBSONField(variableType, "$Type"); got != "DataTypes$ObjectType" { - t.Fatalf("VariableType.$Type = %#v, want DataTypes$ObjectType", got) - } - if got := getBSONField(variableType, "Entity"); got != "System.HttpResponse" { - t.Fatalf("VariableType.Entity = %#v, want System.HttpResponse", got) - } -} diff --git a/sdk/mpr/writer_rest_inline_mapping_test.go b/sdk/mpr/writer_rest_inline_mapping_test.go deleted file mode 100644 index 666bd54475..0000000000 --- a/sdk/mpr/writer_rest_inline_mapping_test.go +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// The INLINE REST mapping is a separate serializer from the mapping-DOCUMENT -// one, and two defects lived here that the document work never touched -// (reported by the mxcli-rest project, findings #36 and #37): -// -// 1. A multi-segment member (`Title = fields/Title`) was stored as ONE member -// whose name contains a slash — "(Object)|fields/Title" — instead of -// "(Object)|fields|Title". Every gate passed and the value was empty at -// runtime, which is the worst possible failure mode for this. -// 2. Every nested child was hardcoded ObjectHandling "Create", including in an -// EXPORT body, where mxbuild refuses to LOAD the project at all. -// -// Four Studio Pro-authored inline response mappings in the demo apps confirm -// the stored form is a full pipe path (e.g. -// "(Object)|results|bindings|(Object)|caseId|value"). - -func inlineRoot(t *testing.T, doc bson.D) map[string]any { - t.Helper() - for _, e := range doc { - if e.Key == "RootMappingElement" { - raw, err := bson.Marshal(e.Value) - if err != nil { - t.Fatalf("marshal root: %v", err) - } - var m map[string]any - if err := bson.Unmarshal(raw, &m); err != nil { - t.Fatalf("unmarshal root: %v", err) - } - return m - } - } - t.Fatal("no RootMappingElement") - return nil -} - -func inlineChildren(t *testing.T, elem map[string]any) []map[string]any { - t.Helper() - arr, ok := elem["Children"].(bson.A) - if !ok { - return nil - } - var out []map[string]any - for _, v := range arr { - m, ok := v.(map[string]any) - if !ok { - continue // the int32 typed-array marker - } - out = append(out, m) - } - return out -} - -// TestInlineResponseMappingMultiSegmentPath is finding #36. -func TestInlineResponseMappingMultiSegmentPath(t *testing.T) { - doc := serializeRestImplicitMappingResponse("RestLab.FlatProbe", []*model.RestResponseMapping{ - {Attribute: "ItemId", ExposedName: "id"}, - {Attribute: "Title", ExposedName: "fields/Title"}, - }) - - children := inlineChildren(t, inlineRoot(t, doc)) - if len(children) != 2 { - t.Fatalf("got %d children, want 2", len(children)) - } - if got := children[0]["JsonPath"]; got != "(Object)|id" { - t.Errorf("single-segment JsonPath = %q, want (Object)|id — unchanged behaviour", got) - } - // The defect: "(Object)|fields/Title" is one member with a slash in its - // name, so nothing binds and the column is silently empty. - if got := children[1]["JsonPath"]; got != "(Object)|fields|Title" { - t.Errorf("multi-segment JsonPath = %q, want (Object)|fields|Title", got) - } - // ExposedName is a label, and Studio Pro stores the last segment. - if got := children[1]["ExposedName"]; got != "Title" { - t.Errorf("ExposedName = %q, want Title", got) - } -} - -// TestInlineExportBodyNestedHandling is finding #37. "Create" on an export -// object element is not a check error — mxbuild throws before the check, and -// the project cannot be opened. -func TestInlineExportBodyNestedHandling(t *testing.T) { - doc := serializeRestImplicitMappingBody("RestLab.Task", []*model.RestResponseMapping{{ - Entity: "RestLab.TaskFields", - Association: "RestLab.Task_TaskFields", - ExposedName: "fields", - Children: []*model.RestResponseMapping{{Attribute: "Title", ExposedName: "Title"}}, - }}) - - root := inlineRoot(t, doc) - if got := root["ObjectHandling"]; got != "Parameter" { - t.Errorf("export root ObjectHandling = %q, want Parameter", got) - } - if got := root["$Type"]; got != "ExportMappings$ObjectMappingElement" { - t.Fatalf("export root $Type = %q", got) - } - - nested := inlineChildren(t, root) - if len(nested) != 1 { - t.Fatalf("got %d nested elements, want 1", len(nested)) - } - if got := nested[0]["ObjectHandling"]; got != "Find" { - t.Errorf("nested export ObjectHandling = %q, want Find — mxbuild refuses to LOAD "+ - "a project whose export object element is Create", got) - } - // An export element has nothing to create, so the backup is Error. - if got := nested[0]["ObjectHandlingBackup"]; got != "Error" { - t.Errorf("nested export ObjectHandlingBackup = %q, want Error", got) - } -} - -// TestInlineImportNestedHandlingUnchanged is the control for the one above: the -// import direction legitimately creates, and must not be changed by the fix. -func TestInlineImportNestedHandlingUnchanged(t *testing.T) { - doc := serializeRestImplicitMappingResponse("RestLab.Task", []*model.RestResponseMapping{{ - Entity: "RestLab.TaskFields", - Association: "RestLab.Task_TaskFields", - ExposedName: "fields", - Children: []*model.RestResponseMapping{{Attribute: "Title", ExposedName: "Title"}}, - }}) - - nested := inlineChildren(t, inlineRoot(t, doc)) - if len(nested) != 1 { - t.Fatalf("got %d nested elements, want 1", len(nested)) - } - if got := nested[0]["ObjectHandling"]; got != "Create" { - t.Errorf("nested import ObjectHandling = %q, want Create", got) - } - if got := nested[0]["JsonPath"]; got != "(Object)|fields" { - t.Errorf("nested JsonPath = %q", got) - } - // The value under it resolves against the nested path, not the root. - values := inlineChildren(t, nested[0]) - if len(values) != 1 || values[0]["JsonPath"] != "(Object)|fields|Title" { - t.Errorf("nested value JsonPath = %v", values) - } -} - -// TestInlineNestedObjectMultiSegmentPath: an OBJECT element can carry a -// multi-segment member too, and it was broken the same way. -func TestInlineNestedObjectMultiSegmentPath(t *testing.T) { - doc := serializeRestImplicitMappingResponse("RestLab.Root", []*model.RestResponseMapping{{ - Entity: "RestLab.Binding", - Association: "RestLab.Binding_Root", - ExposedName: "results/bindings", - Children: []*model.RestResponseMapping{{Attribute: "Value", ExposedName: "caseId/value"}}, - }}) - - nested := inlineChildren(t, inlineRoot(t, doc)) - if len(nested) != 1 { - t.Fatalf("got %d nested elements, want 1", len(nested)) - } - if got := nested[0]["JsonPath"]; got != "(Object)|results|bindings" { - t.Errorf("nested object JsonPath = %q, want (Object)|results|bindings", got) - } - if got := nested[0]["ExposedName"]; got != "bindings" { - t.Errorf("nested object ExposedName = %q, want bindings", got) - } - values := inlineChildren(t, nested[0]) - if len(values) != 1 { - t.Fatalf("got %d values, want 1", len(values)) - } - if got := values[0]["JsonPath"]; got != "(Object)|results|bindings|caseId|value" { - t.Errorf("value JsonPath = %q", got) - } -} diff --git a/sdk/mpr/writer_rest_test.go b/sdk/mpr/writer_rest_test.go deleted file mode 100644 index fc442c3765..0000000000 --- a/sdk/mpr/writer_rest_test.go +++ /dev/null @@ -1,447 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeConsumedRestServiceBasic(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-rest-id", - TypeName: "Rest$ConsumedRestService", - }, - ContainerID: "test-module-id", - Name: "PetStoreAPI", - BaseUrl: "https://petstore.swagger.io/v2", - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - assertField(t, raw, "$Type", "Rest$ConsumedRestService") - assertField(t, raw, "Name", "PetStoreAPI") - - // BaseUrl should be a ValueTemplate - baseUrl, ok := raw["BaseUrl"].(map[string]any) - if !ok { - t.Fatalf("BaseUrl: expected map, got %T", raw["BaseUrl"]) - } - assertField(t, baseUrl, "$Type", "Rest$ValueTemplate") - assertField(t, baseUrl, "Value", "https://petstore.swagger.io/v2") - - // AuthenticationScheme should be nil - if raw["AuthenticationScheme"] != nil { - t.Errorf("AuthenticationScheme: expected nil, got %v", raw["AuthenticationScheme"]) - } -} - -func TestSerializeConsumedRestServiceWithAuth(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-rest-auth-id", - }, - ContainerID: "test-module-id", - Name: "SecureAPI", - BaseUrl: "https://api.example.com", - Authentication: &model.RestAuthentication{ - Scheme: "Basic", - Username: "admin", - Password: "secret", - }, - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // AuthenticationScheme should be BasicAuthenticationScheme - authScheme, ok := raw["AuthenticationScheme"].(map[string]any) - if !ok { - t.Fatalf("AuthenticationScheme: expected map, got %T", raw["AuthenticationScheme"]) - } - assertField(t, authScheme, "$Type", "Rest$BasicAuthenticationScheme") - - // Username should be StringValue (literal) - username, ok := authScheme["Username"].(map[string]any) - if !ok { - t.Fatalf("Username: expected map, got %T", authScheme["Username"]) - } - assertField(t, username, "$Type", "Rest$StringValue") - assertField(t, username, "Value", "admin") - - // Password should be StringValue (literal) - password, ok := authScheme["Password"].(map[string]any) - if !ok { - t.Fatalf("Password: expected map, got %T", authScheme["Password"]) - } - assertField(t, password, "$Type", "Rest$StringValue") - assertField(t, password, "Value", "secret") -} - -func TestSerializeConsumedRestServiceWithConstantAuth(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-rest-const-auth", - }, - ContainerID: "test-module-id", - Name: "ConstAuthAPI", - BaseUrl: "https://api.example.com", - Authentication: &model.RestAuthentication{ - Scheme: "Basic", - Username: "$MyModule.ApiUser", - Password: "$MyModule.ApiPass", - }, - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - authScheme, ok := raw["AuthenticationScheme"].(map[string]any) - if !ok { - t.Fatalf("AuthenticationScheme: expected map, got %T", raw["AuthenticationScheme"]) - } - - // Username should be ConstantValue - username, ok := authScheme["Username"].(map[string]any) - if !ok { - t.Fatalf("Username: expected map, got %T", authScheme["Username"]) - } - assertField(t, username, "$Type", "Rest$ConstantValue") - assertField(t, username, "Value", "MyModule.ApiUser") -} - -func TestSerializeRestOperationGetWithParams(t *testing.T) { - op := &model.RestClientOperation{ - Name: "GetPet", - HttpMethod: "GET", - Path: "/pet/{petId}", - Parameters: []*model.RestClientParameter{ - {Name: "petId", DataType: "Integer"}, - }, - Headers: []*model.RestClientHeader{ - {Name: "Accept", Value: "application/json"}, - }, - ResponseType: "JSON", - Timeout: 30, - } - - result := dToM(serializeRestOperation(op)) - - assertField(t, result, "$Type", "Rest$RestOperation") - assertField(t, result, "Name", "GetPet") - - // Timeout - if v, ok := result["Timeout"].(int64); !ok || v != 30 { - t.Errorf("Timeout: expected 30, got %v", result["Timeout"]) - } - - // Method should be WithoutBody (GET) - method, ok := result["Method"].(bson.M) - if !ok { - t.Fatalf("Method: expected bson.M, got %T", result["Method"]) - } - if method["$Type"] != "Rest$RestOperationMethodWithoutBody" { - t.Errorf("Method.$Type: expected WithoutBody, got %v", method["$Type"]) - } - if method["HttpMethod"] != "Get" { - t.Errorf("Method.HttpMethod: expected Get, got %v", method["HttpMethod"]) - } - - // Path should be ValueTemplate - path, ok := result["Path"].(bson.M) - if !ok { - t.Fatalf("Path: expected bson.M, got %T", result["Path"]) - } - if path["Value"] != "/pet/{petId}" { - t.Errorf("Path.Value: expected /pet/{petId}, got %v", path["Value"]) - } - - // Parameters - params := extractBsonArray(result["Parameters"]) - if len(params) != 1 { - t.Fatalf("Parameters: expected 1, got %d", len(params)) - } - p0, ok := params[0].(bson.M) - if !ok { - t.Fatalf("Parameters[0]: expected bson.M, got %T", params[0]) - } - if p0["Name"] != "petId" { - t.Errorf("Parameter Name: expected petId, got %v", p0["Name"]) - } - dataType, ok := p0["DataType"].(bson.M) - if !ok { - t.Fatalf("Parameter DataType: expected bson.M, got %T", p0["DataType"]) - } - if dataType["$Type"] != "DataTypes$IntegerType" { - t.Errorf("Parameter DataType.$Type: expected IntegerAttributeType, got %v", dataType["$Type"]) - } - - // Headers - headers := extractBsonArray(result["Headers"]) - if len(headers) != 1 { - t.Fatalf("Headers: expected 1, got %d", len(headers)) - } - - // ResponseHandling (JSON uses NoResponseHandling with ContentType for compatibility) - respHandling, ok := result["ResponseHandling"].(bson.M) - if !ok { - t.Fatalf("ResponseHandling: expected bson.M, got %T", result["ResponseHandling"]) - } - if respHandling["$Type"] != "Rest$NoResponseHandling" { - t.Errorf("ResponseHandling.$Type: expected NoResponseHandling, got %v", respHandling["$Type"]) - } - if respHandling["ContentType"] != "application/json" { - t.Errorf("ResponseHandling.ContentType: expected application/json, got %v", respHandling["ContentType"]) - } -} - -func TestSerializeRestOperationPostWithBody(t *testing.T) { - op := &model.RestClientOperation{ - Name: "AddPet", - HttpMethod: "POST", - Path: "/pet", - BodyType: "JSON", - ResponseType: "JSON", - } - - result := dToM(serializeRestOperation(op)) - - // Method should be WithBody (POST) - method, ok := result["Method"].(bson.M) - if !ok { - t.Fatalf("Method: expected bson.M, got %T", result["Method"]) - } - if method["$Type"] != "Rest$RestOperationMethodWithBody" { - t.Errorf("Method.$Type: expected WithBody, got %v", method["$Type"]) - } - if method["HttpMethod"] != "Post" { - t.Errorf("Method.HttpMethod: expected Post, got %v", method["HttpMethod"]) - } - - // Body should be JsonBody (used instead of ImplicitMappingBody to avoid CE7247/CE0061) - body, ok := method["Body"].(bson.M) - if !ok { - t.Fatalf("Body: expected bson.M, got %T", method["Body"]) - } - if body["$Type"] != "Rest$JsonBody" { - t.Errorf("Body.$Type: expected JsonBody, got %v", body["$Type"]) - } -} - -func TestSerializeRestOperationNoResponse(t *testing.T) { - op := &model.RestClientOperation{ - Name: "DeletePet", - HttpMethod: "DELETE", - Path: "/pet/{petId}", - ResponseType: "NONE", - } - - result := dToM(serializeRestOperation(op)) - - respHandling, ok := result["ResponseHandling"].(bson.M) - if !ok { - t.Fatalf("ResponseHandling: expected bson.M, got %T", result["ResponseHandling"]) - } - if respHandling["$Type"] != "Rest$NoResponseHandling" { - t.Errorf("ResponseHandling.$Type: expected NoResponseHandling, got %v", respHandling["$Type"]) - } -} - -func TestSerializeRestOperationQueryParams(t *testing.T) { - op := &model.RestClientOperation{ - Name: "SearchPets", - HttpMethod: "GET", - Path: "/pet/findByStatus", - QueryParameters: []*model.RestClientParameter{ - {Name: "status", DataType: "String"}, - }, - ResponseType: "JSON", - } - - result := dToM(serializeRestOperation(op)) - - queryParams := extractBsonArray(result["QueryParameters"]) - if len(queryParams) != 1 { - t.Fatalf("QueryParameters: expected 1, got %d", len(queryParams)) - } - q0, ok := queryParams[0].(bson.M) - if !ok { - t.Fatalf("QueryParameters[0]: expected bson.M, got %T", queryParams[0]) - } - if q0["Name"] != "status" { - t.Errorf("QueryParam Name: expected status, got %v", q0["Name"]) - } - if q0["$Type"] != "Rest$QueryParameter" { - t.Errorf("QueryParam $Type: expected Rest$QueryParameter, got %v", q0["$Type"]) - } - - // ParameterUsage - usage, ok := q0["ParameterUsage"].(bson.M) - if !ok { - t.Fatalf("ParameterUsage: expected bson.M, got %T", q0["ParameterUsage"]) - } - if usage["$Type"] != "Rest$RequiredQueryParameterUsage" { - t.Errorf("ParameterUsage.$Type: expected RequiredQueryParameterUsage, got %v", usage["$Type"]) - } -} - -func TestHttpMethodToMendix(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"GET", "Get"}, - {"POST", "Post"}, - {"PUT", "Put"}, - {"PATCH", "Patch"}, - {"DELETE", "Delete"}, - {"HEAD", "Head"}, - {"OPTIONS", "Options"}, - } - for _, tc := range tests { - result := httpMethodToMendix(tc.input) - if result != tc.expected { - t.Errorf("httpMethodToMendix(%q): expected %q, got %q", tc.input, tc.expected, result) - } - } -} - -func TestSerializeConsumedRestServiceFullRoundtrip(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-roundtrip-id", - }, - ContainerID: "test-module-id", - Name: "PetStoreAPI", - Documentation: "Swagger Pet Store API", - BaseUrl: "https://petstore.swagger.io/v2", - Operations: []*model.RestClientOperation{ - { - Name: "ListPets", - HttpMethod: "GET", - Path: "/pet/findByStatus", - QueryParameters: []*model.RestClientParameter{ - {Name: "status", DataType: "String"}, - }, - Headers: []*model.RestClientHeader{ - {Name: "Accept", Value: "application/json"}, - }, - ResponseType: "JSON", - Timeout: 30, - }, - { - Name: "GetPet", - HttpMethod: "GET", - Path: "/pet/{petId}", - Parameters: []*model.RestClientParameter{ - {Name: "petId", DataType: "Integer"}, - }, - ResponseType: "JSON", - }, - { - Name: "AddPet", - HttpMethod: "POST", - Path: "/pet", - BodyType: "JSON", - ResponseType: "JSON", - }, - { - Name: "RemovePet", - HttpMethod: "DELETE", - Path: "/pet/{petId}", - ResponseType: "NONE", - Parameters: []*model.RestClientParameter{ - {Name: "petId", DataType: "Integer"}, - }, - }, - }, - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - // Verify the BSON can be deserialized - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Verify top-level structure - assertField(t, raw, "$Type", "Rest$ConsumedRestService") - assertField(t, raw, "Name", "PetStoreAPI") - assertField(t, raw, "Documentation", "Swagger Pet Store API") - - // Verify operations count - ops := extractBsonArray(raw["Operations"]) - if len(ops) != 4 { - t.Fatalf("Operations: expected 4, got %d", len(ops)) - } - - // Verify first operation - op0, ok := ops[0].(map[string]any) - if !ok { - t.Fatalf("Operations[0]: expected map, got %T", ops[0]) - } - assertField(t, op0, "Name", "ListPets") - - // Verify POST operation has WithBody method - op2, ok := ops[2].(map[string]any) - if !ok { - t.Fatalf("Operations[2]: expected map, got %T", ops[2]) - } - assertField(t, op2, "Name", "AddPet") - method2, ok := op2["Method"].(map[string]any) - if !ok { - t.Fatalf("Operations[2].Method: expected map, got %T", op2["Method"]) - } - assertField(t, method2, "$Type", "Rest$RestOperationMethodWithBody") - - // Verify Body is JsonBody - body2, ok := method2["Body"].(map[string]any) - if !ok { - t.Fatalf("Operations[2].Method.Body: expected map, got %T", method2["Body"]) - } - assertField(t, body2, "$Type", "Rest$JsonBody") - - // Verify DELETE operation has WithoutBody method - op3, ok := ops[3].(map[string]any) - if !ok { - t.Fatalf("Operations[3]: expected map, got %T", ops[3]) - } - method3, ok := op3["Method"].(map[string]any) - if !ok { - t.Fatalf("Operations[3].Method: expected map, got %T", op3["Method"]) - } - assertField(t, method3, "$Type", "Rest$RestOperationMethodWithoutBody") -} diff --git a/sdk/mpr/writer_rule_split_test.go b/sdk/mpr/writer_rule_split_test.go deleted file mode 100644 index fab267c06b..0000000000 --- a/sdk/mpr/writer_rule_split_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeExclusiveSplit_RuleSplitCondition_Roundtrip verifies that an -// ExclusiveSplit whose SplitCondition is a RuleSplitCondition survives -// serialize → BSON → parse without losing the rule reference or its parameter -// mappings. This is the BSON-level regression guard for the CE0117 Studio Pro -// error that appears when a rule-based decision is stored as an expression. -func TestSerializeExclusiveSplit_RuleSplitCondition_Roundtrip(t *testing.T) { - split := µflows.ExclusiveSplit{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Position: model.Point{X: 100, Y: 200}, - Size: model.Size{Width: 50, Height: 50}, - }, - Caption: "Module.IsEligible($Customer)", - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - SplitCondition: µflows.RuleSplitCondition{ - BaseElement: model.BaseElement{ID: "22222222-2222-2222-2222-222222222222"}, - RuleQualifiedName: "Module.IsEligible", - ParameterMappings: []*microflows.RuleCallParameterMapping{ - { - BaseElement: model.BaseElement{ID: "33333333-3333-3333-3333-333333333333"}, - ParameterName: "Module.IsEligible.Customer", - Argument: "$Customer", - }, - }, - }, - } - - doc := serializeMicroflowObject(split) - if doc == nil { - t.Fatal("serializeMicroflowObject returned nil") - } - - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("bson.Marshal failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal failed: %v", err) - } - - parsed := parseMicroflowObject(raw) - roundtripSplit, ok := parsed.(*microflows.ExclusiveSplit) - if !ok { - t.Fatalf("parsed object: got %T, want *microflows.ExclusiveSplit", parsed) - } - - ruleCond, ok := roundtripSplit.SplitCondition.(*microflows.RuleSplitCondition) - if !ok { - t.Fatalf("split condition after roundtrip: got %T, want *microflows.RuleSplitCondition", roundtripSplit.SplitCondition) - } - if ruleCond.RuleQualifiedName != "Module.IsEligible" { - t.Errorf("rule qualified name: got %q, want %q", ruleCond.RuleQualifiedName, "Module.IsEligible") - } - if len(ruleCond.ParameterMappings) != 1 { - t.Fatalf("parameter mappings: got %d, want 1", len(ruleCond.ParameterMappings)) - } - pm := ruleCond.ParameterMappings[0] - if pm.ParameterName != "Module.IsEligible.Customer" { - t.Errorf("parameter name: got %q, want %q", pm.ParameterName, "Module.IsEligible.Customer") - } - if pm.Argument != "$Customer" { - t.Errorf("argument: got %q, want %q", pm.Argument, "$Customer") - } -} - -// TestSerializeExclusiveSplit_ExpressionSplitCondition_Roundtrip is the -// complementary baseline that ensures the existing expression path still -// roundtrips correctly after the Rule branch was added to the writer switch. -func TestSerializeExclusiveSplit_ExpressionSplitCondition_Roundtrip(t *testing.T) { - split := µflows.ExclusiveSplit{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "44444444-4444-4444-4444-444444444444"}, - Position: model.Point{X: 100, Y: 200}, - Size: model.Size{Width: 50, Height: 50}, - }, - Caption: "$Var = 'x'", - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - SplitCondition: µflows.ExpressionSplitCondition{ - BaseElement: model.BaseElement{ID: "55555555-5555-5555-5555-555555555555"}, - Expression: "$Var = 'x'", - }, - } - - doc := serializeMicroflowObject(split) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("bson.Marshal failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal failed: %v", err) - } - - parsed := parseMicroflowObject(raw) - roundtripSplit := parsed.(*microflows.ExclusiveSplit) - - exprCond, ok := roundtripSplit.SplitCondition.(*microflows.ExpressionSplitCondition) - if !ok { - t.Fatalf("split condition after roundtrip: got %T, want *microflows.ExpressionSplitCondition", roundtripSplit.SplitCondition) - } - if exprCond.Expression != "$Var = 'x'" { - t.Errorf("expression: got %q, want %q", exprCond.Expression, "$Var = 'x'") - } -} diff --git a/sdk/mpr/writer_security.go b/sdk/mpr/writer_security.go deleted file mode 100644 index d51ba97554..0000000000 --- a/sdk/mpr/writer_security.go +++ /dev/null @@ -1,1843 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// GetRawUnitBytes reads the raw BSON bytes for a unit by ID. -// This returns unprocessed bytes suitable for raw BSON patching. -func (r *Reader) GetRawUnitBytes(id model.ID) ([]byte, error) { - var contents []byte - var err error - - if r.version == MPRVersionV2 { - contents, err = r.readMprContents(string(id)) - if err != nil { - return nil, fmt.Errorf("failed to read unit contents: %w", err) - } - } else { - unitIDBlob := uuidToBlob(string(id)) - row := r.db.QueryRow("SELECT Contents FROM Unit WHERE UnitID = ?", unitIDBlob) - err = row.Scan(&contents) - if err != nil { - return nil, fmt.Errorf("failed to read unit from database: %w", err) - } - } - - contents, err = r.resolveContents(string(id), contents) - if err != nil { - return nil, err - } - - return contents, nil -} - -// readPatchWrite is the core helper: reads raw BSON, applies a patch function, writes back. -func (w *Writer) readPatchWrite(unitID model.ID, patchFn func(doc bson.D) (bson.D, error)) error { - rawBytes, err := w.reader.GetRawUnitBytes(unitID) - if err != nil { - return fmt.Errorf("failed to read unit %s: %w", unitID, err) - } - - var doc bson.D - if err := bson.Unmarshal(rawBytes, &doc); err != nil { - return fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - doc, err = patchFn(doc) - if err != nil { - return err - } - - newBytes, err := marshalUnitIDFirst(doc) - if err != nil { - return fmt.Errorf("failed to marshal BSON: %w", err) - } - - return w.updateUnit(string(unitID), newBytes) -} - -// setBsonField sets a top-level field in a bson.D, adding it if not found. -func setBsonField(doc bson.D, key string, value any) bson.D { - for i, elem := range doc { - if elem.Key == key { - doc[i].Value = value - return doc - } - } - return append(doc, bson.E{Key: key, Value: value}) -} - -// bsonStringField returns a top-level string field of a document, or "" when the -// field is absent (which is how an unconstrained access rule reads). -func bsonStringField(doc bson.D, key string) string { - for _, elem := range doc { - if elem.Key == key { - return bsonutil.String(elem.Value, key) - } - } - return "" -} - -// getBsonArray returns the Mendix-style array for a field (skipping the int32 marker). -func getBsonArray(doc bson.D, key string) bson.A { - for _, elem := range doc { - if elem.Key == key { - if arr, ok := elem.Value.(bson.A); ok { - return arr - } - } - } - return nil -} - -// makeMendixArray builds a Mendix-style array: int32(1) marker followed by items. -func makeMendixArray(items ...any) bson.A { - arr := bson.A{int32(1)} - arr = append(arr, items...) - return arr -} - -// makeMendixStringArray builds a Mendix-style array of strings. -func makeMendixStringArray(items []string) bson.A { - arr := bson.A{int32(1)} - for _, s := range items { - arr = append(arr, s) - } - return arr -} - -// allowedModuleRolesArray builds a Mendix-style AllowedModuleRoles BSON array -// from a slice of model.IDs. Returns the empty array marker if no roles are set. -func allowedModuleRolesArray(roles []model.ID) bson.A { - arr := bson.A{int32(1)} - for _, r := range roles { - arr = append(arr, string(r)) - } - return arr -} - -// ============================================================================ -// Microflow/Page: AllowedModuleRoles -// ============================================================================ - -// UpdateAllowedRoles patches the AllowedModuleRoles BSON field on a unit (microflow or page). -// roles should be qualified name strings like "Module.RoleName". -func (w *Writer) UpdateAllowedRoles(unitID model.ID, roles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "AllowedModuleRoles", makeMendixStringArray(roles)), nil - }) -} - -// UpdatePublishedRestServiceRoles patches the AllowedRoles BSON field on a -// Rest$PublishedRestService unit. Note: REST uses "AllowedRoles" while -// microflows/pages/OData use "AllowedModuleRoles". -func (w *Writer) UpdatePublishedRestServiceRoles(unitID model.ID, roles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "AllowedRoles", makeMendixStringArray(roles)), nil - }) -} - -// RemoveFromAllowedRoles removes a role from the AllowedModuleRoles BSON field on a unit. -// Returns true if the role was found and removed. -func (w *Writer) RemoveFromAllowedRoles(unitID model.ID, roleName string) (bool, error) { - removed := false - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - for _, f := range doc { - if f.Key != "AllowedModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - return doc, nil - } - var remaining bson.A - for _, item := range arr { - if s, ok := item.(string); ok && s == roleName { - removed = true - continue - } - remaining = append(remaining, item) - } - if removed { - return setBsonField(doc, "AllowedModuleRoles", remaining), nil - } - return doc, nil - } - return doc, nil - }) - return removed, err -} - -// ============================================================================ -// Module Roles: CREATE/DROP on Security$ModuleSecurity -// ============================================================================ - -// AddModuleRole adds a new module role to the module's Security$ModuleSecurity unit. -// If a role with the same name (case-insensitive) already exists, the existing role's -// Name is overwritten with the caller-supplied casing and Description is updated. -// Mendix Studio Pro rejects case-insensitive duplicate role names with CE0123, so -// merging into the existing entry matches runtime semantics — and preserves the -// caller's casing for downstream case-sensitive lookups (e.g., GRANT ACCESS TO x.user). -func (w *Writer) AddModuleRole(unitID model.ID, roleName, description string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - // Get existing ModuleRoles array - existing := getBsonArray(doc, "ModuleRoles") - if existing == nil { - existing = bson.A{int32(1)} - } - - // If a case-insensitive duplicate already exists, overwrite its Name and - // Description with the caller's values. This keeps the ID stable (any - // references to it remain valid) while adopting the newly-requested casing. - for i, item := range existing { - role, ok := item.(bson.D) - if !ok { - continue - } - matched := false - for _, field := range role { - if field.Key == "Name" { - if name, ok := field.Value.(string); ok && strings.EqualFold(name, roleName) { - matched = true - } - break - } - } - if !matched { - continue - } - for j, field := range role { - switch field.Key { - case "Name": - role[j].Value = roleName - case "Description": - if description != "" { - role[j].Value = description - } - } - } - existing[i] = role - return setBsonField(doc, "ModuleRoles", existing), nil - } - - // Build the new role BSON document - newRole := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Security$ModuleRole"}, - {Key: "Name", Value: roleName}, - {Key: "Description", Value: description}, - } - - existing = append(existing, newRole) - return setBsonField(doc, "ModuleRoles", existing), nil - }) -} - -// RemoveModuleRole removes a module role by name from the module's Security$ModuleSecurity unit. -func (w *Writer) RemoveModuleRole(unitID model.ID, roleName string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "ModuleRoles") - if existing == nil { - return doc, nil - } - - var filtered bson.A - for _, item := range existing { - // Keep the int32 marker - if _, ok := item.(int32); ok { - filtered = append(filtered, item) - continue - } - // Check if this role matches the name - if roleDoc, ok := item.(bson.D); ok { - name := "" - for _, f := range roleDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name == roleName { - continue // Skip this role (remove it) - } - } - filtered = append(filtered, item) - } - - return setBsonField(doc, "ModuleRoles", filtered), nil - }) -} - -// ============================================================================ -// Project Security: ALTER, User Roles, Demo Users -// ============================================================================ - -// SetProjectSecurityLevel patches the SecurityLevel field on Security$ProjectSecurity. -func (w *Writer) SetProjectSecurityLevel(unitID model.ID, level string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "SecurityLevel", level), nil - }) -} - -// SetProjectDemoUsersEnabled patches the EnableDemoUsers field on Security$ProjectSecurity. -func (w *Writer) SetProjectDemoUsersEnabled(unitID model.ID, enabled bool) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "EnableDemoUsers", enabled), nil - }) -} - -// SetProjectGuestAccess patches EnableGuestAccess — and, when guestUserRole is -// non-empty, GuestUserRole — on Security$ProjectSecurity. An empty role leaves -// the stored one alone so that toggling access off and on does not lose it. -func (w *Writer) SetProjectGuestAccess(unitID model.ID, enabled bool, guestUserRole string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - doc = setBsonField(doc, "EnableGuestAccess", enabled) - if guestUserRole != "" { - doc = setBsonField(doc, "GuestUserRole", guestUserRole) - } - return doc, nil - }) -} - -// AddUserRole adds a new user role to Security$ProjectSecurity. -func (w *Writer) AddUserRole(unitID model.ID, name string, moduleRoles []string, manageAllRoles bool) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - newRole := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Security$UserRole"}, - {Key: "Name", Value: name}, - {Key: "Description", Value: ""}, - {Key: "ModuleRoles", Value: makeMendixStringArray(moduleRoles)}, - {Key: "ManageAllRoles", Value: manageAllRoles}, - {Key: "ManageUsersWithoutRoles", Value: false}, - {Key: "ManageableRoles", Value: makeMendixArray()}, - {Key: "CheckSecurity", Value: false}, - } - - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - existing = bson.A{int32(1)} - } - existing = append(existing, newRole) - return setBsonField(doc, "UserRoles", existing), nil - }) -} - -// AlterUserRoleModuleRoles adds or removes module roles from a user role in Security$ProjectSecurity. -func (w *Writer) AlterUserRoleModuleRoles(unitID model.ID, userRoleName string, add bool, moduleRoles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - return doc, fmt.Errorf("no UserRoles array found") - } - - found := false - for i, item := range existing { - roleDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range roleDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != userRoleName { - continue - } - found = true - - // Get current module roles - var currentRoles []string - for _, f := range roleDoc { - if f.Key == "ModuleRoles" { - if arr, ok := f.Value.(bson.A); ok { - for _, r := range arr { - if s, ok := r.(string); ok { - currentRoles = append(currentRoles, s) - } - } - } - break - } - } - - if add { - // Add new roles, skip duplicates - existingSet := make(map[string]bool) - for _, r := range currentRoles { - existingSet[r] = true - } - for _, r := range moduleRoles { - if !existingSet[r] { - currentRoles = append(currentRoles, r) - } - } - } else { - // Remove specified roles - removeSet := make(map[string]bool) - for _, r := range moduleRoles { - removeSet[r] = true - } - var filtered []string - for _, r := range currentRoles { - if !removeSet[r] { - filtered = append(filtered, r) - } - } - currentRoles = filtered - } - - // Update the ModuleRoles field in the role document - for j, f := range roleDoc { - if f.Key == "ModuleRoles" { - roleDoc[j].Value = makeMendixStringArray(currentRoles) - break - } - } - existing[i] = roleDoc - break - } - - if !found { - return doc, fmt.Errorf("user role not found: %s", userRoleName) - } - - return setBsonField(doc, "UserRoles", existing), nil - }) -} - -// RemoveModuleRoleFromAllUserRoles removes a qualified module role (e.g., "Module.RoleName") -// from every user role's ModuleRoles list in Security$ProjectSecurity. -// Returns the number of user roles that were modified. -func (w *Writer) RemoveModuleRoleFromAllUserRoles(unitID model.ID, qualifiedRole string) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - return doc, nil - } - - for i, item := range existing { - roleDoc, ok := item.(bson.D) - if !ok { - continue - } - - // Find and filter ModuleRoles - for j, f := range roleDoc { - if f.Key != "ModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - var filtered bson.A - found := false - for _, r := range arr { - if s, ok := r.(string); ok && s == qualifiedRole { - found = true - continue // Remove this role - } - filtered = append(filtered, r) - } - if found { - if len(filtered) == 0 { - roleDoc[j].Value = bson.A{int32(1)} // Empty Mendix array - } else { - roleDoc[j].Value = makeMendixStringArray(bsonAToStrings(filtered)) - } - existing[i] = roleDoc - modified++ - } - break - } - } - - return setBsonField(doc, "UserRoles", existing), nil - }) - return modified, err -} - -// bsonAToStrings converts a bson.A of strings to []string. -func bsonAToStrings(a bson.A) []string { - var result []string - for _, v := range a { - if s, ok := v.(string); ok { - result = append(result, s) - } - } - return result -} - -// RemoveUserRole removes a user role by name from Security$ProjectSecurity. -func (w *Writer) RemoveUserRole(unitID model.ID, name string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - return doc, nil - } - - var filtered bson.A - for _, item := range existing { - if _, ok := item.(int32); ok { - filtered = append(filtered, item) - continue - } - if roleDoc, ok := item.(bson.D); ok { - roleName := "" - for _, f := range roleDoc { - if f.Key == "Name" { - roleName = bsonutil.String(f.Value, "Name") - break - } - } - if roleName == name { - continue // Remove this one - } - } - filtered = append(filtered, item) - } - - return setBsonField(doc, "UserRoles", filtered), nil - }) -} - -// AddDemoUser adds a new demo user to Security$ProjectSecurity. -func (w *Writer) AddDemoUser(unitID model.ID, userName, password, entity string, userRoles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - newUser := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Security$DemoUserImpl"}, - {Key: "UserName", Value: userName}, - {Key: "Password", Value: password}, - {Key: "Entity", Value: entity}, - {Key: "UserRoles", Value: makeMendixStringArray(userRoles)}, - } - - existing := getBsonArray(doc, "DemoUsers") - if existing == nil { - existing = bson.A{int32(1)} - } - existing = append(existing, newUser) - return setBsonField(doc, "DemoUsers", existing), nil - }) -} - -// RemoveDemoUser removes a demo user by name from Security$ProjectSecurity. -func (w *Writer) RemoveDemoUser(unitID model.ID, userName string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "DemoUsers") - if existing == nil { - return doc, nil - } - - var filtered bson.A - for _, item := range existing { - if _, ok := item.(int32); ok { - filtered = append(filtered, item) - continue - } - if userDoc, ok := item.(bson.D); ok { - name := "" - for _, f := range userDoc { - if f.Key == "UserName" { - name = bsonutil.String(f.Value, "UserName") - break - } - } - if name == userName { - continue // Remove - } - } - filtered = append(filtered, item) - } - - return setBsonField(doc, "DemoUsers", filtered), nil - }) -} - -// ============================================================================ -// Entity Access: GRANT/REVOKE on DomainModels$DomainModel -// ============================================================================ - -// EntityMemberAccess describes per-member access rights for an access rule. -type EntityMemberAccess struct { - AttributeRef string // "Module.Entity.AttrName" or "" - AssociationRef string // "Module.AssocName" or "" - AccessRights string // "None", "ReadOnly", "ReadWrite" -} - -// AddEntityAccessRule adds or updates an access rule for the given roles on an entity. -// If an existing rule with the same AllowedModuleRoles is found, it is updated in place. -// If memberAccesses is non-nil, explicit per-member access entries are created; -// otherwise an empty MemberAccesses array is used (DefaultMemberAccessRights applies to all). -// Note: Mendix does not have AllowRead/AllowWrite properties on AccessRule — read/write -// access is determined entirely by DefaultMemberAccessRights and MemberAccesses. -func (w *Writer) AddEntityAccessRule(unitID model.ID, entityName string, roleNames []string, - allowCreate, allowDelete bool, - defaultMemberAccess string, xpathConstraint string, - memberAccesses []EntityMemberAccess) error { - - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, fmt.Errorf("no Entities array found in domain model") - } - - found := false - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range entityDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != entityName { - continue - } - found = true - - // Build MemberAccesses BSON - var memberAccessesBson bson.A - if len(memberAccesses) > 0 { - memberAccessesBson = bson.A{int32(3)} // storageListType 3 - for _, ma := range memberAccesses { - maDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: ma.AccessRights}, - } - if ma.AttributeRef != "" { - maDoc = append(maDoc, bson.E{Key: "Attribute", Value: ma.AttributeRef}) - } - if ma.AssociationRef != "" { - maDoc = append(maDoc, bson.E{Key: "Association", Value: ma.AssociationRef}) - } - memberAccessesBson = append(memberAccessesBson, maDoc) - } - } else { - memberAccessesBson = bson.A{int32(3)} // empty — DefaultMemberAccessRights applies - } - - // Get existing AccessRules - var accessRules bson.A - accessRulesIdx := -1 - for j, f := range entityDoc { - if f.Key == "AccessRules" { - if arr, ok := f.Value.(bson.A); ok { - accessRules = arr - } - accessRulesIdx = j - break - } - } - if accessRules == nil { - accessRules = bson.A{int32(3)} // storageListType 3 - } - - // Check for an existing rule with the same AllowedModuleRoles AND the - // same XPathConstraint — upsert. - // - // The constraint belongs in the key because Mendix combines the rights - // of every rule naming a given module role ("Rules are additive", - // refguide/access-rules), so two constraints for one role are two - // legitimate rules. Matching on roles alone folded the second GRANT - // onto the first rule and overwrote its constraint, destroying it - // silently (mendixlabs/mxcli#936). An empty constraint is a value - // here, not a wildcard. - existingIdx := -1 - existingID := "" - for ri, ruleItem := range accessRules { - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - continue - } - if rolesMatch(ruleDoc, roleNames) && - bsonStringField(ruleDoc, "XPathConstraint") == xpathConstraint { - existingIdx = ri - // Preserve the existing rule's $ID - for _, rf := range ruleDoc { - if rf.Key == "$ID" { - existingID = extractBsonIDValue(rf.Value) - break - } - } - break - } - } - - // Build the rule - ruleID := generateUUID() - if existingID != "" { - ruleID = existingID - } - newRule := bson.D{ - {Key: "$ID", Value: idToBsonBinary(ruleID)}, - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowedModuleRoles", Value: makeMendixStringArray(roleNames)}, - {Key: "AllowCreate", Value: allowCreate}, - {Key: "AllowDelete", Value: allowDelete}, - {Key: "DefaultMemberAccessRights", Value: defaultMemberAccess}, - {Key: "XPathConstraint", Value: xpathConstraint}, - {Key: "XPathConstraintCaption", Value: ""}, - {Key: "Documentation", Value: ""}, - {Key: "MemberAccesses", Value: memberAccessesBson}, - } - - if existingIdx >= 0 { - // Merge additively: keep the higher access level for each member - // and OR the structural permissions (Create/Delete). - existingRule, _ := accessRules[existingIdx].(bson.D) - newRule = mergeAccessRule(existingRule, newRule) - accessRules[existingIdx] = newRule - } else { - // Append new rule - accessRules = append(accessRules, newRule) - } - - if accessRulesIdx >= 0 { - entityDoc[accessRulesIdx].Value = accessRules - } else { - entityDoc = append(entityDoc, bson.E{Key: "AccessRules", Value: accessRules}) - } - entitiesArr[i] = entityDoc - break - } - - if !found { - return doc, fmt.Errorf("entity not found: %s", entityName) - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) -} - -// rolesMatch checks if a rule's AllowedModuleRoles matches the given role names (order-independent). -func rolesMatch(ruleDoc bson.D, roleNames []string) bool { - for _, f := range ruleDoc { - if f.Key != "AllowedModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - return false - } - // Extract role strings from the Mendix array (skip int32 markers) - var existing []string - for _, item := range arr { - if s, ok := item.(string); ok { - existing = append(existing, s) - } - } - if len(existing) != len(roleNames) { - return false - } - // Build set for comparison - set := make(map[string]bool, len(existing)) - for _, s := range existing { - set[s] = true - } - for _, rn := range roleNames { - if !set[rn] { - return false - } - } - return true - } - return false -} - -// accessRightsLevel returns a numeric level for access rights comparison. -// None=0 < ReadOnly=1 < ReadWrite=2. -// accessRightsLevel ranks member access rights. The lattice is shared with the -// codec engine (mdl/types) so the two cannot drift: both merge a GRANT by taking -// the higher of the stored and incoming rights. -func accessRightsLevel(s string) int { - return types.AccessRightsLevel(s) -} - -// mergeAccessRule merges a new access rule into an existing one additively. -// AllowCreate/AllowDelete are OR'd. MemberAccesses keep the higher access level. -// XPathConstraint is replaced only if the new rule specifies one. -func mergeAccessRule(existing, newRule bson.D) bson.D { - // Extract existing MemberAccesses keyed by attribute/association ref - existingMembers := make(map[string]string) // ref -> access rights - for _, f := range existing { - if f.Key != "MemberAccesses" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - for _, item := range arr { - maDoc, ok := item.(bson.D) - if !ok { - continue - } - var ref, rights string - for _, mf := range maDoc { - switch mf.Key { - case "Attribute": - ref = bsonutil.String(mf.Value, "Attribute") - case "Association": - ref = bsonutil.String(mf.Value, "Association") - case "AccessRights": - rights = bsonutil.String(mf.Value, "AccessRights") - } - } - if ref != "" { - existingMembers[ref] = rights - } - } - } - - // Extract existing AllowCreate/AllowDelete and DefaultMemberAccessRights - var existCreate, existDelete bool - var existDefault string - for _, f := range existing { - switch f.Key { - case "AllowCreate": - existCreate = bsonutil.Bool(f.Value, "AllowCreate") - case "AllowDelete": - existDelete = bsonutil.Bool(f.Value, "AllowDelete") - case "DefaultMemberAccessRights": - existDefault = bsonutil.String(f.Value, "DefaultMemberAccessRights") - } - } - - // Merge into newRule - for i, f := range newRule { - switch f.Key { - case "AllowCreate": - newVal := bsonutil.Bool(f.Value, "AllowCreate") - newRule[i].Value = newVal || existCreate - case "AllowDelete": - newVal := bsonutil.Bool(f.Value, "AllowDelete") - newRule[i].Value = newVal || existDelete - case "DefaultMemberAccessRights": - newVal := bsonutil.String(f.Value, "DefaultMemberAccessRights") - if accessRightsLevel(existDefault) > accessRightsLevel(newVal) { - newRule[i].Value = existDefault - } - // XPathConstraint is not merged: it is part of the key that selected this - // rule, so the stored and incoming values are equal by construction. It - // used to be inherited from the stored rule when the new GRANT had no - // WHERE, which quietly constrained access the user had asked to be - // unconstrained; such a GRANT now matches (or creates) the unconstrained - // rule instead. - case "MemberAccesses": - arr, ok := f.Value.(bson.A) - if !ok { - break - } - for j, item := range arr { - maDoc, ok := item.(bson.D) - if !ok { - continue - } - var ref, newRights string - for _, mf := range maDoc { - switch mf.Key { - case "Attribute": - ref = bsonutil.String(mf.Value, "Attribute") - case "Association": - ref = bsonutil.String(mf.Value, "Association") - case "AccessRights": - newRights = bsonutil.String(mf.Value, "AccessRights") - } - } - if ref == "" { - continue - } - if existRights, ok := existingMembers[ref]; ok { - if accessRightsLevel(existRights) > accessRightsLevel(newRights) { - // Upgrade to existing higher level - for k, mf := range maDoc { - if mf.Key == "AccessRights" { - maDoc[k].Value = existRights - break - } - } - arr[j] = maDoc - } - } - } - newRule[i].Value = arr - } - } - - return newRule -} - -// RemoveEntityAccessRule removes the given roles from access rules on an entity. -// For multi-role rules, only the specified roles are removed from the rule's role list. -// If a rule has no remaining roles after removal, the entire rule is deleted. -// Returns the number of rules that were modified or removed. -func (w *Writer) RemoveEntityAccessRule(unitID model.ID, entityName string, roleNames []string) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, fmt.Errorf("no Entities array found in domain model") - } - - removeRoles := make(map[string]bool) - for _, r := range roleNames { - removeRoles[r] = true - } - - found := false - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range entityDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != entityName { - continue - } - found = true - - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - - var filtered bson.A - for _, ruleItem := range arr { - if _, ok := ruleItem.(int32); ok { - filtered = append(filtered, ruleItem) - continue - } - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - filtered = append(filtered, ruleItem) - continue - } - - keepRule, wasModified := removeRolesFromAccessRule(ruleDoc, removeRoles) - if wasModified { - modified++ - } - if keepRule { - filtered = append(filtered, ruleDoc) - } - } - - entityDoc[j].Value = filtered - break - } - - entitiesArr[i] = entityDoc - break - } - - if !found { - return doc, fmt.Errorf("entity not found: %s", entityName) - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - return modified, err -} - -// removeRolesFromAccessRule removes the specified roles from a rule's AllowedModuleRoles. -// Returns (keepRule, wasModified). keepRule is false if no roles remain (rule should be deleted). -func removeRolesFromAccessRule(ruleDoc bson.D, removeRoles map[string]bool) (bool, bool) { - for k, rf := range ruleDoc { - if rf.Key != "AllowedModuleRoles" { - continue - } - rolesArr, ok := rf.Value.(bson.A) - if !ok { - return true, false - } - - var remaining bson.A - removed := false - roleCount := 0 - for _, rr := range rolesArr { - if _, ok := rr.(int32); ok { - remaining = append(remaining, rr) // keep array marker - continue - } - if s, ok := rr.(string); ok { - if removeRoles[s] { - removed = true - } else { - remaining = append(remaining, rr) - roleCount++ - } - } - } - - if !removed { - return true, false // no change - } - if roleCount == 0 { - return false, true // delete entire rule - } - ruleDoc[k].Value = remaining - return true, true // keep rule with fewer roles - } - return true, false -} - -// EntityAccessRevocation describes what to revoke from an entity access rule. -type EntityAccessRevocation struct { - RevokeCreate bool - RevokeDelete bool - // Members to fully revoke (set to None) - RevokeReadMembers []string // attribute/association refs to set to None - // Members to downgrade from ReadWrite to ReadOnly - RevokeWriteMembers []string // attribute/association refs to downgrade - // Revoke all read/write access - RevokeReadAll bool - RevokeWriteAll bool -} - -// RevokeEntityMemberAccess performs a partial revoke on an existing access rule. -// It downgrades or removes specific rights without deleting the entire rule. -// Returns the number of rules modified. -func (w *Writer) RevokeEntityMemberAccess(unitID model.ID, entityName string, roleNames []string, revocation EntityAccessRevocation) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, fmt.Errorf("no Entities array found in domain model") - } - - found := false - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range entityDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != entityName { - continue - } - found = true - - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - - for ri, ruleItem := range arr { - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - continue - } - if !rolesMatch(ruleDoc, roleNames) { - continue - } - - // Found matching rule — apply revocations - ruleModified := false - - // Build sets for quick lookup - revokeReadSet := make(map[string]bool) - for _, ref := range revocation.RevokeReadMembers { - revokeReadSet[ref] = true - } - revokeWriteSet := make(map[string]bool) - for _, ref := range revocation.RevokeWriteMembers { - revokeWriteSet[ref] = true - } - - for k, rf := range ruleDoc { - switch rf.Key { - case "AllowCreate": - if revocation.RevokeCreate { - ruleDoc[k].Value = false - ruleModified = true - } - case "AllowDelete": - if revocation.RevokeDelete { - ruleDoc[k].Value = false - ruleModified = true - } - case "DefaultMemberAccessRights": - if revocation.RevokeReadAll { - ruleDoc[k].Value = "None" - ruleModified = true - } else if revocation.RevokeWriteAll { - cur := bsonutil.String(rf.Value, "DefaultMemberAccessRights") - if cur == "ReadWrite" { - ruleDoc[k].Value = "ReadOnly" - ruleModified = true - } - } - case "MemberAccesses": - maArr, ok := rf.Value.(bson.A) - if !ok { - break - } - for mi, maItem := range maArr { - maDoc, ok := maItem.(bson.D) - if !ok { - continue - } - var ref, rights string - for _, mf := range maDoc { - switch mf.Key { - case "Attribute": - ref = bsonutil.String(mf.Value, "Attribute") - case "Association": - ref = bsonutil.String(mf.Value, "Association") - case "AccessRights": - rights = bsonutil.String(mf.Value, "AccessRights") - } - } - if ref == "" { - continue - } - - newRights := rights - if revocation.RevokeReadAll || revokeReadSet[ref] { - newRights = "None" - } else if revocation.RevokeWriteAll || revokeWriteSet[ref] { - if rights == "ReadWrite" { - newRights = "ReadOnly" - } - } - - if newRights != rights { - for mk, mf := range maDoc { - if mf.Key == "AccessRights" { - maDoc[mk].Value = newRights - break - } - } - maArr[mi] = maDoc - ruleModified = true - } - } - ruleDoc[k].Value = maArr - } - } - - if ruleModified { - arr[ri] = ruleDoc - modified++ - } - break - } - - entityDoc[j].Value = arr - break - } - - entitiesArr[i] = entityDoc - break - } - - if !found { - return doc, fmt.Errorf("entity not found: %s", entityName) - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - return modified, err -} - -// RemoveRoleFromAllEntities removes the given role from all entity access rules in a domain model. -// Used by DROP MODULE ROLE cascade. Returns the number of rules modified/removed. -func (w *Writer) RemoveRoleFromAllEntities(unitID model.ID, roleName string) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, nil // no entities, nothing to do - } - - removeRoles := map[string]bool{roleName: true} - - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - - var filtered bson.A - for _, ruleItem := range arr { - if _, ok := ruleItem.(int32); ok { - filtered = append(filtered, ruleItem) - continue - } - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - filtered = append(filtered, ruleItem) - continue - } - - keepRule, wasModified := removeRolesFromAccessRule(ruleDoc, removeRoles) - if wasModified { - modified++ - } - if keepRule { - filtered = append(filtered, ruleDoc) - } - } - - entityDoc[j].Value = filtered - break - } - - entitiesArr[i] = entityDoc - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - return modified, err -} - -// ReconcileMemberAccesses reconciles MemberAccesses on all AccessRules within a domain model -// to match the current entity structure. It adds entries for new attributes/associations and -// removes entries for deleted ones. Returns the number of rules modified. -func (w *Writer) ReconcileMemberAccesses(unitID model.ID, moduleName string) (int, error) { - modified := 0 - - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, nil - } - - // Collect all association names in this module (from Associations + CrossAssociations) - assocNames := map[string]bool{} - assocArr := getBsonArray(doc, "Associations") - for _, item := range assocArr { - assocDoc, ok := item.(bson.D) - if !ok { - continue - } - for _, f := range assocDoc { - if f.Key == "Name" { - if name, ok := f.Value.(string); ok { - assocNames[name] = true - } - break - } - } - } - crossArr := getBsonArray(doc, "CrossAssociations") - for _, item := range crossArr { - crossDoc, ok := item.(bson.D) - if !ok { - continue - } - for _, f := range crossDoc { - if f.Key == "Name" { - if name, ok := f.Value.(string); ok { - assocNames[name] = true - } - break - } - } - } - - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - - // Get entity name - entityName := "" - for _, f := range entityDoc { - if f.Key == "Name" { - entityName = bsonutil.String(f.Value, "Name") - break - } - } - if entityName == "" { - continue - } - - // The attributes this entity's rules must cover: its OWN and those it - // INHERITS from a generalization in this module, each qualified - // against the entity that DECLARES it — which is what Mendix stores, - // and what makes an inherited entry's reference name an ancestor - // rather than this entity. - // - // Collecting only the entity's own attributes left every - // specialization's rule short of a member as soon as the - // generalization gained one, which Mendix reports as CE0066 "Entity - // access is out of date" — and `UPDATE SECURITY`, the command that - // exists to repair it, found nothing missing and reported "All entity - // access rules are up to date" over a project mx check rejects - // (mendixlabs/mxcli#1047, reported against 0.21.0). The codec engine - // had the same defect in the same shape; both are fixed together, - // because a fix in one of these parallel writers leaves the other - // latent until something switches engines. - // - // Keyed by full reference rather than bare name: the compare pass - // below preserves any reference not qualified against this entity, so - // a bare-name key would mark an inherited entry uncovered and ADD a - // second copy of a member the rule already has. - expectedAttrs := entityAttrsInChain(entitiesArr, entityName, moduleName) - expectedAttrRefs := map[string]bool{} - calculatedAttrRefs := map[string]bool{} - for _, ea := range expectedAttrs { - expectedAttrRefs[ea.ref] = true - if ea.calculated { - calculatedAttrRefs[ea.ref] = true - } - } - - // Collect associations where this entity is the FROM entity (ParentPointer). - // In Mendix BSON, ParentPointer = FROM entity (FK owner), ChildPointer = TO entity. - // MemberAccess for associations is only required on the FROM (owner) side. - entityID := "" - for _, f := range entityDoc { - if f.Key == "$ID" { - entityID = extractBsonIDValue(f.Value) - break - } - } - entityAssocNames := map[string]bool{} - - // Check for system associations (HasOwner, HasChangedBy) in NoGeneralization. - // These add implicit System.owner / System.changedBy associations that - // require MemberAccess entries. Stored as full refs (e.g., "System.owner"). - systemAssocRefs := map[string]bool{} - for _, f := range entityDoc { - if f.Key == "Generalization" || f.Key == "MaybeGeneralization" { - if genDoc, ok := f.Value.(bson.D); ok { - for _, gf := range genDoc { - if gf.Key == "$Type" { - if gt, ok := gf.Value.(string); ok && gt == "DomainModels$NoGeneralization" { - for _, ngf := range genDoc { - switch ngf.Key { - case "HasOwner": - if v, ok := ngf.Value.(bool); ok && v { - systemAssocRefs["System.owner"] = true - } - case "HasChangedBy": - if v, ok := ngf.Value.(bool); ok && v { - systemAssocRefs["System.changedBy"] = true - } - } - } - } - } - } - } - break - } - } - for _, aItem := range assocArr { - aDoc, ok := aItem.(bson.D) - if !ok { - continue - } - aParentID := "" - aName := "" - for _, f := range aDoc { - switch f.Key { - case "ParentPointer": - aParentID = extractBsonIDValue(f.Value) - case "Name": - aName = bsonutil.String(f.Value, "Name") - } - } - if aParentID == entityID && aName != "" { - entityAssocNames[aName] = true - } - } - for _, caItem := range crossArr { - caDoc, ok := caItem.(bson.D) - if !ok { - continue - } - parentID := "" - caName := "" - for _, f := range caDoc { - if f.Key == "ParentPointer" { - parentID = extractBsonIDValue(f.Value) - } - if f.Key == "Name" { - caName = bsonutil.String(f.Value, "Name") - } - } - if parentID == entityID && caName != "" { - entityAssocNames[caName] = true - } - } - - // Process AccessRules - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - rulesArr, ok := f.Value.(bson.A) - if !ok { - break - } - - for k, ruleItem := range rulesArr { - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - continue - } - - // Strip invalid properties (AllowRead, AllowWrite) that - // old mxcli versions wrote. These crash Studio Pro with - // "Sequence contains no matching element" in MprProperty..ctor. - ruleDoc, stripped := stripInvalidAccessRuleProps(ruleDoc) - if stripped { - rulesArr[k] = ruleDoc - modified++ - } - - // Find MemberAccesses - for m, rf := range ruleDoc { - if rf.Key != "MemberAccesses" { - continue - } - maArr, ok := rf.Value.(bson.A) - if !ok { - break - } - - // A list holding only the storage marker is NOT a reason to - // skip: a rule with no member entries on an entity that has - // members is precisely the out-of-date state CE0066 names, and - // topping it up is what this function is for. The early break - // that used to be here made the legacy engine disagree with the - // codec engine, which fills such a rule in — and it surfaced the - // moment `create or modify entity` started PRESERVING rules - // instead of deleting them: a rewrite that drops every attribute - // a rule covered empties the list, and the entity's new - // attributes then never got an entry (CE0066 on the module). - // An entity with no members at all still lands here and still - // changes nothing, since the add loops below find nothing to add. - if len(maArr) == 0 { - break // no storage marker; not a list this writer produced - } - - // Get DefaultMemberAccessRights for new entries - defaultRights := "ReadWrite" - for _, drf := range ruleDoc { - if drf.Key == "DefaultMemberAccessRights" { - if dr, ok := drf.Value.(string); ok { - defaultRights = dr - } - break - } - } - - // Build set of covered attributes and associations - coveredAttrs := map[string]bool{} - coveredAssocs := map[string]bool{} - changed := false - var filtered bson.A - // Preserve the storage marker - if len(maArr) > 0 { - filtered = bson.A{maArr[0]} - } - - coveredSystemAssocs := map[string]bool{} - for _, maItem := range maArr[1:] { - maDoc, ok := maItem.(bson.D) - if !ok { - continue - } - attrRef := "" - assocRef := "" - for _, mf := range maDoc { - if mf.Key == "Attribute" { - attrRef = bsonutil.String(mf.Value, "Attribute") - } - if mf.Key == "Association" { - assocRef = bsonutil.String(mf.Value, "Association") - } - } - - if attrRef != "" { - switch { - case expectedAttrRefs[attrRef]: - // A member the entity has — its own, or one inherited - // from a generalization in this module. - coveredAttrs[attrRef] = true - // Downgrade write rights on calculated attributes (CE6592) - if calculatedAttrRefs[attrRef] { - maDoc = downgradeCalculatedAttrRights(maDoc) - } - filtered = append(filtered, maDoc) - case !attrRefBelongsToEntity(attrRef, moduleName, entityName): - // An inherited member's reference is qualified against the - // entity that DECLARES it, so it does not match this - // entity's own attribute list and used to be deleted as - // stale (mendixlabs/mxcli#758). The ancestor may live in - // another module or in System, neither loaded here, so an - // inherited reference cannot be validated at this layer — - // preserve what cannot be checked. Mirrors the codec engine - // (mdl/backend/modelsdk.attrRefBelongsTo). - filtered = append(filtered, maDoc) - default: - changed = true // stale attribute entry removed - } - } else if assocRef != "" { - // Check if it's a system association (e.g., "System.owner") - if systemAssocRefs[assocRef] { - coveredSystemAssocs[assocRef] = true - filtered = append(filtered, maItem) - } else { - // Extract association name from Module.AssocName - parts := splitAssocRef(assocRef) - if parts != "" && entityAssocNames[parts] { - coveredAssocs[parts] = true - filtered = append(filtered, maItem) - } else { - changed = true // stale association entry removed - } - } - } else { - filtered = append(filtered, maItem) - } - } - - // Add missing attributes, in declaration order (own first, - // then each ancestor's). Iterating the map instead made the - // order of new entries vary between runs, so two identical - // reconciles could produce different bytes. - for _, ea := range expectedAttrs { - if !coveredAttrs[ea.ref] { - rights := defaultRights - // Calculated attributes cannot have write rights (CE6592) - if ea.calculated && (rights == "ReadWrite" || rights == "WriteOnly") { - rights = "ReadOnly" - } - newMA := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: rights}, - {Key: "Attribute", Value: ea.ref}, - } - filtered = append(filtered, newMA) - changed = true - } - } - - // Add missing module associations - for aName := range entityAssocNames { - if !coveredAssocs[aName] { - newMA := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: defaultRights}, - {Key: "Association", Value: moduleName + "." + aName}, - } - filtered = append(filtered, newMA) - changed = true - } - } - - // Add missing system associations (e.g., System.owner) - for sysRef := range systemAssocRefs { - if !coveredSystemAssocs[sysRef] { - newMA := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: defaultRights}, - {Key: "Association", Value: sysRef}, - } - filtered = append(filtered, newMA) - changed = true - } - } - - if changed { - ruleDoc[m].Value = filtered - rulesArr[k] = ruleDoc - modified++ - } - - break - } - } - - entityDoc[j].Value = rulesArr - break - } - - entitiesArr[i] = entityDoc - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - - return modified, err -} - -// downgradeCalculatedAttrRights changes ReadWrite/WriteOnly to ReadOnly on a MemberAccess doc. -func downgradeCalculatedAttrRights(doc bson.D) bson.D { - for i, f := range doc { - if f.Key == "AccessRights" { - if rights, ok := f.Value.(string); ok && (rights == "ReadWrite" || rights == "WriteOnly") { - doc[i].Value = "ReadOnly" - } - } - } - return doc -} - -// extractBsonIDValue extracts a string ID from various BSON ID representations. -func extractBsonIDValue(v any) string { - switch val := v.(type) { - case string: - return val - case primitive.Binary: - return blobToUUID(val.Data) - default: - return fmt.Sprintf("%v", v) - } -} - -// splitQualifiedRef extracts the last component from "Module.Entity.AttrName". -func splitQualifiedRef(ref string) string { - parts := splitByDot(ref) - if len(parts) >= 3 { - return parts[len(parts)-1] - } - return "" -} - -// splitAssocRef extracts the association name from "Module.AssocName". -func splitAssocRef(ref string) string { - parts := splitByDot(ref) - if len(parts) >= 2 { - return parts[len(parts)-1] - } - return "" -} - -// splitByDot splits a string by "." - simple helper to avoid importing strings. -func splitByDot(s string) []string { - var parts []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '.' { - parts = append(parts, s[start:i]) - start = i + 1 - } - } - parts = append(parts, s[start:]) - return parts -} - -// invalidAccessRuleProps lists BSON keys that are NOT valid Mendix metamodel -// properties on DomainModels$AccessRule. Old mxcli versions wrote these; -// Studio Pro crashes with "Sequence contains no matching element" if present. -var invalidAccessRuleProps = map[string]bool{ - "AllowRead": true, - "AllowWrite": true, -} - -// stripInvalidAccessRuleProps removes invalid properties from an AccessRule BSON document. -// Returns the cleaned document and true if any properties were removed. -func stripInvalidAccessRuleProps(doc bson.D) (bson.D, bool) { - cleaned := make(bson.D, 0, len(doc)) - stripped := false - for _, f := range doc { - if invalidAccessRuleProps[f.Key] { - stripped = true - continue - } - cleaned = append(cleaned, f) - } - return cleaned, stripped -} - -// ensure primitive import is used -var _ = primitive.Binary{} - -// chainAttr is one attribute of an entity's access surface: the reference -// Mendix stores for it, and whether it is calculated (which caps its rights). -type chainAttr struct { - ref string // "Module.DeclaringEntity.Attribute" - calculated bool -} - -// entityAttrsInChain returns the attributes an entity's access rules must -// cover — its own, then those of each generalization that lives in THIS module, -// nearest ancestor first — each qualified against the entity that declares it. -// -// A nearer entity's attribute SHADOWS an ancestor's of the same name, matching -// the executor's own member walk (EntityMembersFor): emitting both would put two -// entries in the rule for one member the modeller sees. -// -// The walk stops at the first ancestor outside this module (or one that cannot -// be found), because only this module's domain model is loaded here. Those -// members are neither added nor pruned — the compare pass preserves the entries -// that already reference them. -func entityAttrsInChain(entitiesArr bson.A, entityName, moduleName string) []chainAttr { - byName := map[string]bson.D{} - for _, item := range entitiesArr { - ed, ok := item.(bson.D) - if !ok { - continue - } - for _, f := range ed { - if f.Key == "Name" { - if n := bsonutil.String(f.Value, "Name"); n != "" { - byName[n] = ed - } - break - } - } - } - - var out []chainAttr - claimed := map[string]bool{} // bare attribute name -> already taken by a nearer entity - seen := map[string]bool{} // cycle guard - - for name := entityName; name != ""; { - ed, ok := byName[name] - if !ok || seen[name] { - break - } - seen[name] = true - - for _, ca := range ownAttrsOf(ed, moduleName, name) { - bare := ca.ref[strings.LastIndex(ca.ref, ".")+1:] - if claimed[bare] { - continue - } - claimed[bare] = true - out = append(out, ca) - } - - // Step to the generalization, if it is in this module. - genRef := generalizationRefOf(ed) - idx := strings.LastIndex(genRef, ".") - if idx < 0 || !strings.EqualFold(genRef[:idx], moduleName) { - break - } - name = genRef[idx+1:] - } - return out -} - -// ownAttrsOf reads one entity document's own attributes. -func ownAttrsOf(entityDoc bson.D, moduleName, entityName string) []chainAttr { - var out []chainAttr - for _, attrItem := range getBsonArray(entityDoc, "Attributes") { - attrDoc, ok := attrItem.(bson.D) - if !ok { - continue - } - attrName := "" - isCalculated := false - for _, f := range attrDoc { - if f.Key == "Name" { - attrName = bsonutil.String(f.Value, "Name") - } - if f.Key == "Value" { - if valueDoc, ok := f.Value.(bson.D); ok { - for _, vf := range valueDoc { - if vf.Key == "$Type" { - if vt, ok := vf.Value.(string); ok && vt == "DomainModels$CalculatedValue" { - isCalculated = true - } - } - } - } - } - } - if attrName != "" { - out = append(out, chainAttr{ - ref: moduleName + "." + entityName + "." + attrName, - calculated: isCalculated, - }) - } - } - return out -} - -// generalizationRefOf returns the qualified name of an entity's generalization -// ("Module.Entity"), or "" when it has none. Newer formats store the field as -// MaybeGeneralization; a NoGeneralization carries no reference. -func generalizationRefOf(entityDoc bson.D) string { - for _, f := range entityDoc { - if f.Key != "Generalization" && f.Key != "MaybeGeneralization" { - continue - } - gd, ok := f.Value.(bson.D) - if !ok { - return "" - } - for _, gf := range gd { - if gf.Key == "Generalization" { - return bsonutil.String(gf.Value, "Generalization") - } - } - return "" - } - return "" -} - -// attrRefBelongsToEntity reports whether a MemberAccess attribute reference -// ("Module.Entity.Attribute") names one of the given entity's OWN attributes, -// rather than one inherited from an ancestor. Only an own reference can be -// validated from a single domain model. -func attrRefBelongsToEntity(attrRef, moduleName, entityName string) bool { - idx := strings.LastIndex(attrRef, ".") - if idx < 0 { - return false - } - return strings.EqualFold(attrRef[:idx], moduleName+"."+entityName) -} diff --git a/sdk/mpr/writer_security_inherited_test.go b/sdk/mpr/writer_security_inherited_test.go deleted file mode 100644 index 57f4a37f8d..0000000000 --- a/sdk/mpr/writer_security_inherited_test.go +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// mendixlabs/mxcli#1047: adding an attribute to a GENERALIZATION left the -// project at CE0066 "Entity access is out of date", and `UPDATE SECURITY` -// reported "All entity access rules are up to date" without changing anything. -// -// ReconcileMemberAccesses computed a specialization's expected member set from -// the entity's OWN attributes, so an inherited one was never missing and never -// added. Both engines had it, in the same shape; this is the legacy half. - -// seedGeneralizationChain inserts a domain model holding Gen (attribute Name) -// and Spec (extends Gen, attribute Extra), each with one access rule. The rules -// list only what the entity declares itself, which is the state a project -// reaches when the generalization gains an attribute afterwards. -func seedGeneralizationChain(t *testing.T, db *sql.DB) model.ID { - t.Helper() - - const ( - unitIDStr = "11111111-1111-1111-1111-111111111111" - containerIDStr = "22222222-2222-2222-2222-222222222222" - genIDStr = "33333333-3333-3333-3333-333333333333" - specIDStr = "55555555-5555-5555-5555-555555555555" - ) - - attr := func(id, name string) bson.D { - return bson.D{ - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "Name", Value: name}, - } - } - rule := func(id string, members bson.A) bson.D { - return bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(1), "MyModule.Administrator"}}, - {Key: "DefaultMemberAccessRights", Value: "ReadWrite"}, - {Key: "MemberAccesses", Value: members}, - } - } - member := func(id, ref string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: "ReadWrite"}, - {Key: "Attribute", Value: ref}, - } - } - - dmBSON := bson.D{ - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "$ID", Value: idToBsonBinary(unitIDStr)}, - {Key: "Entities", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(genIDStr)}, - {Key: "Name", Value: "Gen"}, - {Key: "Attributes", Value: bson.A{int32(3), attr(attrIDForIndex(0), "Name")}}, - {Key: "AccessRules", Value: bson.A{int32(3), - rule("44444444-4444-4444-4444-444444444444", bson.A{ - int32(3), member("66666666-6666-6666-6666-666666666666", "MyModule.Gen.Name"), - })}}, - }, - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(specIDStr)}, - {Key: "Name", Value: "Spec"}, - {Key: "Attributes", Value: bson.A{int32(3), attr(attrIDForIndex(1), "Extra")}}, - {Key: "Generalization", Value: bson.D{ - {Key: "$Type", Value: "DomainModels$Generalization"}, - {Key: "$ID", Value: idToBsonBinary("77777777-7777-7777-7777-777777777777")}, - {Key: "Generalization", Value: "MyModule.Gen"}, - }}, - {Key: "AccessRules", Value: bson.A{int32(3), - rule("88888888-8888-8888-8888-888888888888", bson.A{ - int32(3), member("99999999-9999-9999-9999-999999999999", "MyModule.Spec.Extra"), - })}}, - }, - }}, - {Key: "Associations", Value: bson.A{int32(3)}}, - } - - contents, err := bson.Marshal(dmBSON) - if err != nil { - t.Fatalf("marshal domain model: %v", err) - } - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'DomainModel', 0, ?, '', ?)`, - uuidToBlob(unitIDStr), uuidToBlob(containerIDStr), - contentHashBase64(contents), contents, - ); err != nil { - t.Fatalf("insert domain model unit: %v", err) - } - return model.ID(unitIDStr) -} - -// memberRefsOfEntity reads one named entity's first rule's attribute references. -func memberRefsOfEntity(t *testing.T, db *sql.DB, unitID model.ID, entityName string) []string { - t.Helper() - var contents []byte - if err := db.QueryRow(`SELECT Contents FROM Unit WHERE UnitID = ?`, - uuidToBlob(string(unitID))).Scan(&contents); err != nil { - t.Fatalf("read unit: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - for _, e := range extractBsonArray(raw["Entities"]) { - ent, ok := e.(map[string]any) - if !ok || extractString(ent["Name"]) != entityName { - continue - } - rules := extractBsonArray(ent["AccessRules"]) - if len(rules) == 0 { - t.Fatalf("entity %s has no access rules", entityName) - } - var refs []string - for _, ma := range extractBsonArray(rules[0].(map[string]any)["MemberAccesses"]) { - if m, ok := ma.(map[string]any); ok { - refs = append(refs, extractString(m["Attribute"])) - } - } - return refs - } - t.Fatalf("entity %s not found", entityName) - return nil -} - -func hasString(vals []string, want string) bool { - for _, v := range vals { - if v == want { - return true - } - } - return false -} - -func TestReconcileMemberAccesses_AddsAnInheritedAttribute(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedGeneralizationChain(t, db) - - // Read the seed back before asking anything of it: a fixture that failed to - // store the chain would make the assertions below meaningless. - if refs := memberRefsOfEntity(t, db, unitID, "Spec"); len(refs) != 1 || refs[0] != "MyModule.Spec.Extra" { - t.Fatalf("fixture did not store the specialization's rule as expected: %v", refs) - } - - modified, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - - refs := memberRefsOfEntity(t, db, unitID, "Spec") - if !hasString(refs, "MyModule.Gen.Name") { - t.Fatalf("the inherited attribute was not added: %v\n"+ - "this is the CE0066 in mendixlabs/mxcli#1047 — and the reference must name Gen, "+ - "the entity that DECLARES it, not Spec", refs) - } - if !hasString(refs, "MyModule.Spec.Extra") { - t.Errorf("the specialization's own attribute was dropped: %v", refs) - } - // The count is what `update security` turns into its message. Reporting 0 - // while adding a member is how "All entity access rules are up to date" came - // to be printed over a project mx check rejects. - if modified == 0 { - t.Error("a member was added but 0 modified was reported") - } -} - -// Running it twice must not add a second copy. The compare pass keys on the -// full reference; keying on the bare attribute name instead would leave the -// inherited entry looking uncovered on every later run. -func TestReconcileMemberAccesses_InheritedAttributeIsNotDuplicated(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedGeneralizationChain(t, db) - - if _, err := w.ReconcileMemberAccesses(unitID, "MyModule"); err != nil { - t.Fatalf("first reconcile: %v", err) - } - first := memberRefsOfEntity(t, db, unitID, "Spec") - - modified, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("second reconcile: %v", err) - } - second := memberRefsOfEntity(t, db, unitID, "Spec") - - if len(second) != len(first) { - t.Errorf("a second reconcile changed the member list: %v -> %v", first, second) - } - if modified != 0 { - t.Errorf("a second reconcile reported %d modified; an in-sync rule must be quiet", modified) - } -} - -// The generalization's own rule is already complete, so it must not change — -// otherwise the test above would pass against a fix that rewrites everything. -func TestReconcileMemberAccesses_LeavesTheGeneralizationsRuleAlone(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedGeneralizationChain(t, db) - - before := memberRefsOfEntity(t, db, unitID, "Gen") - if _, err := w.ReconcileMemberAccesses(unitID, "MyModule"); err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - after := memberRefsOfEntity(t, db, unitID, "Gen") - - if len(before) != len(after) || !hasString(after, "MyModule.Gen.Name") { - t.Errorf("the generalization's rule changed: %v -> %v", before, after) - } - for _, r := range after { - if r != "MyModule.Gen.Name" { - t.Errorf("the generalization gained a member it does not declare: %v", after) - } - } -} diff --git a/sdk/mpr/writer_security_reconcile_test.go b/sdk/mpr/writer_security_reconcile_test.go deleted file mode 100644 index e83d9c30bd..0000000000 --- a/sdk/mpr/writer_security_reconcile_test.go +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// ReconcileMemberAccesses used to SKIP any rule whose MemberAccesses list held -// only the storage marker: -// -// // If empty (just the storage marker), skip -// if len(maArr) <= 1 { break } -// -// A rule with no member entries on an entity that HAS members is precisely the -// out-of-date state CE0066 names, so the skip left behind the one thing this -// function exists to prevent. It also made the legacy engine disagree with the -// codec engine, which fills such a rule in. -// -// Nothing reached that state until `create or modify entity` started PRESERVING -// access rules instead of deleting them: a rewrite that drops every attribute a -// rule covered empties the list, and the entity's new attributes then never got -// an entry. Measured on the BusinessEvents 3.12.0 marketplace module against -// mxbuild 11.14.0 — `create or modify persistent entity -// BusinessEvents.PublishedBusinessEvent ( EventId: long )` over the real module -// took its Administrator rule from 5 members to 0 on legacy and 1 on modelsdk, -// and mx check reported CE0066 at "Domain model of module 'BusinessEvents'". - -// seedRuleWithEmptyMembers inserts a domain model with one entity, two -// attributes, and one access rule whose MemberAccesses list is bare. -func seedRuleWithEmptyMembers(t *testing.T, db *sql.DB, attrNames ...string) model.ID { - t.Helper() - - const ( - unitIDStr = "11111111-1111-1111-1111-111111111111" - containerIDStr = "22222222-2222-2222-2222-222222222222" - entityIDStr = "33333333-3333-3333-3333-333333333333" - ) - - attrs := bson.A{int32(3)} - for i, name := range attrNames { - attrs = append(attrs, bson.D{ - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "$ID", Value: idToBsonBinary(attrIDForIndex(i))}, - {Key: "Name", Value: name}, - }) - } - - dmBSON := bson.D{ - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "$ID", Value: idToBsonBinary(unitIDStr)}, - {Key: "Entities", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(entityIDStr)}, - {Key: "Name", Value: "Order"}, - {Key: "Attributes", Value: attrs}, - {Key: "AccessRules", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "$ID", Value: idToBsonBinary("44444444-4444-4444-4444-444444444444")}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(1), "MyModule.Administrator"}}, - {Key: "DefaultMemberAccessRights", Value: "ReadOnly"}, - // The state a preserving rewrite leaves behind: the rule - // survives, every member it named is gone. - {Key: "MemberAccesses", Value: bson.A{int32(3)}}, - }, - }}, - }, - }}, - {Key: "Associations", Value: bson.A{int32(3)}}, - } - - contents, err := bson.Marshal(dmBSON) - if err != nil { - t.Fatalf("marshal domain model: %v", err) - } - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'DomainModel', 0, ?, '', ?)`, - uuidToBlob(unitIDStr), uuidToBlob(containerIDStr), - contentHashBase64(contents), contents, - ); err != nil { - t.Fatalf("insert domain model unit: %v", err) - } - return model.ID(unitIDStr) -} - -func attrIDForIndex(i int) string { - return string(rune('a'+i)) + "aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" -} - -// entityAttrNames reads back the seeded entity's attribute names. -func entityAttrNames(t *testing.T, db *sql.DB, unitID model.ID) []string { - t.Helper() - entity := readSeededEntity(t, db, unitID) - var names []string - for _, a := range extractBsonArray(entity["Attributes"]) { - if m, ok := a.(map[string]any); ok { - names = append(names, extractString(m["Name"])) - } - } - return names -} - -// memberAttrRefs reads back the rule's member entries. -func memberAttrRefs(t *testing.T, db *sql.DB, unitID model.ID) []string { - t.Helper() - entity := readSeededEntity(t, db, unitID) - rules := extractBsonArray(entity["AccessRules"]) - if len(rules) == 0 { - t.Fatal("no access rules") - } - rule := rules[0].(map[string]any) - - var refs []string - for _, ma := range extractBsonArray(rule["MemberAccesses"]) { - m, ok := ma.(map[string]any) - if !ok { - continue - } - refs = append(refs, extractString(m["Attribute"])) - } - return refs -} - -// readSeededEntity returns the single entity of the seeded domain model unit. -func readSeededEntity(t *testing.T, db *sql.DB, unitID model.ID) map[string]any { - t.Helper() - var contents []byte - if err := db.QueryRow(`SELECT Contents FROM Unit WHERE UnitID = ?`, - uuidToBlob(string(unitID))).Scan(&contents); err != nil { - t.Fatalf("read unit: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - entities := extractBsonArray(raw["Entities"]) - if len(entities) == 0 { - t.Fatal("no entities") - } - entity, ok := entities[0].(map[string]any) - if !ok { - t.Fatalf("entity is %T, want map[string]any", entities[0]) - } - return entity -} - -func TestReconcileMemberAccesses_FillsARuleWhoseMembersAreAllGone(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedRuleWithEmptyMembers(t, db, "EventId") - - // Read the seed back before asking anything of it. Without this, a fixture - // that failed to round-trip is indistinguishable from the reconcile - // declining to act, and the failure message would blame the wrong code. - if attrs := entityAttrNames(t, db, unitID); len(attrs) != 1 || attrs[0] != "EventId" { - t.Fatalf("fixture did not round-trip: entity attributes = %v, want [EventId]", attrs) - } - - count, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - if count == 0 { - t.Fatal("reconcile reported no change — a rule with zero member entries on " + - "an entity that has attributes is CE0066 \"Entity access is out of date\", " + - "and it is exactly the state a preserving entity rewrite leaves behind") - } - - refs := memberAttrRefs(t, db, unitID) - if len(refs) != 1 || refs[0] != "MyModule.Order.EventId" { - t.Errorf("member entries = %v, want [MyModule.Order.EventId]", refs) - } -} - -// CONTROL: an entity with NO members must still come out unchanged. The old -// early break covered this case by accident; removing it must not turn every -// member-less entity into a write. -func TestReconcileMemberAccesses_LeavesAMemberlessEntityAlone(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedRuleWithEmptyMembers(t, db) // no attributes - - count, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - if count != 0 { - t.Errorf("reconcile rewrote %d rule(s) on an entity with no members", count) - } - if refs := memberAttrRefs(t, db, unitID); len(refs) != 0 { - t.Errorf("member entries = %v, want none", refs) - } -} - -// CONTROL: the ordinary case — some members covered, one not — must keep working. -// This is the path the early break never reached, so a fix that broke it would -// otherwise go unnoticed by the test above. -func TestReconcileMemberAccesses_StillTopsUpAPartiallyCoveredRule(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedRuleWithEmptyMembers(t, db, "Ref", "Amount") - - // Give the rule an entry for Ref only. - if err := w.AddEntityAccessRule(unitID, "Order", - []string{"MyModule.Administrator"}, false, false, "ReadOnly", "", - []EntityMemberAccess{{AttributeRef: "MyModule.Order.Ref", AccessRights: "ReadOnly"}}, - ); err != nil { - t.Fatalf("AddEntityAccessRule: %v", err) - } - - if _, err := w.ReconcileMemberAccesses(unitID, "MyModule"); err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - - got := map[string]bool{} - for _, r := range memberAttrRefs(t, db, unitID) { - got[r] = true - } - for _, want := range []string{"MyModule.Order.Ref", "MyModule.Order.Amount"} { - if !got[want] { - t.Errorf("missing member entry %s (got %v)", want, got) - } - } -} diff --git a/sdk/mpr/writer_security_test.go b/sdk/mpr/writer_security_test.go deleted file mode 100644 index bebb81c97e..0000000000 --- a/sdk/mpr/writer_security_test.go +++ /dev/null @@ -1,384 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "io" - "log" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "go.mongodb.org/mongo-driver/bson" - _ "modernc.org/sqlite" -) - -// ============================================================================= -// removeRolesFromAccessRule — unit tests for multi-role handling -// ============================================================================= - -func makeAccessRule(roleNames ...string) bson.D { - roles := bson.A{int32(1)} // Mendix array marker - for _, r := range roleNames { - roles = append(roles, r) - } - return bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowedModuleRoles", Value: roles}, - {Key: "AllowCreate", Value: true}, - } -} - -func getRoleNames(rule bson.D) []string { - for _, f := range rule { - if f.Key != "AllowedModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - return nil - } - var names []string - for _, item := range arr { - if s, ok := item.(string); ok { - names = append(names, s) - } - } - return names - } - return nil -} - -func TestRemoveRolesFromAccessRule_SingleRole_ExactMatch(t *testing.T) { - rule := makeAccessRule("Mod.RoleA") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.RoleA": true}) - if keep { - t.Error("expected rule to be deleted (no roles remaining)") - } - if !modified { - t.Error("expected modified=true") - } -} - -func TestRemoveRolesFromAccessRule_MultiRole_RemoveOne(t *testing.T) { - rule := makeAccessRule("Mod.User", "Mod.Admin") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.User": true}) - if !keep { - t.Error("expected rule to be kept (Admin still present)") - } - if !modified { - t.Error("expected modified=true") - } - names := getRoleNames(rule) - if len(names) != 1 || names[0] != "Mod.Admin" { - t.Errorf("expected [Mod.Admin], got %v", names) - } -} - -func TestRemoveRolesFromAccessRule_MultiRole_RemoveAll(t *testing.T) { - rule := makeAccessRule("Mod.User", "Mod.Admin") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.User": true, "Mod.Admin": true}) - if keep { - t.Error("expected rule to be deleted (no roles remaining)") - } - if !modified { - t.Error("expected modified=true") - } -} - -func TestRemoveRolesFromAccessRule_NoMatch(t *testing.T) { - rule := makeAccessRule("Mod.User", "Mod.Admin") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.Other": true}) - if !keep { - t.Error("expected rule to be kept") - } - if modified { - t.Error("expected modified=false") - } - names := getRoleNames(rule) - if len(names) != 2 { - t.Errorf("expected 2 roles unchanged, got %v", names) - } -} - -func TestRemoveRolesFromAccessRule_NoAllowedModuleRoles(t *testing.T) { - rule := bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowCreate", Value: true}, - } - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.User": true}) - if !keep { - t.Error("expected rule to be kept (no AllowedModuleRoles field)") - } - if modified { - t.Error("expected modified=false") - } -} - -func TestRemoveRolesFromAccessRule_ThreeRoles_RemoveMiddle(t *testing.T) { - rule := makeAccessRule("Mod.A", "Mod.B", "Mod.C") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.B": true}) - if !keep { - t.Error("expected rule to be kept") - } - if !modified { - t.Error("expected modified=true") - } - names := getRoleNames(rule) - if len(names) != 2 || names[0] != "Mod.A" || names[1] != "Mod.C" { - t.Errorf("expected [Mod.A, Mod.C], got %v", names) - } -} - -// ============================================================================= -// mergeAccessRule — malformed BSON must not panic -// ============================================================================= - -// Not parallel-safe: redirects global log output. -func TestMergeAccessRule_UnexpectedTypes_NoPanic(t *testing.T) { - origOutput := log.Writer() - log.SetOutput(io.Discard) - defer log.SetOutput(origOutput) - - existing := bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowCreate", Value: 42}, // wrong type: int instead of bool - {Key: "AllowDelete", Value: "not-a-bool"}, // wrong type: string instead of bool - {Key: "DefaultMemberAccessRights", Value: 99}, - } - newRule := bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowCreate", Value: true}, - {Key: "AllowDelete", Value: false}, - {Key: "DefaultMemberAccessRights", Value: "ReadWrite"}, - } - - // Must not panic - result := mergeAccessRule(existing, newRule) - if result == nil { - t.Error("expected non-nil result") - } -} - -// ============================================================================= -// AddEntityAccessRule — XPath constraint preserved and rights readable (#431) -// ============================================================================= - -// newTestWriterSecurity creates an in-memory SQLite writer for security tests. -func newTestWriterSecurity(t *testing.T) (*Writer, *sql.DB) { - t.Helper() - dbPath := filepath.Join(t.TempDir(), "sec.mpr") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `); err != nil { - t.Fatalf("create Unit table: %v", err) - } - - reader := &Reader{db: db, version: MPRVersionV1} - return &Writer{reader: reader}, db -} - -// seedDomainModelUnit inserts a minimal domain model BSON with one entity+attribute. -// Returns the unit ID and the domain model BSON (as bson.D). -func seedDomainModelUnit(t *testing.T, w *Writer, db *sql.DB) (unitID model.ID, entityID model.ID) { - t.Helper() - - unitIDStr := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - containerIDStr := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - entityIDStr := "cccccccc-cccc-cccc-cccc-cccccccccccc" - - dmBSON := bson.D{ - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "$ID", Value: idToBsonBinary(unitIDStr)}, - {Key: "Entities", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(entityIDStr)}, - {Key: "Name", Value: "Order"}, - {Key: "Attributes", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "$ID", Value: idToBsonBinary("dddddddd-dddd-dddd-dddd-dddddddddddd")}, - {Key: "Name", Value: "Status"}, - }, - }}, - {Key: "AccessRules", Value: bson.A{int32(3)}}, - }, - }}, - {Key: "Associations", Value: bson.A{int32(3)}}, - } - - contents, err := bson.Marshal(dmBSON) - if err != nil { - t.Fatalf("marshal domain model: %v", err) - } - - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'DomainModel', 0, ?, '', ?)`, - uuidToBlob(unitIDStr), - uuidToBlob(containerIDStr), - contentHashBase64(contents), - contents, - ); err != nil { - t.Fatalf("insert domain model unit: %v", err) - } - - return model.ID(unitIDStr), model.ID(entityIDStr) -} - -// TestAddEntityAccessRule_XPathConstraint_FullRoundtrip verifies the complete -// flow for issue #431: AddEntityAccessRule + ReconcileMemberAccesses + parseDomainModel. -// Ensures that XPath and read/write rights survive the full write-then-read cycle. -func TestAddEntityAccessRule_XPathConstraint_FullRoundtrip(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID, _ := seedDomainModelUnit(t, w, db) - containerIDStr := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - - err := w.AddEntityAccessRule( - unitID, "Order", - []string{"MyModule.User"}, - false, false, - "ReadWrite", - "[Status = 'Open']", - []EntityMemberAccess{ - {AttributeRef: "MyModule.Order.Status", AccessRights: "ReadWrite"}, - }, - ) - if err != nil { - t.Fatalf("AddEntityAccessRule: %v", err) - } - - // ReconcileMemberAccesses is called by execGrantEntityAccess right after - count, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - _ = count - - // Read back via parseDomainModel (same path as GetDomainModel) - dm, err := w.reader.GetDomainModel(model.ID(containerIDStr)) - if err != nil { - t.Fatalf("GetDomainModel: %v", err) - } - - if len(dm.Entities) == 0 { - t.Fatal("no entities found in domain model") - } - - var order *domainmodel.Entity - for _, e := range dm.Entities { - if e.Name == "Order" { - order = e - break - } - } - if order == nil { - t.Fatal("Order entity not found") - } - - if len(order.AccessRules) == 0 { - t.Fatal("AccessRules empty after AddEntityAccessRule + ReconcileMemberAccesses (issue #431)") - } - - rule := order.AccessRules[0] - if rule.XPathConstraint != "[Status = 'Open']" { - t.Errorf("XPathConstraint = %q, want %q", rule.XPathConstraint, "[Status = 'Open']") - } - if rule.DefaultMemberAccessRights != domainmodel.MemberAccessRightsReadWrite { - t.Errorf("DefaultMemberAccessRights = %q, want ReadWrite", rule.DefaultMemberAccessRights) - } - if len(rule.ModuleRoleNames) == 0 || rule.ModuleRoleNames[0] != "MyModule.User" { - t.Errorf("ModuleRoleNames = %v, want [MyModule.User]", rule.ModuleRoleNames) - } - if len(rule.MemberAccesses) == 0 { - t.Error("MemberAccesses empty after reconciliation") - } -} - -// TestAddEntityAccessRule_XPathConstraint_PreservesRights verifies that granting -// entity access with an XPath WHERE clause correctly persists both the XPath -// and the read/write rights (issue #431: rights were silently dropped). -func TestAddEntityAccessRule_XPathConstraint_PreservesRights(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID, _ := seedDomainModelUnit(t, w, db) - - err := w.AddEntityAccessRule( - unitID, "Order", - []string{"MyModule.User"}, - false, false, - "ReadWrite", - "[Status = 'Open']", - []EntityMemberAccess{ - {AttributeRef: "MyModule.Order.Status", AccessRights: "ReadWrite"}, - }, - ) - if err != nil { - t.Fatalf("AddEntityAccessRule: %v", err) - } - - // Read back via parseDomainModel - row := db.QueryRow(`SELECT Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(string(unitID))) - var contents []byte - if err := row.Scan(&contents); err != nil { - t.Fatalf("read unit contents: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - entities := extractBsonArray(raw["Entities"]) - if len(entities) == 0 { - t.Fatal("no entities found after AddEntityAccessRule") - } - - entityMap, ok := entities[0].(map[string]any) - if !ok { - t.Fatalf("entity is %T, want map[string]any", entities[0]) - } - - rules := extractBsonArray(entityMap["AccessRules"]) - if len(rules) == 0 { - t.Fatal("AccessRules is empty after grant — rights not persisted (issue #431)") - } - - ruleMap, ok := rules[0].(map[string]any) - if !ok { - t.Fatalf("rule is %T, want map[string]any", rules[0]) - } - - xpath := extractString(ruleMap["XPathConstraint"]) - if xpath != "[Status = 'Open']" { - t.Errorf("XPathConstraint = %q, want %q", xpath, "[Status = 'Open']") - } - - defaultAccess := extractString(ruleMap["DefaultMemberAccessRights"]) - if defaultAccess != "ReadWrite" { - t.Errorf("DefaultMemberAccessRights = %q, want %q", defaultAccess, "ReadWrite") - } - - memberAccesses := extractBsonArray(ruleMap["MemberAccesses"]) - if len(memberAccesses) == 0 { - t.Error("MemberAccesses is empty after grant") - } -} diff --git a/sdk/mpr/writer_settings.go b/sdk/mpr/writer_settings.go deleted file mode 100644 index 03e3c1b123..0000000000 --- a/sdk/mpr/writer_settings.go +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/settingsoverlay" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// safeInt64 converts an int to int64. -func safeInt64(v int) int64 { - return int64(v) -} - -// UpdateProjectSettings updates the project settings document. -// The project settings document always exists, so this only needs update, not create/delete. -func (w *Writer) UpdateProjectSettings(ps *model.ProjectSettings) error { - contents, err := w.serializeProjectSettings(ps) - if err != nil { - return fmt.Errorf("failed to serialize project settings: %w", err) - } - - return w.updateUnit(string(ps.ID), contents) -} - -// serializeProjectSettings converts ProjectSettings to BSON bytes. -// It uses the RawParts for round-trip fidelity, updating only the parts -// that have been parsed and modified. -func (w *Writer) serializeProjectSettings(ps *model.ProjectSettings) ([]byte, error) { - // Without the raw parts there is nothing to overlay onto, and writing the - // document anyway would replace every settings part with an empty array — the - // whole Project Settings dialog silently reset. Refuse instead. - if len(ps.RawParts) == 0 { - return nil, fmt.Errorf("no raw settings parts captured on read; " + - "refusing to write a settings document that would drop every part") - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ps.ID))}, - {Key: "$Type", Value: "Settings$ProjectSettings"}, - } - - // Rebuild the Settings array from RawParts, overwriting modified parts - settings := bson.A{int32(2)} // versioned array prefix - - for _, rawPart := range ps.RawParts { - typeName, _ := rawPart["$Type"].(string) - switch typeName { - case "Settings$ModelSettings": - if ps.Model != nil { - settings = append(settings, serializeModelSettings(ps.Model, rawPart)) - } else { - settings = append(settings, rawPart) - } - case "Settings$ConfigurationSettings": - if ps.Configuration != nil { - settings = append(settings, serializeConfigurationSettings(ps.Configuration, rawPart)) - } else { - settings = append(settings, rawPart) - } - case "Settings$LanguageSettings": - if ps.Language != nil { - settings = append(settings, serializeLanguageSettings(ps.Language, rawPart)) - } else { - settings = append(settings, rawPart) - } - case "Settings$WorkflowsProjectSettingsPart": - if ps.Workflows != nil { - settings = append(settings, serializeWorkflowsSettings(ps.Workflows, rawPart)) - } else { - settings = append(settings, rawPart) - } - default: - // Preserve raw part as-is (WebUI, Integration, Certificate, JarDeployment, Distribution, Convention) - settings = append(settings, rawPart) - } - } - - doc = append(doc, bson.E{Key: "Settings", Value: settings}) - // The Settings array carries parsed parts as Go maps (RawParts); marshalling - // a map randomizes key order, so hoist "$ID"/"$Type" first per 11.12 (#nightly). - return marshalUnitIDFirst(doc) -} - -// serializeModelSettings overlays the modified model settings onto the raw BSON -// part. The overlay is shared with the codec engine so the two write paths cannot -// drift (see mdl/settingsoverlay), and is presence-gated so a write never -// introduces a property this Mendix version does not store. -func serializeModelSettings(ms *model.ModelSettings, raw map[string]any) map[string]any { - return settingsoverlay.SetModelSettings(ms, raw) -} - -// serializeConfigurationSettings overlays the modified configuration settings onto -// the raw BSON part. The overlay is shared with the codec engine so the two write -// paths cannot drift (see mdl/settingsoverlay and mendixlabs/mxcli#801). -func serializeConfigurationSettings(cs *model.ConfigurationSettings, raw map[string]any) map[string]any { - return settingsoverlay.Configurations(cs, raw) -} - -// serializeLanguageSettings updates the raw BSON map with modified language settings. -func serializeLanguageSettings(ls *model.LanguageSettings, raw map[string]any) map[string]any { - raw["DefaultLanguageCode"] = ls.DefaultLanguageCode - return raw -} - -// serializeWorkflowsSettings updates the raw BSON map with modified workflow settings. -func serializeWorkflowsSettings(ws *model.WorkflowsSettings, raw map[string]any) map[string]any { - raw["UserEntity"] = ws.UserEntity - raw["DefaultTaskParallelism"] = safeInt64(ws.DefaultTaskParallelism) - raw["WorkflowEngineParallelism"] = safeInt64(ws.WorkflowEngineParallelism) - return raw -} diff --git a/sdk/mpr/writer_units.go b/sdk/mpr/writer_units.go deleted file mode 100644 index c89e66c66a..0000000000 --- a/sdk/mpr/writer_units.go +++ /dev/null @@ -1,293 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/modelsdk/canon" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// isContentsHashSchemaError returns true when the error looks like SQLite complaining -// about the absence of the ContentsHash column (i.e. an old MPR v1 schema from pre-Mx -// versions that predate ContentsHash). Anything else — a disk-full error, a missing -// UnitID, a rolled-back transaction — must propagate so writes don't silently succeed -// without updating Contents. -func isContentsHashSchemaError(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "ContentsHash") -} - -// updateTransactionID updates the _Transaction table with a new UUID. -// Studio Pro uses this to detect external changes during F4 sync. -// Only applies to MPR v2 projects (Mendix >= 10.18). -func (w *Writer) updateTransactionID() error { - if w.reader.version != MPRVersionV2 { - return nil - } - newID := generateUUID() - _, err := w.reader.db.Exec(`UPDATE _Transaction SET LastTransactionID = ?`, newID) - return err -} - -// placeholderBinaryPrefix is the GUID-swapped byte pattern for placeholder IDs generated -// by sdk/widgets/augment.go placeholderID(). These are "aa000000000000000000000000XXXXXX" -// hex strings which, after hex decode + GUID byte-swap, produce 16-byte blobs whose first -// 13 bytes are \x00\x00\x00\xaa followed by 9 zero bytes. -var placeholderBinaryPrefix = []byte{0x00, 0x00, 0x00, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} - -// placeholderStringPrefix is the ASCII prefix of a placeholder ID that leaked as a string. -var placeholderStringBytes = []byte("aa000000000000000000000000") - -// validateNoPlaceholderIDs scans raw BSON bytes for leaked placeholder IDs. -// Returns an error if any placeholder pattern is found. -func validateNoPlaceholderIDs(unitID string, contents []byte) error { - if bytes.Contains(contents, placeholderBinaryPrefix) { - return fmt.Errorf("placeholder ID leak detected in unit %s: binary aa000000-prefix ID found in BSON contents", unitID) - } - if bytes.Contains(contents, placeholderStringBytes) { - return fmt.Errorf("placeholder ID leak detected in unit %s: string aa000000-prefix ID found in BSON contents", unitID) - } - return nil -} - -func contentHashBase64(contents []byte) string { - hash := sha256.Sum256(contents) - return base64.StdEncoding.EncodeToString(hash[:]) -} - -func (w *Writer) insertUnit(unitID, containerID, containmentName, unitType string, contents []byte) error { - if err := validateNoPlaceholderIDs(unitID, contents); err != nil { - return err - } - // Two elements sharing an $ID make the whole project unopenable. Checked on - // both engines from the same function: which engine ran is an --engine flag, - // not something a user should be able to see in their diff — and least of - // all in whether a corrupt write was caught. (ako/mxcli-captrack #2) - if err := canon.DuplicateElementIDError(unitID, contents); err != nil { - return err - } - - // Convert UUID strings to 16-byte blobs for database - unitIDBlob := uuidToBlob(unitID) - containerIDBlob := uuidToBlob(containerID) - - if w.reader.version == MPRVersionV2 { - // Get swapped UUID for file path - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Create directory structure: mprcontents/XX/YY/ - dir := filepath.Join(w.reader.contentsDir, swappedUUID[0:2], swappedUUID[2:4]) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - // Write content file - filePath := filepath.Join(dir, swappedUUID+".mxunit") - if err := os.WriteFile(filePath, contents, 0644); err != nil { - return fmt.Errorf("failed to write unit file: %w", err) - } - - contentsHash := contentHashBase64(contents) - - // Insert reference to database - _, err := w.reader.db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts) - VALUES (?, ?, ?, 0, ?, '') - `, unitIDBlob, containerIDBlob, containmentName, contentsHash) - if err != nil { - // Clean up the file we just wrote — otherwise it becomes an orphan - os.Remove(filePath) - return err - } - w.reader.InvalidateCache() - if err := w.updateTransactionID(); err != nil { - return fmt.Errorf("failed to update transaction ID: %w", err) - } - return nil - } - - // MPR v1: Store directly in database - contentsHash := contentHashBase64(contents) - - // Try new schema first (without Type column - Mendix 11.6.2+) - _, err := w.reader.db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, ?, 0, ?, '', ?) - `, unitIDBlob, containerIDBlob, containmentName, contentsHash, contents) - if err != nil { - // Try old schema with Type column - _, err = w.reader.db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Type, Contents) - VALUES (?, ?, ?, ?, ?) - `, unitIDBlob, containerIDBlob, containmentName, unitType, contents) - } - if err == nil { - w.reader.InvalidateCache() - } - return err -} - -func (w *Writer) updateUnit(unitID string, contents []byte) error { - if err := validateNoPlaceholderIDs(unitID, contents); err != nil { - return err - } - // Two elements sharing an $ID make the whole project unopenable. Checked on - // both engines from the same function: which engine ran is an --engine flag, - // not something a user should be able to see in their diff — and least of - // all in whether a corrupt write was caught. (ako/mxcli-captrack #2) - if err := canon.DuplicateElementIDError(unitID, contents); err != nil { - return err - } - - // No-op elision and identity preservation (ADR-0008 decision 1), sharing the - // modelsdk engine's policy rather than reimplementing it. The two engines - // must agree here: which one ran is an --engine flag, not something a user - // should be able to see in their diff. - w.writesOffered++ - if stored, err := w.reader.GetRawUnitBytes(model.ID(unitID)); err == nil { - var unchanged bool - if contents, unchanged = canon.Reconcile(contents, stored); unchanged { - return nil - } - } - w.writesLanded++ - - // Convert UUID string to 16-byte blob - unitIDBlob := uuidToBlob(unitID) - - if w.reader.version == MPRVersionV2 { - // Get swapped UUID for file path - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Build file path: mprcontents/XX/YY/UUID.mxunit - filePath := filepath.Join( - w.reader.contentsDir, - swappedUUID[0:2], - swappedUUID[2:4], - swappedUUID+".mxunit", - ) - - // Write updated content - if err := os.WriteFile(filePath, contents, 0644); err != nil { - return fmt.Errorf("failed to write unit file: %w", err) - } - - contentsHash := contentHashBase64(contents) - _, err := w.reader.db.Exec(` - UPDATE Unit SET ContentsHash = ? WHERE UnitID = ? - `, contentsHash, unitIDBlob) - if err == nil { - w.reader.InvalidateCache() - if txErr := w.updateTransactionID(); txErr != nil { - return fmt.Errorf("failed to update transaction ID: %w", txErr) - } - } - return err - } - - // MPR v1: Update in database - contentsHash := contentHashBase64(contents) - _, err := w.reader.db.Exec(` - UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? - `, contents, contentsHash, unitIDBlob) - if err != nil && isContentsHashSchemaError(err) { - // Older v1 schemas do not have ContentsHash; retry without it. - // Any other error (disk full, invalid UnitID, rolled-back tx) propagates. - _, err = w.reader.db.Exec(` - UPDATE Unit SET Contents = ? WHERE UnitID = ? - `, contents, unitIDBlob) - } - return err -} - -// UpdateRawUnit saves raw BSON bytes for a unit, bypassing deserialization. -// Used by ALTER PAGE to modify the BSON widget tree directly. -// AddRawUnit inserts a unit verbatim: same contents, same containment name, no -// re-encoding. It is the primitive a module transplant is built from. -// -// Copying a unit wholesale is safe because element `$ID` pointers do not cross -// unit boundaries — measured at 0 of 9,910 in a real project (PROPOSAL -// marketplace_module_upgrade §4) — and cross-unit references are qualified-name -// strings. Rewriting the contents, by contrast, would risk exactly the -// intra-unit pointer inconsistency ADR-0008 forbids. -// -// The caller owns uniqueness of unitID. Inserting an ID the project already -// holds is a caller error, not something this can repair. -func (w *Writer) AddRawUnit(unitID, containerID, containmentName, unitType string, contents []byte) error { - return w.insertUnit(unitID, containerID, containmentName, unitType, contents) -} - -func (w *Writer) UpdateRawUnit(unitID string, contents []byte) error { - return w.updateUnit(unitID, contents) -} - -func (w *Writer) deleteUnit(unitID string) error { - // Convert UUID string to 16-byte blob - unitIDBlob := uuidToBlob(unitID) - if unitIDBlob == nil { - return fmt.Errorf("invalid unit ID: %s", unitID) - } - - if w.reader.version == MPRVersionV2 { - // Get swapped UUID for file path - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Delete external file - subDir1 := swappedUUID[0:2] - subDir2 := swappedUUID[2:4] - filePath := filepath.Join(w.reader.contentsDir, subDir1, subDir2, swappedUUID+".mxunit") - os.Remove(filePath) // Ignore error if file doesn't exist - - // Clean up empty parent directories (YY/, then XX/) - dir2 := filepath.Join(w.reader.contentsDir, subDir1, subDir2) - os.Remove(dir2) // Only succeeds if empty - dir1 := filepath.Join(w.reader.contentsDir, subDir1) - os.Remove(dir1) // Only succeeds if empty - } - - result, err := w.reader.db.Exec(`DELETE FROM Unit WHERE UnitID = ?`, unitIDBlob) - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %w", err) - } - - if rowsAffected == 0 { - return fmt.Errorf("unit not found in database: %s", unitID) - } - - w.reader.InvalidateCache() - if err := w.updateTransactionID(); err != nil { - return fmt.Errorf("failed to update transaction ID after deleting unit: %w", err) - } - return nil -} - -func (w *Writer) updateDomainModel(dm *domainmodel.DomainModel) error { - contents, err := w.serializeDomainModel(dm) - if err != nil { - return fmt.Errorf("failed to serialize domain model: %w", err) - } - - return w.updateUnit(string(dm.ID), contents) -} - -// UpdateDomainModel serializes and saves a domain model back to the MPR file. -func (w *Writer) UpdateDomainModel(dm *domainmodel.DomainModel) error { - return w.updateDomainModel(dm) -} diff --git a/sdk/mpr/writer_units_test.go b/sdk/mpr/writer_units_test.go deleted file mode 100644 index 0094e08893..0000000000 --- a/sdk/mpr/writer_units_test.go +++ /dev/null @@ -1,154 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "path/filepath" - "testing" - - _ "modernc.org/sqlite" -) - -func newTestWriterV1(t *testing.T, unitSchema string) (*Writer, *sql.DB) { - t.Helper() - - dbPath := filepath.Join(t.TempDir(), "test.mpr") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("failed to open sqlite database: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(unitSchema); err != nil { - t.Fatalf("failed to create Unit table: %v", err) - } - - reader := &Reader{ - db: db, - version: MPRVersionV1, - } - return &Writer{reader: reader}, db -} - -func TestInsertUnitV1_PopulatesContentsHash(t *testing.T) { - writer, db := newTestWriterV1(t, ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `) - - unitID := "11111111-1111-1111-1111-111111111111" - containerID := "22222222-2222-2222-2222-222222222222" - contents := []byte("new microflow bytes") - if err := writer.insertUnit(unitID, containerID, "Documents", "Microflows$Microflow", contents); err != nil { - t.Fatalf("insertUnit failed: %v", err) - } - - var gotHash string - var gotContents []byte - err := db.QueryRow(`SELECT ContentsHash, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotHash, &gotContents) - if err != nil { - t.Fatalf("failed to read inserted row: %v", err) - } - - if gotHash == "" { - t.Fatal("insertUnit wrote empty ContentsHash") - } - if want := contentHashBase64(contents); gotHash != want { - t.Fatalf("ContentsHash = %q, want %q", gotHash, want) - } - if string(gotContents) != string(contents) { - t.Fatalf("Contents = %q, want %q", string(gotContents), string(contents)) - } -} - -func TestUpdateUnitV1_UpdatesContentsHash(t *testing.T) { - writer, db := newTestWriterV1(t, ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `) - - unitID := "33333333-3333-3333-3333-333333333333" - containerID := "44444444-4444-4444-4444-444444444444" - oldContents := []byte("old bytes") - newContents := []byte("updated bytes") - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'Documents', 0, ?, '', ?) - `, uuidToBlob(unitID), uuidToBlob(containerID), contentHashBase64(oldContents), oldContents); err != nil { - t.Fatalf("failed to seed row: %v", err) - } - - if err := writer.updateUnit(unitID, newContents); err != nil { - t.Fatalf("updateUnit failed: %v", err) - } - - var gotHash string - var gotContents []byte - err := db.QueryRow(`SELECT ContentsHash, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotHash, &gotContents) - if err != nil { - t.Fatalf("failed to read updated row: %v", err) - } - - if gotHash == "" { - t.Fatal("updateUnit wrote empty ContentsHash") - } - if want := contentHashBase64(newContents); gotHash != want { - t.Fatalf("ContentsHash = %q, want %q", gotHash, want) - } - if string(gotContents) != string(newContents) { - t.Fatalf("Contents = %q, want %q", string(gotContents), string(newContents)) - } -} - -func TestUnitV1_OldSchemaWithoutContentsHashStillWorks(t *testing.T) { - writer, db := newTestWriterV1(t, ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - Type TEXT, - Contents BLOB - ) - `) - - unitID := "55555555-5555-5555-5555-555555555555" - containerID := "66666666-6666-6666-6666-666666666666" - initialContents := []byte("initial bytes") - updatedContents := []byte("updated old schema bytes") - - if err := writer.insertUnit(unitID, containerID, "Documents", "Microflows$Microflow", initialContents); err != nil { - t.Fatalf("insertUnit failed on old schema: %v", err) - } - if err := writer.updateUnit(unitID, updatedContents); err != nil { - t.Fatalf("updateUnit failed on old schema: %v", err) - } - - var gotType string - var gotContents []byte - err := db.QueryRow(`SELECT Type, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotType, &gotContents) - if err != nil { - t.Fatalf("failed to read old-schema row: %v", err) - } - - if gotType != "Microflows$Microflow" { - t.Fatalf("Type = %q, want %q", gotType, "Microflows$Microflow") - } - if string(gotContents) != string(updatedContents) { - t.Fatalf("Contents = %q, want %q", string(gotContents), string(updatedContents)) - } -} diff --git a/sdk/mpr/writer_validationrule_test.go b/sdk/mpr/writer_validationrule_test.go deleted file mode 100644 index 06d6fbd69e..0000000000 --- a/sdk/mpr/writer_validationrule_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - "testing" - - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// TestSerializeRuleInfo_RefusesUnreproducibleTypes is the data-loss guard. -// -// serializeRuleInfo used to fall back to RequiredRuleInfo for any type it did -// not recognise. Because ALTER ENTITY round-trips an entity through the writer, -// a stored RegEx rule was read as "RegEx" and written back as Required — the -// pattern reference gone, the field merely mandatory — and mxbuild reported -// nothing, because a Required rule is perfectly valid. -// MaxLength and EqualsTo stay refused: the model carries no payload type for -// either, so a rewrite would lose them. RegEx and Range are now writable, but -// only WITH their payload — see TestSerializeRuleInfo_RefusesPayloadlessRules. -func TestSerializeRuleInfo_RefusesUnreproducibleTypes(t *testing.T) { - for _, ruleType := range []string{"MaxLength", "EqualsTo"} { - t.Run(ruleType, func(t *testing.T) { - vr := &domainmodel.ValidationRule{Type: ruleType} - if got := serializeRuleInfo(vr); got != nil { - t.Errorf("serializeRuleInfo(%q) = %v, want nil (refusal) — a fallback silently downgrades the rule", ruleType, got) - } - if reproducibleRule(vr) { - t.Errorf("reproducibleRule(%q) = true", ruleType) - } - }) - } -} - -// TestSerializeRuleInfo_RefusesPayloadlessRules: a rule TYPE is not a rule. A -// bare RegExRuleInfo with no reference, or a Range with no bounds, is a document -// Mendix accepts that constrains nothing — the same silent downgrade wearing the -// right type name. -func TestSerializeRuleInfo_RefusesPayloadlessRules(t *testing.T) { - for _, vr := range []*domainmodel.ValidationRule{ - {Type: "RegEx"}, - {Type: "RegEx", Rule: &domainmodel.RegexValidationRuleInfo{}}, - {Type: "Range"}, - {Type: "Range", Rule: &domainmodel.RangeValidationRuleInfo{}}, - } { - if got := serializeRuleInfo(vr); got != nil { - t.Errorf("%s with rule %#v serialized to %v, want nil", vr.Type, vr.Rule, got) - } - } -} - -func TestSerializeRuleInfo_ReproducibleTypes(t *testing.T) { - for ruleType, wantType := range map[string]string{ - "Required": "DomainModels$RequiredRuleInfo", - "Unique": "DomainModels$UniqueRuleInfo", - // An empty type is what the attribute-constraint path produces for - // `not null`; it must keep working. - "": "DomainModels$RequiredRuleInfo", - } { - doc := serializeRuleInfo(&domainmodel.ValidationRule{Type: ruleType}) - if doc == nil { - t.Fatalf("serializeRuleInfo(%q) = nil, want a document", ruleType) - } - if doc[0].Key != "$ID" { - t.Errorf("%q: first key = %q, want $ID (Mendix rejects any other order)", ruleType, doc[0].Key) - } - if doc[1].Value != wantType { - t.Errorf("%q: $Type = %v, want %s", ruleType, doc[1].Value, wantType) - } - } -} - -// TestSerializeRuleInfo_RegExUsesStorageName pins the key both engines must -// write. The SDK name is "RegularExpression"; Studio Pro stores -// "RegExIdentifier", and writing the SDK name makes mxbuild report CE0135 -// "No regular expression specified" (measured on 11.13.0). -func TestSerializeRuleInfo_RegExUsesStorageName(t *testing.T) { - doc := serializeRuleInfo(&domainmodel.ValidationRule{ - Type: "RegEx", - Rule: &domainmodel.RegexValidationRuleInfo{RegularExpressionQualifiedName: "Val.EmailAddress"}, - }) - if doc == nil { - t.Fatal("a RegEx rule with a reference must serialize") - } - if doc[1].Value != "DomainModels$RegExRuleInfo" { - t.Errorf("$Type = %v", doc[1].Value) - } - if doc[2].Key != "RegExIdentifier" { - t.Errorf("reference key = %q, want RegExIdentifier — the SDK name yields CE0135", doc[2].Key) - } - if doc[2].Value != "Val.EmailAddress" { - t.Errorf("reference = %v", doc[2].Value) - } -} - -func TestSerializeRuleInfo_RangeKinds(t *testing.T) { - lo, hi := "1", "100" - tests := []struct { - name string - info *domainmodel.RangeValidationRuleInfo - want string - }{ - {"between", &domainmodel.RangeValidationRuleInfo{MinValue: &lo, MaxValue: &hi, UseMinValue: true, UseMaxValue: true}, "Between"}, - {"min only", &domainmodel.RangeValidationRuleInfo{MinValue: &lo, UseMinValue: true}, "GreaterThanOrEqualTo"}, - {"max only", &domainmodel.RangeValidationRuleInfo{MaxValue: &hi, UseMaxValue: true}, "SmallerThanOrEqualTo"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - doc := serializeRuleInfo(&domainmodel.ValidationRule{Type: "Range", Rule: tt.info}) - if doc == nil { - t.Fatal("a Range rule with bounds must serialize") - } - if doc[2].Key != "TypeOfRange" || doc[2].Value != tt.want { - t.Errorf("TypeOfRange = %v %v, want %q", doc[2].Key, doc[2].Value, tt.want) - } - }) - } -} - -func TestValidationRulesAreReproducible(t *testing.T) { - e := &domainmodel.Entity{ValidationRules: []*domainmodel.ValidationRule{ - {Type: "Required"}, {Type: "Unique"}, - }} - if _, ok := validationRulesAreReproducible(e); !ok { - t.Error("Required/Unique should be reproducible") - } - - e.ValidationRules = append(e.ValidationRules, &domainmodel.ValidationRule{Type: "EqualsTo"}) - got, ok := validationRulesAreReproducible(e) - if ok { - t.Fatal("an entity with an EqualsTo rule must not be reported reproducible") - } - if got != "EqualsTo" { - t.Errorf("reported %q, want EqualsTo", got) - } -} - -func TestUpdateEntity_RefusalNamesTheRuleAndTheConsequence(t *testing.T) { - // The message has to say what would be lost, not just that it refused — - // a bare "cannot rewrite" sends the user looking for a bug in their script. - e := &domainmodel.Entity{Name: "Person", ValidationRules: []*domainmodel.ValidationRule{{Type: "EqualsTo"}}} - ruleType, _ := validationRulesAreReproducible(e) - msg := "entity " + e.Name + " has a " + ruleType + " validation rule" - for _, want := range []string{"Person", "EqualsTo"} { - if !strings.Contains(msg, want) { - t.Errorf("message missing %q", want) - } - } -} diff --git a/sdk/mpr/writer_webservice_body_test.go b/sdk/mpr/writer_webservice_body_test.go deleted file mode 100644 index 4e95a5a438..0000000000 --- a/sdk/mpr/writer_webservice_body_test.go +++ /dev/null @@ -1,275 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func bodyGet(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -// TestWebServiceRequestBody_Arguments is the legacy half of the CE0178 fix, and -// exists to keep the two engines from drifting: the modelsdk twin -// (TestWebServiceCallAction_ArgumentsAreSimpleParameterMappings) asserts the -// same keys, values and marker. -func TestWebServiceRequestBody_Arguments(t *testing.T) { - doc := webServiceRequestBody(µflows.WebServiceCallAction{ - Arguments: []microflows.WebServiceArgument{{ - Name: "OrderId", - Path: "http%3A//www.example.com/:GetOrder|OrderId", - Expression: "$Customer/OrderId", - Checked: true, - }}, - }) - - if got := bodyGet(doc, "$Type"); got != "Microflows$SimpleRequestHandling" { - t.Fatalf("$Type = %#v", got) - } - arr, ok := bodyGet(doc, "ParameterMappings").(bson.A) - if !ok || len(arr) != 2 { - t.Fatalf("ParameterMappings = %#v, want the marker plus one mapping", bodyGet(doc, "ParameterMappings")) - } - if got, isInt := arr[0].(int32); !isInt || got != 2 { - t.Errorf("marker = %#v, want int32(2)", arr[0]) - } - pm, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("mapping = %#v", arr[1]) - } - for _, want := range []struct { - key string - val any - }{ - {"$Type", "Microflows$WebServiceOperationSimpleParameterMapping"}, - {"Argument", "$Customer/OrderId"}, - {"IsChecked", true}, - {"ParameterName", ""}, - {"ParameterPath", "http%3A//www.example.com/:GetOrder|OrderId"}, - } { - if got := bodyGet(pm, want.key); got != want.val { - t.Errorf("%s = %#v, want %#v", want.key, got, want.val) - } - } -} - -// TestWebServiceRequestBody_SendMapping is the legacy half of the CE0369 fix. -// -// MappingId / MappingVariableName are the STORAGE names; modelsdk/gen binds the -// same two properties as Mapping / MappingArgumentVariableName, which mxbuild -// tolerates and Studio Pro cannot open. -func TestWebServiceRequestBody_SendMapping(t *testing.T) { - doc := webServiceRequestBody(µflows.WebServiceCallAction{ - SendMappingID: "Clients.SoapOrderExportMapping", - SendMappingVariable: "NewSaveOrder", - }) - for _, want := range []struct { - key string - val any - }{ - {"$Type", "Microflows$MappingRequestHandling"}, - {"ContentType", "Json"}, - {"MappingId", "Clients.SoapOrderExportMapping"}, - {"MappingVariableName", "NewSaveOrder"}, - } { - if got := bodyGet(doc, want.key); got != want.val { - t.Errorf("%s = %#v, want %#v", want.key, got, want.val) - } - } -} - -// TestWebServiceRequestBody_EmptyIsTheBareSimpleForm — a call with neither -// clause still writes the empty Simple body every reference call carries, so -// this change does not alter what already shipped. -func TestWebServiceRequestBody_EmptyIsTheBareSimpleForm(t *testing.T) { - doc := webServiceRequestBody(µflows.WebServiceCallAction{}) - if got := bodyGet(doc, "$Type"); got != "Microflows$SimpleRequestHandling" { - t.Fatalf("$Type = %#v", got) - } - arr, ok := bodyGet(doc, "ParameterMappings").(bson.A) - if !ok || len(arr) != 1 { - t.Fatalf("ParameterMappings = %#v, want just the marker", bodyGet(doc, "ParameterMappings")) - } -} - -// TestParseWebServiceRequestBody round-trips both variants back into the model, -// and pins that the parameter NAME comes from the path's last segment — the only -// part MDL spells. -func TestParseWebServiceRequestBody(t *testing.T) { - action := µflows.WebServiceCallAction{} - parseWebServiceRequestBody(map[string]any{ - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", - "NullValueOption": "LeaveOutElement", - "ParameterMappings": []any{int32(2), map[string]any{ - "$Type": "Microflows$WebServiceOperationSimpleParameterMapping", - "Argument": "2", - "IsChecked": true, - "ParameterName": "", - "ParameterPath": "http%3A//www.example.com/:GetOrder|OrderId", - }}, - }, - }, action) - if len(action.Arguments) != 1 { - t.Fatalf("read %d arguments, want 1", len(action.Arguments)) - } - got := action.Arguments[0] - if got.Name != "OrderId" || got.Expression != "2" || !got.Checked || - got.Path != "http%3A//www.example.com/:GetOrder|OrderId" { - t.Errorf("argument = %+v", got) - } - - mapped := µflows.WebServiceCallAction{} - parseWebServiceRequestBody(map[string]any{ - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$MappingRequestHandling", - "ContentType": "Json", - "MappingId": "Clients.SoapOrderExportMapping", - "MappingVariableName": "NewSaveOrder", - }, - }, mapped) - if string(mapped.SendMappingID) != "Clients.SoapOrderExportMapping" || - mapped.SendMappingVariable != "NewSaveOrder" || - mapped.SendMappingContentType != "Json" { - t.Errorf("send mapping = %+v", mapped) - } - // The two variants differ in ARITY, so dispatching on $Type rather than on - // which fields are present is what stops one being read as the other. - if len(mapped.Arguments) != 0 { - t.Errorf("a mapping body produced %d arguments", len(mapped.Arguments)) - } -} - -// referenceSoapAction builds the fifteen-key action shape every ako/TestApp SOAP -// call carries, with mxcli's own values for the six boilerplate keys. -func referenceSoapAction() map[string]any { - return map[string]any{ - "$ID": "a", "$Type": "Microflows$CallWebServiceAction", - "ErrorHandlingType": "Rollback", - "HttpConfiguration": map[string]any{ - "$Type": "Microflows$HttpConfiguration", - "ClientCertificate": "", "CustomLocation": "", - "CustomLocationTemplate": nil, - "HttpAuthenticationPassword": "", "HttpAuthenticationUserName": "", - "HttpHeaderEntries": []any{int32(3)}, - "HttpMethod": "Post", - "OverrideLocation": false, "UseHttpAuthentication": false, - }, - "ImportedService": "Clients.OrderSoapClient", "IsValidationRequired": false, - "NewResultHandling": map[string]any{ - "$Type": "Microflows$ResultHandling", "Bind": true, - "ImportMappingCall": map[string]any{ - "$Type": "Microflows$ImportMappingCall", "Commit": "YesWithoutEvents", - "ContentType": "Xml", "ForceSingleOccurrence": false, - "ObjectHandlingBackup": "Create", "ParameterVariableName": "", - "Range": map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": true}, - "ReturnValueMapping": "Clients.SoapOrdersImportMapping", - }, - "ResultVariableName": "Orders", - "VariableType": map[string]any{"$Type": "DataTypes$ObjectType", "Entity": "Clients.Order"}, - }, - "OperationName": "GetOrder", "ProxyConfiguration": nil, - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", "NullValueOption": "LeaveOutElement", - "ParameterMappings": []any{int32(2), map[string]any{ - "$Type": "Microflows$WebServiceOperationSimpleParameterMapping", - "Argument": "2", "IsChecked": true, "ParameterName": "", - "ParameterPath": "http%3A//www.example.com/:GetOrder|OrderId", - }}, - }, - "RequestHeaderHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", "NullValueOption": "LeaveOutElement", - "ParameterMappings": []any{int32(2)}, - }, - "RequestProxyType": "DefaultProxy", "ServiceName": "OrdersWS", - "TimeOutExpression": "300", "UseRequestTimeOut": true, - } -} - -// TestWebServiceActionRequiresRawBSON_StructuredWhenReproducible — an action -// mxcli itself would write describes structurally rather than as base64. -// -// Before the request body was authorable this could never happen: a real call -// carries fifteen keys and only nine were admitted, so EVERY SOAP call in every -// project — Studio Pro's and mxcli's — rendered as `call web service raw '<…>'`. -func TestWebServiceActionRequiresRawBSON_StructuredWhenReproducible(t *testing.T) { - if webServiceActionRequiresRawBSON(referenceSoapAction()) { - t.Error("an action mxcli would write itself still falls back to raw") - } -} - -// TestWebServiceActionRequiresRawBSON_ValueSensitive is the regression test for -// what a describe -> exec round trip over ako/TestApp actually caught. -// -// Admitting the six boilerplate keys BY NAME would have silently normalised a -// call the moment anyone round-tripped it. Each case below is a document mxcli -// would write differently, so each must keep the byte-exact raw fallback. -func TestWebServiceActionRequiresRawBSON_ValueSensitive(t *testing.T) { - for _, tc := range []struct { - name string - mutit func(map[string]any) - }{ - // Measured: Clients.GetOrders stores SingleObject FALSE where mxcli - // writes true. No error comes of it, which is exactly why writing it - // back must not happen silently — the round trip would change the - // user's document with nothing to show for it. - {"Range.SingleObject differs", func(m map[string]any) { - rh := m["NewResultHandling"].(map[string]any) - imc := rh["ImportMappingCall"].(map[string]any) - imc["Range"] = map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": false} - }}, - // Measured: Clients.SaveOrder binds $IsSaved with NO import mapping and - // a DataTypes$BooleanType — the OPERATION's return type, which lives in - // the WSDL. Written back as VoidType it is CE0366 + CE6011. - {"result type comes from the WSDL, not a mapping", func(m map[string]any) { - m["NewResultHandling"] = map[string]any{ - "$Type": "Microflows$ResultHandling", "Bind": true, - "ImportMappingCall": nil, - "ResultVariableName": "IsSaved", - "VariableType": map[string]any{"$Type": "DataTypes$BooleanType"}, - } - }}, - {"HTTP authentication configured", func(m map[string]any) { - m["HttpConfiguration"].(map[string]any)["UseHttpAuthentication"] = true - }}, - {"custom location", func(m map[string]any) { - m["HttpConfiguration"].(map[string]any)["CustomLocation"] = "https://elsewhere/" - }}, - {"a SOAP header is configured", func(m map[string]any) { - m["RequestHeaderHandling"].(map[string]any)["ParameterMappings"] = []any{int32(2), - map[string]any{"$Type": "Microflows$WebServiceOperationSimpleParameterMapping"}} - }}, - {"validation required", func(m map[string]any) { m["IsValidationRequired"] = true }}, - {"non-default proxy", func(m map[string]any) { m["RequestProxyType"] = "NoProxy" }}, - {"timeout disabled", func(m map[string]any) { m["UseRequestTimeOut"] = false }}, - // A per-parameter export mapping has no MDL spelling at all. - {"advanced parameter mapping", func(m map[string]any) { - m["RequestBodyHandling"].(map[string]any)["ParameterMappings"] = []any{int32(2), - map[string]any{"$Type": "Microflows$WebServiceOperationAdvancedParameterMapping"}} - }}, - // Without a "|" the parameter name MDL spells cannot be recovered, so - // the write path could not rebuild the same path. - {"parameter path with no name segment", func(m map[string]any) { - pms := m["RequestBodyHandling"].(map[string]any)["ParameterMappings"].([]any) - pms[1].(map[string]any)["ParameterPath"] = "http%3A//www.example.com/:GetOrder" - }}, - {"unknown key entirely", func(m map[string]any) { m["SomethingNew"] = 1 }}, - } { - t.Run(tc.name, func(t *testing.T) { - m := referenceSoapAction() - tc.mutit(m) - if !webServiceActionRequiresRawBSON(m) { - t.Error("describes structurally, so a round trip would silently rewrite it") - } - }) - } -} diff --git a/sdk/mpr/writer_widgets.go b/sdk/mpr/writer_widgets.go deleted file mode 100644 index 4fd71c086d..0000000000 --- a/sdk/mpr/writer_widgets.go +++ /dev/null @@ -1,727 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Widget Serialization — Dispatch -// ============================================================================ - -// serializeWidgetArray serializes a slice of widgets to a BSON array with version prefix. -// Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays. -// Items go directly after the version marker, NOT nested in another array. -func serializeWidgetArray(widgets []pages.Widget) bson.A { - arr := bson.A{int32(3)} // Start with empty marker - hasItems := false - for _, w := range widgets { - if w != nil { - if !hasItems { - arr = bson.A{int32(2)} // First item: change to version 2 - hasItems = true - } - arr = append(arr, serializeWidget(w)) - } - } - return arr -} - -// SerializeWidget serializes a single widget to BSON. -// This is the public entry point for widget serialization. -func SerializeWidget(w pages.Widget) bson.D { - return serializeWidget(w) -} - -// serializeWidget serializes a single widget to BSON. -func serializeWidget(w pages.Widget) bson.D { - var doc bson.D - switch widget := w.(type) { - case *pages.Container: - doc = serializeContainer(widget) - case *pages.GroupBox: - return serializeGroupBox(widget) - case *pages.TabContainer: - return serializeTabContainer(widget) - case *pages.LayoutGrid: - doc = serializeLayoutGrid(widget) - case *pages.DynamicText: - doc = serializeDynamicText(widget) - case *pages.ActionButton: - doc = serializeActionButton(widget) - case *pages.Text: - doc = serializeStaticText(widget) - case *pages.Title: - doc = serializeTitle(widget) - case *pages.SnippetCallWidget: - doc = serializeSnippetCall(widget) - case *pages.Gallery: - doc = serializeGallery(widget) - case *pages.CustomWidget: - doc = serializeCustomWidget(widget) - case *pages.DataView: - doc = serializeDataView(widget) - case *pages.DataGrid: - doc = serializeDataGrid(widget) - case *pages.TextBox: - doc = serializeTextBox(widget) - case *pages.TextArea: - doc = serializeTextArea(widget) - case *pages.DatePicker: - doc = serializeDatePicker(widget) - case *pages.CheckBox: - doc = serializeCheckBox(widget) - case *pages.RadioButtons: - doc = serializeRadioButtons(widget) - case *pages.DropDown: - doc = serializeDropDown(widget) - case *pages.NavigationList: - doc = serializeNavigationList(widget) - case *pages.ListView: - doc = serializeListView(widget) - case *pages.StaticImage: - doc = serializeStaticImage(widget) - case *pages.DynamicImage: - doc = serializeDynamicImage(widget) - default: - // Fallback for unknown widget types - doc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(w.GetID()))}, - {Key: "$Type", Value: w.GetTypeName()}, - {Key: "Name", Value: w.GetName()}, - } - } - - // Patch conditional settings from BaseWidget if set - doc = patchConditionalSettings(doc, w) - return doc -} - -// patchConditionalSettings replaces nil ConditionalVisibilitySettings/ConditionalEditabilitySettings -// in the serialized BSON with actual values from the widget's BaseWidget fields. -func patchConditionalSettings(doc bson.D, w pages.Widget) bson.D { - type baseWidgetGetter interface { - GetBaseWidget() *pages.BaseWidget - } - bwg, ok := w.(baseWidgetGetter) - if !ok { - return doc - } - bw := bwg.GetBaseWidget() - if bw.ConditionalVisibility == nil && bw.ConditionalEditability == nil { - return doc - } - - for i, elem := range doc { - if elem.Key == "ConditionalVisibilitySettings" && bw.ConditionalVisibility != nil { - doc[i].Value = serializeConditionalVisibility(bw.ConditionalVisibility) - } - if elem.Key == "ConditionalEditabilitySettings" && bw.ConditionalEditability != nil { - doc[i].Value = serializeConditionalEditability(bw.ConditionalEditability) - } - // Editability: the conditional settings element wins, then an explicit - // `editable:`. Only ever narrowed from the template's own value when the - // author actually said something, so a widget that never mentions - // editability keeps whatever the template had. - if elem.Key == "Editable" && (bw.ConditionalEditability != nil || bw.Editable != "") { - doc[i].Value = pages.WidgetEditability(bw) - } - } - return doc -} - -func serializeConditionalVisibility(cvs *pages.ConditionalVisibilitySettings) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cvs.ID))}, - {Key: "$Type", Value: "Forms$ConditionalVisibilitySettings"}, - // Attribute is a BY_NAME AttributeIdentifier; Studio Pro writes "" (not null) - // when there is no attribute-based condition. 11.12's reader rejects the null. - {Key: "Attribute", Value: ""}, - {Key: "Conditions", Value: bson.A{int32(3)}}, - {Key: "Expression", Value: cvs.Expression}, - {Key: "IgnoreSecurity", Value: false}, - {Key: "ModuleRoles", Value: bson.A{int32(3)}}, - {Key: "SourceVariable", Value: nil}, - } -} - -func serializeConditionalEditability(ces *pages.ConditionalEditabilitySettings) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ces.ID))}, - {Key: "$Type", Value: "Forms$ConditionalEditabilitySettings"}, - {Key: "Attribute", Value: ""}, // "" not null — see serializeConditionalVisibility - {Key: "Conditions", Value: bson.A{int32(3)}}, - {Key: "Expression", Value: ces.Expression}, - {Key: "SourceVariable", Value: nil}, - } -} - -// ============================================================================ -// DataSource Serialization -// ============================================================================ - -// serializeDataSource serializes a datasource for DataView widgets (Forms$*Source types). -// NOTE: DataViews do not support database sources in Mendix. If a DatabaseSource is passed, -// it is serialized as a Forms$DataViewSource with entity reference as a best-effort fallback. -func serializeDataSource(ds pages.DataSource) bson.D { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // DataViews cannot have a database source in Mendix. Serialize as - // Forms$DataViewSource with entity ref as the closest valid alternative. - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: nil}, - } - case *pages.MicroflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: d.Microflow}, // Qualified name (e.g., "Module.MicroflowName") - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } - case *pages.NanoflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: d.Nanoflow}, // Qualified name (e.g., "Module.NanoflowName") - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } - default: - return nil - } -} - -// SerializeCustomWidgetDataSource serializes a datasource for custom widgets. -// Exported for use by page builders. -func SerializeCustomWidgetDataSource(ds pages.DataSource) bson.D { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // EntityRef needs to be serialized with the entity qualified name - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - - // Build SortItems array from Sorting field - sortItems := bson.A{int32(2)} // Version marker for non-empty array - for _, sort := range d.Sorting { - sortItem := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortItem"}, - {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: sort.AttributePath}, - {Key: "EntityRef", Value: nil}, - }}, - // Forms$GridSortItem stores its direction under SortDirection, NOT - // SortOrder (that key belongs to Microflows$SortItem / document - // templates). Studio Pro ignores the misnamed field → sort silently - // reverts to ascending. Bug 8. - {Key: "SortDirection", Value: string(sort.Direction)}, - } - sortItems = append(sortItems, sortItem) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "CustomWidgets$CustomWidgetXPathSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: sortItems}, - }}, - {Key: "SourceVariable", Value: nil}, - {Key: "XPathConstraint", Value: d.XPathConstraint}, - } - case *pages.MicroflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: d.Microflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } - case *pages.NanoflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: d.Nanoflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } - case *pages.AssociationSource: - return serializeAssociationSource(d) - default: - return nil - } -} - -// serializeAssociationSource builds a Forms$AssociationSource BSON document. -// EntityPath is "Module.Assoc" or "Module.Assoc/Module.DestEntity". -// When DestinationEntity is omitted, it's left empty — Studio Pro will resolve it. -func serializeAssociationSource(d *pages.AssociationSource) bson.D { - parts := strings.Split(d.EntityPath, "/") - association := parts[0] - destEntity := "" - if len(parts) >= 2 { - destEntity = parts[1] - } - - step := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$EntityRefStep"}, - {Key: "Association", Value: association}, - {Key: "DestinationEntity", Value: destEntity}, - } - - entityRef := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, - {Key: "Steps", Value: bson.A{int32(2), step}}, - } - - var sourceVar any - if d.ContextVariable != "" { - sourceVar = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "LocalVariable", Value: ""}, - {Key: "PageParameter", Value: d.ContextVariable}, - {Key: "SnippetParameter", Value: ""}, - {Key: "SubKey", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } - } - - id := string(d.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Forms$AssociationSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: sourceVar}, - } -} - -// ============================================================================ -// Reference Serialization -// ============================================================================ - -// serializeAttributeRef serializes an attribute reference for input widgets. -// The attrPath MUST be a fully qualified name (Module.Entity.Attribute) with at least 2 dots. -// If the path is not fully qualified, returns nil to avoid Mendix resolution errors. -func serializeAttributeRef(attrPath string) any { - if attrPath == "" { - return nil - } - // Attribute path must be fully qualified: Module.Entity.Attribute (at least 2 dots) - dotCount := strings.Count(attrPath, ".") - if dotCount < 2 { - // Not fully qualified - cannot serialize as Mendix won't be able to resolve it - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: attrPath}, - {Key: "EntityRef", Value: nil}, - } -} - -// serializeEntityRef serializes an entity reference. -func serializeEntityRef(entityPath string) any { - if entityPath == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: entityPath}, - } -} - -// ============================================================================ -// Appearance Serialization -// ============================================================================ - -// serializeAppearance creates a standard Appearance object for widgets. -func serializeAppearance(class, style, dynamicClasses string, designProps []pages.DesignPropertyValue) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$Appearance"}, - {Key: "Class", Value: class}, - {Key: "DesignProperties", Value: serializeDesignProperties(designProps)}, - {Key: "DynamicClasses", Value: dynamicClasses}, - {Key: "Style", Value: style}, - } -} - -// serializeDesignProperties serializes design property values to a BSON array. -// Both empty and non-empty use version marker int64(3). -func serializeDesignProperties(props []pages.DesignPropertyValue) bson.A { - if len(props) == 0 { - return bson.A{int32(3)} - } - - arr := bson.A{int32(3)} - for _, p := range props { - var valueBson bson.D - switch p.ValueType { - case "toggle": - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ToggleDesignPropertyValue"}, - } - case "option": - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$OptionDesignPropertyValue"}, - {Key: "Option", Value: p.Option}, - } - case "custom": - // ToggleButtonGroup and ColorPicker properties use CustomDesignPropertyValue - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$CustomDesignPropertyValue"}, - {Key: "Value", Value: p.Option}, - } - case "compound": - // A property whose value is itself a set of sub-properties (e.g. Atlas - // "Spacing" → margin-top/-bottom/…). Forms$CompoundDesignPropertyValue holds - // the sub-entries in a Properties list with the SAME marker-prefixed - // Forms$DesignPropertyValue shape as the outer array, so recurse. Without - // this case a compound design property was silently dropped on write (the - // old `default: continue`), while toggle/option survived. - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$CompoundDesignPropertyValue"}, - {Key: "Properties", Value: serializeDesignProperties(p.Compound)}, - } - default: - continue - } - arr = append(arr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$DesignPropertyValue"}, - {Key: "Key", Value: p.Key}, - {Key: "Value", Value: valueBson}, - }) - } - return arr -} - -// ============================================================================ -// Input Widget Helpers -// ============================================================================ - -// serializeWidgetValidation creates the required WidgetValidation object for input widgets. -func serializeWidgetValidation() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$WidgetValidation"}, - {Key: "Expression", Value: ""}, - {Key: "Message", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - } -} - -// serializeFormattingInfo creates a default FormattingInfo object for input widgets. -func serializeFormattingInfo() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormattingInfo"}, - {Key: "CustomDateFormat", Value: ""}, - {Key: "DateFormat", Value: "Date"}, - {Key: "DecimalPrecision", Value: int64(2)}, - {Key: "EnumFormat", Value: "Text"}, - {Key: "GroupDigits", Value: false}, - } -} - -// ============================================================================ -// Text/Template Helpers -// ============================================================================ - -// serializeEmptyText creates an empty Texts$Text object. -// Required for properties like CounterMessage that cannot be null. -func serializeEmptyText() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - } -} - -// serializeEmptyPlaceholderTemplate creates an empty ClientTemplate for placeholder text. -func serializeEmptyPlaceholderTemplate() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Parameters", Value: bson.A{int32(3)}}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - } -} - -// serializePlaceholderTemplate creates a placeholder ClientTemplate from a Text, -// or an empty one when nil. Same Forms$ClientTemplate shape as the label template. -func serializePlaceholderTemplate(t *model.Text) bson.D { - if t == nil { - return serializeEmptyPlaceholderTemplate() - } - text := "" - for _, v := range t.Translations { - text = v - break - } - if text == "" { - return serializeEmptyPlaceholderTemplate() - } - return serializeLabelTemplate(text) -} - -// serializeLabelTemplate creates a standard label template for input widgets. -func serializeLabelTemplate(label string) bson.D { - if label == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Parameters", Value: bson.A{int32(3)}}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: label}, - }}}, - }}, - } -} - -// serializeClientTemplate serializes a ClientTemplate with parameters. -func serializeClientTemplate(ct *pages.ClientTemplate, fallbackText *model.Text, defaultText string) bson.D { - captionID := generateUUID() - captionTransID := generateUUID() - captionText := defaultText - - // Get text from ClientTemplate or fallback Text - if ct != nil && ct.Template != nil { - for _, text := range ct.Template.Translations { - captionText = text - break - } - } else if fallbackText != nil { - for _, text := range fallbackText.Translations { - captionText = text - break - } - } - - // Build the template document - // Mendix uses [3] as version marker, followed by array items - template := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(captionTransID)}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: captionText}, - }}}, - } - - // Build Fallback as a Texts$Text object (not a string) - fallback := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, // Empty fallback - } - - // Build parameters array - use [3] for empty, [2, items...] for non-empty - params := bson.A{int32(3)} // Empty array with version marker 3 - if ct != nil && len(ct.Parameters) > 0 { - params = bson.A{int32(2)} // Non-empty array uses version marker 2 - for _, param := range ct.Parameters { - params = append(params, serializeClientTemplateParameter(param)) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(captionID)}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: fallback}, // Must be Fallback object, not FallbackValue string - {Key: "Parameters", Value: params}, - {Key: "Template", Value: template}, - } -} - -// serializeClientTemplateParameter serializes a ClientTemplateParameter. -func serializeClientTemplateParameter(param *pages.ClientTemplateParameter) bson.D { - paramID := generateUUID() - if param.ID != "" { - paramID = string(param.ID) - } - - // Build AttributeRef if present - use serializeAttributeRef for validation - attrRef := serializeAttributeRef(param.AttributeRef) - - // Build FormattingInfo — schema-aligned with Forms$FormattingInfo - // reflection (CustomDateFormat / DateFormat / DecimalPrecision / - // EnumFormat / GroupDigits). Writing TimeFormat here triggers Studio - // Pro CE0463 "widget definition changed" on pluggable widgets that - // embed this struct (e.g. Gallery / DataGrid2 column captions). - // - // Use the parameter's per-parameter formatting when present; a nil - // FormattingInfo reproduces the previous hardcoded defaults, so every - // unformatted parameter is byte-identical to before. - dateFormat, customDateFormat, enumFormat := "Date", "", "Text" - decimalPrecision := int64(2) - groupDigits := false - if fi := param.FormattingInfo; fi != nil { - if fi.DateFormat != "" { - dateFormat = fi.DateFormat - } - customDateFormat = fi.CustomDateFormat - if fi.EnumFormat != "" { - enumFormat = fi.EnumFormat - } - decimalPrecision = int64(fi.DecimalPrecision) - groupDigits = fi.GroupDigits - } - formattingInfo := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormattingInfo"}, - {Key: "CustomDateFormat", Value: customDateFormat}, - {Key: "DateFormat", Value: dateFormat}, - {Key: "DecimalPrecision", Value: decimalPrecision}, - {Key: "EnumFormat", Value: enumFormat}, - {Key: "GroupDigits", Value: groupDigits}, - } - - // Build SourceVariable if present. Studio Pro distinguishes three bindings on - // the same Forms$PageVariable — LocalVariable (a page `Variables:` entry), - // SnippetParameter, and PageParameter — and exactly one is populated. This - // wrote PageParameter for all three, so a binding to a page-level variable - // named a page parameter that does not exist (upstream #977). The modelsdk - // writer already branched; the two now agree. - var sourceVariable any - if param.SourceVariable != "" { - fields := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - } - switch param.SourceVariableKind { - case "local": - fields = append(fields, - bson.E{Key: "LocalVariable", Value: param.SourceVariable}, - bson.E{Key: "PageParameter", Value: ""}, - bson.E{Key: "SnippetParameter", Value: ""}, - ) - case "snippet": - fields = append(fields, - bson.E{Key: "LocalVariable", Value: ""}, - bson.E{Key: "PageParameter", Value: ""}, - bson.E{Key: "SnippetParameter", Value: param.SourceVariable}, - ) - default: - fields = append(fields, - bson.E{Key: "LocalVariable", Value: ""}, - bson.E{Key: "PageParameter", Value: param.SourceVariable}, - bson.E{Key: "SnippetParameter", Value: ""}, - ) - } - sourceVariable = append(fields, - bson.E{Key: "UseAllPages", Value: false}, - bson.E{Key: "Widget", Value: ""}, - ) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramID)}, - {Key: "$Type", Value: "Forms$ClientTemplateParameter"}, - {Key: "AttributeRef", Value: attrRef}, - {Key: "Expression", Value: param.Expression}, - {Key: "FormattingInfo", Value: formattingInfo}, - {Key: "SourceVariable", Value: sourceVariable}, - } -} diff --git a/sdk/mpr/writer_widgets_action.go b/sdk/mpr/writer_widgets_action.go deleted file mode 100644 index 1274b331ba..0000000000 --- a/sdk/mpr/writer_widgets_action.go +++ /dev/null @@ -1,275 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Client Action Serialization -// ============================================================================ - -// SerializeClientAction serializes a ClientAction to BSON. -// This is the exported version for use by the pluggable widget engine. -func SerializeClientAction(action pages.ClientAction) bson.D { - return serializeClientAction(action) -} - -// serializeClientAction serializes a ClientAction. -func serializeClientAction(action pages.ClientAction) bson.D { - if action == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - } - } - - switch a := action.(type) { - case *pages.SaveChangesClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$SaveChangesClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - {Key: "SyncAutomatically", Value: true}, - } - case *pages.CancelChangesClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$CancelChangesClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - } - case *pages.ClosePageClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$ClosePageClientAction"}, - } - case *pages.DeleteClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$DeleteClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - } - case *pages.LinkClientAction: - // OPEN_LINK fell through to the default below and was written as - // Forms$NoAction, exactly as SIGN_OUT was — the button rendered and did - // nothing (CapTrackV2 FINDINGS §10). - // - // The storage name is Forms$OpenLinkClientAction, NOT the - // "Forms$LinkClientAction" the semantic type carries. Pinned against 31 - // Studio Pro-authored link buttons: five keys, LinkType "Web" in all 31, - // address nested as a Forms$StaticOrDynamicString whose AttributeRef is - // null for the static form MDL authors. - linkType := string(a.LinkType) - if linkType == "" { - linkType = "Web" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$OpenLinkClientAction"}, - {Key: "Address", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$StaticOrDynamicString"}, - {Key: "AttributeRef", Value: nil}, - {Key: "IsDynamic", Value: false}, - {Key: "Value", Value: a.Address}, - }}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "LinkType", Value: linkType}, - } - case *pages.SignOutClientAction: - // Until this case existed, SIGN_OUT fell through to the default below - // and was written as Forms$NoAction — so the button rendered, said - // "Sign out", and did nothing, with `mxcli check`, `exec` and `mx check` - // all clean. That made the documented workaround for the modelsdk - // engine's refusal ("rerun with MXCLI_ENGINE=legacy") the more dangerous - // of the two paths (CapTrackV2 FINDINGS §10). - // - // One property, pinned against a Studio Pro-authored button (ako/TestApp, - // Mendix 11): DisabledDuringExecution, true. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$SignOutClientAction"}, - {Key: "DisabledDuringExecution", Value: true}, - } - case *pages.CreateObjectClientAction: - // Build EntityRef if entity is specified - var entityRef any - if a.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: a.EntityName}, - } - } - // Build PageSettings (Forms$FormSettings) - always required, even if no page specified - pageSettings := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: a.PageName}, // BY_NAME_REFERENCE - qualified name, or empty string if no page - {Key: "ParameterMappings", Value: bson.A{int32(2)}}, - {Key: "TitleOverride", Value: nil}, // no override: the page keeps its own title (#812) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$CreateObjectClientAction"}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "EntityRef", Value: entityRef}, - {Key: "NumberOfPagesToClose2", Value: ""}, - {Key: "PageSettings", Value: pageSettings}, - } - case *pages.PageClientAction: - // Studio Pro stores ParameterMappings as an empty initialized array [2] and - // infers $currentObject from the enclosing widget context (DataGrid, DataView, etc.). - // Storing explicit inline Forms$PageParameterMapping objects with an Argument of - // "$currentObject" makes Studio Pro report CE0115 "parameters do not match" — a - // widget's current-row object is represented by an inferred WidgetValue, not an - // Argument expression (issue #296; re-confirmed against mxbuild 11.12.1 for - // FINDINGS #56). - // - // The other half of this decision lives in DESCRIBE, which must put the - // argument back from the target page's own parameters — see - // pageActionParameters. It did not, for a long time, and this comment - // asserted that it did (mxcli-formula1 §39). - formSettings := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: a.PageName}, // BY_NAME_REFERENCE - qualified name - {Key: "ParameterMappings", Value: bson.A{int32(2)}}, - {Key: "TitleOverride", Value: nil}, // no override: the page keeps its own title (#812) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$FormAction"}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "FormSettings", Value: formSettings}, - {Key: "NumberOfPagesToClose2", Value: ""}, - {Key: "PagesForSpecializations", Value: bson.A{int32(2)}}, - } - case *pages.MicroflowClientAction: - // Build ParameterMappings if any - paramMappings := bson.A{int32(len(a.ParameterMappings))} - for _, pm := range a.ParameterMappings { - // Parameter is BY_NAME_REFERENCE: MicroflowName.ParameterName - paramRef := a.MicroflowName + "." + pm.ParameterName - - // Determine the expression value - var expression string - if pm.Variable != "" { - expression = pm.Variable // e.g., "$Customer" - } else if pm.Expression != "" { - expression = pm.Expression - } - - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowParameterMapping"}, - {Key: "Expression", Value: expression}, - {Key: "Parameter", Value: paramRef}, // BY_NAME_REFERENCE - {Key: "Variable", Value: nil}, - } - paramMappings = append(paramMappings, mapping) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$MicroflowAction"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Microflow", Value: a.MicroflowName}, - {Key: "ParameterMappings", Value: paramMappings}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Asynchronous", Value: false}, - {Key: "FormValidations", Value: "All"}, - {Key: "ConfirmationInfo", Value: nil}, - }}, - {Key: "DisabledDuringExecution", Value: true}, - } - case *pages.NanoflowClientAction: - // Build ParameterMappings if any - nfParamMappings := bson.A{int32(len(a.ParameterMappings))} - for _, pm := range a.ParameterMappings { - // Parameter is BY_NAME_REFERENCE: NanoflowName.ParameterName - paramRef := a.NanoflowName + "." + pm.ParameterName - - // Determine the expression value - var expression string - if pm.Variable != "" { - expression = pm.Variable // e.g., "$Customer" - } else if pm.Expression != "" { - expression = pm.Expression - } - - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowParameterMapping"}, - {Key: "Expression", Value: expression}, - {Key: "Parameter", Value: paramRef}, // BY_NAME_REFERENCE - {Key: "Variable", Value: nil}, - } - nfParamMappings = append(nfParamMappings, mapping) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$CallNanoflowClientAction"}, - {Key: "Nanoflow", Value: a.NanoflowName}, - {Key: "ParameterMappings", Value: nfParamMappings}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "DisabledDuringExecution", Value: true}, - } - case *pages.SetTaskOutcomeClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$SetTaskOutcomeClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - {Key: "Commit", Value: a.Commit}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "OutcomeValue", Value: a.OutcomeValue}, - } - case *pages.NoClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$NoAction"}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - } - } -} - -// buildFormPageVariable returns a Forms$PageVariable BSON document. -// pageParam is the page parameter name that supplies the value (without leading $). -// For Forms$PageParameterMapping (show-page button), all sub-fields are empty and -// the variable is carried in the sibling Argument field. -// For Forms$SnippetParameterMapping, pageParam is set and Argument is empty. -func buildFormPageVariable(pageParam string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "LocalVariable", Value: ""}, - {Key: "PageParameter", Value: pageParam}, - {Key: "SnippetParameter", Value: ""}, - {Key: "SubKey", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } -} diff --git a/sdk/mpr/writer_widgets_action_test.go b/sdk/mpr/writer_widgets_action_test.go deleted file mode 100644 index aaafcc660d..0000000000 --- a/sdk/mpr/writer_widgets_action_test.go +++ /dev/null @@ -1,286 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// getFormSettings extracts FormSettings from a serialized Forms$FormAction document. -func getFormSettings(t *testing.T, doc bson.D) bson.D { - t.Helper() - for _, e := range doc { - if e.Key == "FormSettings" { - fs, ok := e.Value.(bson.D) - if !ok { - t.Fatalf("FormSettings is not bson.D, got %T", e.Value) - } - return fs - } - } - t.Fatal("FormSettings not found") - return nil -} - -// getParamMappings extracts ParameterMappings from a FormSettings document. -func getParamMappings(t *testing.T, formSettings bson.D) primitive.A { - t.Helper() - for _, e := range formSettings { - if e.Key == "ParameterMappings" { - arr, ok := e.Value.(primitive.A) - if !ok { - t.Fatalf("ParameterMappings is not primitive.A, got %T", e.Value) - } - return arr - } - } - t.Fatal("ParameterMappings not found") - return nil -} - -// TestPageClientAction_ParameterMappings_TypeIndicator verifies that -// Forms$FormAction always serializes ParameterMappings as [2] (type indicator -// only, no inline mapping objects), matching Studio Pro's native format. -// -// Studio Pro infers $currentObject from the enclosing widget context at runtime -// rather than reading explicit Forms$PageParameterMapping objects from BSON. -// Using int32(len) as the array's first element produces an invalid type -// indicator that Studio Pro cannot read, causing CE0115 (issue #296). -func TestPageClientAction_ParameterMappings_TypeIndicator(t *testing.T) { - action := &pages.PageClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - PageName: "AuditTrail.Log_View", - ParameterMappings: []*pages.PageClientParameterMapping{ - { - BaseElement: model.BaseElement{ID: "mapping-id"}, - ParameterName: "Log", - Variable: "$currentObject", - }, - }, - } - - doc := serializeClientAction(action) - if doc == nil { - t.Fatal("serializeClientAction returned nil") - } - - formSettings := getFormSettings(t, doc) - mappings := getParamMappings(t, formSettings) - - // Must be exactly [int32(2)] — type indicator only, no inline objects. - // Studio Pro's reader skips the type indicator (2 or 3) and reads the rest - // as items; any other first-element value is treated as invalid. - if len(mappings) != 1 { - t.Fatalf("ParameterMappings: want exactly 1 element (type indicator), got %d", len(mappings)) - } - indicator, ok := mappings[0].(int32) - if !ok { - t.Fatalf("ParameterMappings[0] is not int32, got %T", mappings[0]) - } - if indicator != 2 { - t.Errorf("ParameterMappings type indicator = %d, want 2", indicator) - } -} - -// TestPageClientAction_NoParams_TypeIndicator verifies that a PageClientAction -// without parameter mappings still serializes ParameterMappings as [2]. -func TestPageClientAction_NoParams_TypeIndicator(t *testing.T) { - action := &pages.PageClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - PageName: "Sales.Customer_Overview", - } - - doc := serializeClientAction(action) - if doc == nil { - t.Fatal("serializeClientAction returned nil") - } - - var bsonType string - for _, e := range doc { - if e.Key == "$Type" { - bsonType, _ = e.Value.(string) - } - } - if bsonType != "Forms$FormAction" { - t.Errorf("$Type = %q, want %q", bsonType, "Forms$FormAction") - } - - formSettings := getFormSettings(t, doc) - mappings := getParamMappings(t, formSettings) - if len(mappings) != 1 { - t.Fatalf("ParameterMappings: want [2], got %v", mappings) - } -} - -// TestPageClientAction_RequiredFields verifies that Forms$FormAction includes -// all fields required by Studio Pro: NumberOfPagesToClose2, PagesForSpecializations, -// and FormSettings.TitleOverride. -func TestPageClientAction_RequiredFields(t *testing.T) { - action := &pages.PageClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - PageName: "Sales.Order_Detail", - ParameterMappings: []*pages.PageClientParameterMapping{ - {ParameterName: "Order", Variable: "$Order"}, - {ParameterName: "Customer", Variable: "$Customer"}, - }, - } - - doc := serializeClientAction(action) - - fields := map[string]bool{} - for _, e := range doc { - fields[e.Key] = true - } - for _, required := range []string{"NumberOfPagesToClose2", "PagesForSpecializations"} { - if !fields[required] { - t.Errorf("Forms$FormAction missing required field %q", required) - } - } - - formSettings := getFormSettings(t, doc) - fsFields := map[string]bool{} - for _, e := range formSettings { - fsFields[e.Key] = true - } - if !fsFields["TitleOverride"] { - t.Errorf("FormSettings missing required field %q", "TitleOverride") - } - - // TitleOverride must be null. A button opening a page has no MDL syntax for - // overriding the opened page's title, so the page always keeps its own. - // - // This assertion was previously inverted, on the reasoning that Studio Pro rejects - // null embedded objects ("same class of bug as issue #295"). #295 was about - // Forms$PageVariable; the conclusion was generalised to TitleOverride without being - // observed. An empty Microflows$TextTemplate is not "no override" — it overrides - // with the empty string, so every popup opened by an mxcli-authored button showed a - // blank caption and only the close button (#812). - found := false - for _, e := range formSettings { - if e.Key != "TitleOverride" { - continue - } - found = true - if e.Value != nil { - t.Fatalf("TitleOverride = %#v, want nil (#812)", e.Value) - } - } - if !found { - t.Error("TitleOverride key missing entirely; Studio Pro writes it as an explicit null") - } -} - -// CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by -// the default modelsdk engine with "client action *pages.SignOutClientAction -// not yet supported … rerun with MXCLI_ENGINE=legacy". -// -// That advice was the more dangerous of the two paths. The legacy writer had no -// case for the action either, so it fell through to the default below and wrote -// Forms$NoAction: the button rendered, said "Sign out", and did nothing, with -// `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13 — -// `describe page` came back `actionbutton btnOut (Caption: 'Sign out')`, no -// action at all, and the stored BSON held Forms$NoAction. -// -// The shape is pinned against a Studio Pro-authored sign-out button -// (ako/TestApp), which is provably Studio Pro's rather than mxcli's: until this -// change NEITHER engine could emit the type. -func TestSignOutClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.SignOutClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - }) - if doc == nil { - t.Fatal("serializeClientAction returned nil") - } - - got := map[string]any{} - for _, e := range doc { - got[e.Key] = e.Value - } - - if got["$Type"] == "Forms$NoAction" { - t.Fatal("SIGN_OUT was written as Forms$NoAction — the button renders and does nothing, " + - "which check, exec and mx check all report as fine") - } - if got["$Type"] != "Forms$SignOutClientAction" { - t.Errorf("$Type = %v, want Forms$SignOutClientAction", got["$Type"]) - } - if got["DisabledDuringExecution"] != true { - t.Errorf("DisabledDuringExecution = %v, want true (the Studio Pro reference's only property)", - got["DisabledDuringExecution"]) - } - // The reference carries exactly these three keys and no more. An extra - // property is what Studio Pro refuses to open even when mxbuild accepts it. - if len(doc) != 3 { - t.Errorf("the action has %d keys, want 3 ($ID, $Type, DisabledDuringExecution): %v", len(doc), doc) - } -} - -// CONTROL: the quiet default still exists and still yields Forms$NoAction, so -// the tests here prove something about the actions they name rather than about -// the fallback having been removed. -// -// ShowHomePage is the stand-in: no MDL statement builds one, so it is a -// semantic type nothing writes. (An earlier draft used LinkClientAction, which -// stopped being a valid control the moment OPEN_LINK was implemented — a -// control has to name something still genuinely unhandled.) -func TestUnhandledClientActionStillFallsBackToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.ShowHomePageClientAction{ - BaseElement: model.BaseElement{ID: "home-id"}, - }) - var typeName string - for _, e := range doc { - if e.Key == "$Type" { - typeName, _ = e.Value.(string) - } - } - if typeName != "Forms$NoAction" { - t.Errorf("$Type = %q; this control pins the fallback SIGN_OUT and OPEN_LINK used to hit, "+ - "so the tests above cannot pass for the wrong reason", typeName) - } -} - -// OPEN_LINK on the legacy engine, which fell to that same NoAction default. -// Pinned against the 31 Studio Pro references: five keys, and the address a -// nested Forms$StaticOrDynamicString whose AttributeRef is null for the static -// form MDL authors. -func TestOpenLinkClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.LinkClientAction{ - BaseElement: model.BaseElement{ID: "link-id"}, - LinkType: pages.LinkTypeWeb, - Address: "https://example.com", - }) - got := map[string]any{} - for _, e := range doc { - got[e.Key] = e.Value - } - if got["$Type"] != "Forms$OpenLinkClientAction" { - t.Fatalf("$Type = %v, want Forms$OpenLinkClientAction (NOT Forms$LinkClientAction, "+ - "which is the SDK name and not what Mendix stores)", got["$Type"]) - } - if got["LinkType"] != "Web" { - t.Errorf("LinkType = %v, want Web", got["LinkType"]) - } - if len(doc) != 5 { - t.Errorf("the action has %d keys, want 5: %v", len(doc), doc) - } - addr, ok := got["Address"].(bson.D) - if !ok { - t.Fatalf("Address is %T, want a nested document", got["Address"]) - } - a := map[string]any{} - for _, e := range addr { - a[e.Key] = e.Value - } - if a["$Type"] != "Forms$StaticOrDynamicString" || a["IsDynamic"] != false || - a["Value"] != "https://example.com" { - t.Errorf("Address = %v", addr) - } - if _, present := a["AttributeRef"]; !present { - t.Error("AttributeRef is absent; all 31 references carry it as null") - } -} diff --git a/sdk/mpr/writer_widgets_container_test.go b/sdk/mpr/writer_widgets_container_test.go deleted file mode 100644 index 663f010a49..0000000000 --- a/sdk/mpr/writer_widgets_container_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Issue #603: a Container (Forms$DivContainer) is clickable via its -// OnClickAction. serializeContainer must wire the configured action through -// instead of always emitting Forms$NoAction. - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// bsonLookup returns the value of key in doc, or nil if absent. -func bsonLookup(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -// bsonSubDoc returns doc[key] as a bson.D, failing the test if it is missing or -// not a sub-document. -func bsonSubDoc(t *testing.T, doc bson.D, key string) bson.D { - t.Helper() - v := bsonLookup(doc, key) - sub, ok := v.(bson.D) - if !ok { - t.Fatalf("field %q: want bson.D, got %T", key, v) - } - return sub -} - -// TestSerializeContainer_DynamicClasses locks in the DynamicClasses serialization fix: a widget's -// DynamicClasses expression is serialized into its Forms$Appearance -// (previously the field was hardcoded to ""). -func TestSerializeContainer_DynamicClasses(t *testing.T) { - c := &pages.Container{} - c.Name = "box" - c.Class = "ss-box" - c.DynamicClasses = "if $currentObject/Name = '' then 'ss-box--empty' else ''" - - doc := serializeContainer(c) - - appearance := bsonSubDoc(t, doc, "Appearance") - if got := bsonLookup(appearance, "DynamicClasses"); got != c.DynamicClasses { - t.Errorf("Appearance.DynamicClasses = %v, want %q", got, c.DynamicClasses) - } - if got := bsonLookup(appearance, "Class"); got != "ss-box" { - t.Errorf("Appearance.Class = %v, want %q", got, "ss-box") - } -} - -func TestSerializeContainer_OnClickActionDefaultsToNoAction(t *testing.T) { - c := &pages.Container{} - c.Name = "box" - - doc := serializeContainer(c) - - action := bsonSubDoc(t, doc, "OnClickAction") - if got := bsonLookup(action, "$Type"); got != "Forms$NoAction" { - t.Errorf("default OnClickAction $Type = %v, want Forms$NoAction", got) - } -} - -func TestSerializeContainer_OnClickActionMicroflow(t *testing.T) { - c := &pages.Container{ - OnClickAction: &pages.MicroflowClientAction{ - MicroflowName: "MyFirstModule.MyFirstLogic", - }, - } - c.Name = "box" - - doc := serializeContainer(c) - - action := bsonSubDoc(t, doc, "OnClickAction") - if got := bsonLookup(action, "$Type"); got != "Forms$MicroflowAction" { - t.Fatalf("OnClickAction $Type = %v, want Forms$MicroflowAction", got) - } - settings := bsonSubDoc(t, action, "MicroflowSettings") - if got := bsonLookup(settings, "Microflow"); got != "MyFirstModule.MyFirstLogic" { - t.Errorf("Microflow = %v, want MyFirstModule.MyFirstLogic", got) - } -} diff --git a/sdk/mpr/writer_widgets_custom.go b/sdk/mpr/writer_widgets_custom.go deleted file mode 100644 index 6e63b3f2a4..0000000000 --- a/sdk/mpr/writer_widgets_custom.go +++ /dev/null @@ -1,472 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Custom/Pluggable Widget Serialization -// ============================================================================ - -// serializeCustomWidget serializes a CustomWidget (pluggable widget) to BSON. -// If the widget has a RawType (cloned from existing widget), use that instead. -func serializeCustomWidget(cw *pages.CustomWidget) bson.D { - // Check if we have a raw type definition to use - if cw.RawType != nil { - return serializeCustomWidgetWithRawType(cw) - } - - // Build widget type from structured data - widgetType := serializeCustomWidgetType(cw.WidgetType) - - // Build widget object (properties) - widgetObject := serializeWidgetObject(cw.WidgetObject) - - editable := cw.Editable - if editable == "" { - editable = "Always" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cw.ID))}, - {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, - {Key: "Appearance", Value: serializeAppearance(cw.Class, cw.Style, cw.DynamicClasses, cw.DesignProperties)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: editable}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(cw.Label)}, - {Key: "Name", Value: cw.Name}, - {Key: "Object", Value: widgetObject}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Type", Value: widgetType}, - } - - return doc -} - -// serializeCustomWidgetWithRawType serializes a CustomWidget using a pre-cloned raw type definition. -func serializeCustomWidgetWithRawType(cw *pages.CustomWidget) bson.D { - // Use the cloned RawObject if available (contains all property values) - // Otherwise fall back to building from WidgetObject with PropertyTypeIDMap - var widgetObject any - if cw.RawObject != nil { - widgetObject = cw.RawObject - } else { - // Build widget object (properties) - this still needs to match the raw type's PropertyType IDs - // The ObjectTypeID is used to set the TypePointer on the WidgetObject - widgetObject = serializeWidgetObjectForRawType(cw.WidgetObject, cw.PropertyTypeIDMap, cw.ObjectTypeID) - } - - editable := cw.Editable - if editable == "" { - editable = "Always" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cw.ID))}, - {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, - {Key: "Appearance", Value: serializeAppearance(cw.Class, cw.Style, cw.DynamicClasses, cw.DesignProperties)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: editable}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(cw.Label)}, - {Key: "Name", Value: cw.Name}, - {Key: "Object", Value: widgetObject}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Type", Value: cw.RawType}, - } - - return doc -} - -// serializeWidgetObjectForRawType serializes WidgetObject using the PropertyType IDs from a cloned type. -// The objectTypeID parameter is used to set the TypePointer which references the WidgetObjectType. -func serializeWidgetObjectForRawType(wo *pages.WidgetObject, propTypeIDMap map[string]pages.PropertyTypeIDEntry, objectTypeID string) bson.D { - if wo == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: bson.A{int32(3)}}, - {Key: "TypePointer", Value: nil}, - } - } - - id := string(wo.ID) - if id == "" { - id = generateUUID() - } - - var properties bson.A - if len(wo.Properties) == 0 { - properties = bson.A{int32(3)} - } else { - properties = bson.A{int32(2)} // Version marker for non-empty array - for _, prop := range wo.Properties { - // Look up the PropertyType IDs from the map - var propertyTypeID, valueTypeID string - if propTypeIDMap != nil && prop.PropertyKey != "" { - if ids, ok := propTypeIDMap[prop.PropertyKey]; ok { - propertyTypeID = ids.PropertyTypeID - valueTypeID = ids.ValueTypeID - } - } - properties = append(properties, serializeWidgetPropertyForRawType(prop, propertyTypeID, valueTypeID)) - } - } - - // Build TypePointer - references the WidgetObjectType from the cloned CustomWidgetType - var typePointer any - if objectTypeID != "" { - typePointer = idToBsonBinary(objectTypeID) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: properties}, - {Key: "TypePointer", Value: typePointer}, - } -} - -// serializeWidgetPropertyForRawType serializes a widget property using specific PropertyType and ValueType IDs. -func serializeWidgetPropertyForRawType(prop *pages.WidgetProperty, propertyTypeID, valueTypeID string) bson.D { - if prop == nil { - return nil - } - - id := string(prop.ID) - if id == "" { - id = generateUUID() - } - - // Use the provided IDs, or fall back to the property's TypePointer - ptID := propertyTypeID - if ptID == "" { - ptID = string(prop.TypePointer) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: idToBsonBinary(ptID)}, - {Key: "Value", Value: serializeWidgetValueForRawType(prop.Value, valueTypeID)}, - } -} - -// serializeWidgetValueForRawType serializes a widget value using a specific ValueType ID. -func serializeWidgetValueForRawType(val *pages.WidgetValue, valueTypeID string) bson.D { - if val == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(nil)}, - {Key: "AttributeRef", Value: nil}, - {Key: "DataSource", Value: nil}, - {Key: "EntityRef", Value: nil}, - {Key: "Expression", Value: ""}, - {Key: "Form", Value: ""}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: ""}, - {Key: "Microflow", Value: ""}, - {Key: "Nanoflow", Value: ""}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: idToBsonBinary(valueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - } - } - - id := string(val.ID) - if id == "" { - id = generateUUID() - } - - // Serialize DataSource if present - var dataSource any - if val.DataSource != nil { - dataSource = SerializeCustomWidgetDataSource(val.DataSource) - } - - // Serialize widgets if present - widgets := bson.A{int32(2)} - for _, w := range val.Widgets { - widgets = append(widgets, serializeWidget(w)) - } - - // Use the provided ValueType ID - var typePointer any - if valueTypeID != "" { - typePointer = idToBsonBinary(valueTypeID) - } else if val.TypePointer != "" { - typePointer = idToBsonBinary(string(val.TypePointer)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(val.Action)}, - {Key: "AttributeRef", Value: serializeAttributeRef(val.AttributeRef)}, - {Key: "DataSource", Value: dataSource}, - {Key: "EntityRef", Value: serializeEntityRef(val.EntityRef)}, - {Key: "Expression", Value: val.Expression}, - {Key: "Form", Value: val.Form}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: val.Image}, - {Key: "Microflow", Value: val.Microflow}, - {Key: "Nanoflow", Value: val.Nanoflow}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: val.PrimitiveValue}, - {Key: "Selection", Value: val.Selection}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: typePointer}, - {Key: "Widgets", Value: widgets}, - } -} - -// serializeCustomWidgetType serializes the CustomWidgetType. -func serializeCustomWidgetType(wt *pages.CustomWidgetType) bson.D { - if wt == nil { - return nil - } - - id := string(wt.ID) - if id == "" { - id = generateUUID() - } - - objectTypeID := generateUUID() - - supportedPlatform := wt.SupportedPlatform - if supportedPlatform == "" { - supportedPlatform = "Web" - } - - // Use the ObjectType ID from wt if available - otID := objectTypeID - if wt.ObjectType != nil && wt.ObjectType.ID != "" { - otID = string(wt.ObjectType.ID) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, - {Key: "HelpUrl", Value: wt.HelpURL}, - {Key: "ObjectType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(otID)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObjectType"}, - {Key: "PropertyTypes", Value: serializePropertyTypes(wt.ObjectType)}, - }}, - {Key: "OfflineCapable", Value: wt.OfflineCapable}, - {Key: "StudioCategory", Value: ""}, - {Key: "StudioProCategory", Value: ""}, - {Key: "SupportedPlatform", Value: supportedPlatform}, - {Key: "WidgetDescription", Value: wt.Description}, - {Key: "WidgetId", Value: wt.WidgetID}, - {Key: "WidgetName", Value: wt.Name}, - {Key: "WidgetNeedsEntityContext", Value: wt.NeedsEntityContext}, - {Key: "WidgetPluginWidget", Value: wt.PluginWidget}, - } - - return doc -} - -// serializePropertyTypes serializes the property types for a widget. -func serializePropertyTypes(ot *pages.WidgetObjectType) bson.A { - if ot == nil || len(ot.PropertyTypes) == 0 { - return bson.A{int32(3)} - } - - arr := bson.A{int32(2)} // Version marker for non-empty array - - for _, pt := range ot.PropertyTypes { - id := string(pt.ID) - if id == "" { - id = generateUUID() - } - // Use the ValueTypeID if provided, otherwise generate a new one - vtID := string(pt.ValueTypeID) - if vtID == "" { - vtID = generateUUID() - } - arr = append(arr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, - {Key: "Caption", Value: pt.Caption}, - {Key: "Category", Value: ""}, - {Key: "Description", Value: pt.Description}, - {Key: "IsDefault", Value: pt.IsDefault}, - {Key: "PropertyKey", Value: pt.Key}, - {Key: "ValueType", Value: serializeWidgetValueType(vtID, pt.ValueType)}, - }) - } - - return arr -} - -// serializeWidgetValueType serializes a WidgetValueType for a property type. -// The id parameter is the ValueTypeID that WidgetValue.TypePointer should reference. -// The valueType string is converted to the appropriate Type enum value. -func serializeWidgetValueType(id string, valueType string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetValueType"}, - {Key: "ActionVariables", Value: bson.A{int32(2)}}, - {Key: "AllowedTypes", Value: bson.A{int32(1)}}, - {Key: "AllowNonPersistableEntities", Value: false}, - {Key: "AllowUpload", Value: false}, - {Key: "AssociationTypes", Value: bson.A{int32(1)}}, - {Key: "DataSourceProperty", Value: ""}, - {Key: "DefaultType", Value: "None"}, - {Key: "DefaultValue", Value: ""}, - {Key: "EntityProperty", Value: ""}, - {Key: "EnumerationValues", Value: bson.A{int32(2)}}, - {Key: "IsList", Value: false}, - {Key: "IsPath", Value: "No"}, - {Key: "LinkableEntityTypes", Value: bson.A{int32(1)}}, - {Key: "MicroflowActionInfo", Value: nil}, - {Key: "ObjectType", Value: nil}, - {Key: "OnChangeProperty", Value: ""}, - {Key: "PathType", Value: "None"}, - {Key: "ReturnType", Value: nil}, - {Key: "SelectableObjectsProperty", Value: ""}, - {Key: "SelectionTypes", Value: bson.A{int32(1)}}, - {Key: "SetLabel", Value: false}, - {Key: "Translations", Value: bson.A{int32(2)}}, - {Key: "Type", Value: valueType}, - } -} - -// serializeWidgetObject serializes the WidgetObject (property values). -func serializeWidgetObject(wo *pages.WidgetObject) bson.D { - if wo == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: bson.A{int32(3)}}, - } - } - - id := string(wo.ID) - if id == "" { - id = generateUUID() - } - - var properties bson.A - if len(wo.Properties) == 0 { - properties = bson.A{int32(3)} - } else { - properties = bson.A{int32(2)} // Version marker for non-empty array - for _, prop := range wo.Properties { - properties = append(properties, serializeWidgetProperty(prop)) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: properties}, - } -} - -// serializeWidgetProperty serializes a single widget property. -func serializeWidgetProperty(prop *pages.WidgetProperty) bson.D { - if prop == nil { - return nil - } - - id := string(prop.ID) - if id == "" { - id = generateUUID() - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: idToBsonBinary(string(prop.TypePointer))}, - {Key: "Value", Value: serializeWidgetValue(prop.Value)}, - } -} - -// serializeWidgetValue serializes a widget property value. -func serializeWidgetValue(val *pages.WidgetValue) bson.D { - if val == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(nil)}, - {Key: "AttributeRef", Value: nil}, - {Key: "DataSource", Value: nil}, - {Key: "EntityRef", Value: nil}, - {Key: "Expression", Value: ""}, - {Key: "Form", Value: ""}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: ""}, - {Key: "Microflow", Value: ""}, - {Key: "Nanoflow", Value: ""}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: nil}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - } - } - - id := string(val.ID) - if id == "" { - id = generateUUID() - } - - // Serialize DataSource if present - var dataSource any - if val.DataSource != nil { - dataSource = SerializeCustomWidgetDataSource(val.DataSource) - } - - // Serialize widgets if present - widgets := bson.A{int32(2)} - for _, w := range val.Widgets { - widgets = append(widgets, serializeWidget(w)) - } - - // TypePointer should be null when not set - var typePointer any - if val.TypePointer != "" { - typePointer = idToBsonBinary(string(val.TypePointer)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(val.Action)}, - {Key: "AttributeRef", Value: serializeAttributeRef(val.AttributeRef)}, - {Key: "DataSource", Value: dataSource}, - {Key: "EntityRef", Value: serializeEntityRef(val.EntityRef)}, - {Key: "Expression", Value: val.Expression}, - {Key: "Form", Value: val.Form}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: val.Image}, - {Key: "Microflow", Value: val.Microflow}, - {Key: "Nanoflow", Value: val.Nanoflow}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: val.PrimitiveValue}, - {Key: "Selection", Value: val.Selection}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: typePointer}, - {Key: "Widgets", Value: widgets}, - } -} diff --git a/sdk/mpr/writer_widgets_display.go b/sdk/mpr/writer_widgets_display.go deleted file mode 100644 index 0e4106374d..0000000000 --- a/sdk/mpr/writer_widgets_display.go +++ /dev/null @@ -1,970 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeSnippetCall serializes a SnippetCallWidget. -func serializeSnippetCall(s *pages.SnippetCallWidget) bson.D { - // Build parameter mappings array. - // Format: [count, mapping1, mapping2, ...] where count is the Mendix array version marker. - // Type is Forms$SnippetParameterMapping (not Forms$PageParameterMapping). - // The variable reference goes in Variable.PageParameter; Argument is always empty. - paramMappings := bson.A{int32(len(s.ParameterMappings))} - for _, pm := range s.ParameterMappings { - // Parameter is BY_NAME_REFERENCE: SnippetQualifiedName.ParameterName - paramRef := s.SnippetName + "." + pm.ParamName - // Strip leading $ from the variable name for PageParameter sub-field - varName := strings.TrimPrefix(pm.Argument, "$") - paramMappings = append(paramMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$SnippetParameterMapping"}, - {Key: "Argument", Value: ""}, - {Key: "Parameter", Value: paramRef}, - {Key: "Variable", Value: buildFormPageVariable(varName)}, - }) - } - - // Build the inner SnippetCall object - snippetCallID := generateUUID() - snippetCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(snippetCallID)}, - {Key: "$Type", Value: "Forms$SnippetCall"}, - {Key: "ParameterMappings", Value: paramMappings}, - } - - // Add snippet reference - prefer qualified name (BY_NAME_REFERENCE) over binary ID - if s.SnippetName != "" { - snippetCall = append(snippetCall, bson.E{Key: "Form", Value: s.SnippetName}) - } else if s.SnippetID != "" { - snippetCall = append(snippetCall, bson.E{Key: "Form", Value: idToBsonBinary(string(s.SnippetID))}) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(s.ID))}, - {Key: "$Type", Value: "Forms$SnippetCallWidget"}, - {Key: "Appearance", Value: serializeAppearance(s.Class, s.Style, s.DynamicClasses, s.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "FormCall", Value: snippetCall}, - {Key: "Name", Value: s.Name}, - {Key: "TabIndex", Value: int64(0)}, - } - - return doc -} - -// serializeGallery serializes a Gallery widget as Forms$ListView. -// Note: Forms$Gallery is not available in all Mendix versions, so we use ListView as a fallback. -// ListView provides similar grid-based item display functionality. -func serializeGallery(g *pages.Gallery) bson.D { - // Default values - pageSize := g.PageSize - if pageSize == 0 { - pageSize = 20 - } - numberOfColumns := g.DesktopItems - if numberOfColumns == 0 { - numberOfColumns = 4 - } - - // Serialize datasource - Gallery (as ListView) requires a non-null DataSource - var dataSource any - if g.DataSource != nil { - dataSource = serializeListViewDataSource(g.DataSource) - } - // Fallback: provide empty ListViewXPathSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = emptyListViewXPathSource() - } - - // Build content widgets - contentWidgets := bson.A{int32(3)} - if g.ContentWidget != nil { - contentWidgets = append(contentWidgets, serializeWidget(g.ContentWidget)) - } - - // Templates array (empty for basic ListView) - templates := bson.A{int32(3)} - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(g.ID))}, - {Key: "$Type", Value: "Forms$ListView"}, - {Key: "Appearance", Value: serializeAppearance(g.Class, g.Style, g.DynamicClasses, g.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(nil)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: dataSource}, - {Key: "Editable", Value: false}, - {Key: "Name", Value: g.Name}, - {Key: "NumberOfColumns", Value: int64(numberOfColumns)}, - {Key: "PageSize", Value: int64(pageSize)}, - {Key: "PullDownAction", Value: serializeClientAction(nil)}, - {Key: "ScrollDirection", Value: "Vertical"}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Templates", Value: templates}, - {Key: "Widgets", Value: contentWidgets}, - } - - return doc -} - -// serializeListView serializes a ListView widget. -func serializeListView(lv *pages.ListView) bson.D { - // Default values - pageSize := lv.PageSize - if pageSize == 0 { - pageSize = 20 - } - - // Serialize datasource - ListView requires a non-null DataSource (EntityWidget) - var dataSource any - if lv.DataSource != nil { - dataSource = serializeListViewDataSource(lv.DataSource) - } - // Fallback: provide empty ListViewXPathSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = emptyListViewXPathSource() - } - - // Build content widgets - contentWidgets := serializeWidgetArray(lv.Widgets) - - // Templates array - templates := bson.A{int32(3)} - if len(lv.Templates) > 0 { - templates = bson.A{int32(2)} - for _, t := range lv.Templates { - templateWidgets := bson.A{int32(3)} - if len(t.Widgets) > 0 { - templateWidgets = bson.A{int32(2)} - for _, w := range t.Widgets { - templateWidgets = append(templateWidgets, serializeWidget(w)) - } - } - // Key order and names match Studio Pro's own documents: $ID, $Type, - // Entity, Widgets. "Entity" is the storage name of the SDK's - // Specialization property — see pages.ListViewTemplate. - template := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(t.ID))}, - {Key: "$Type", Value: "Forms$ListViewTemplate"}, - {Key: "Entity", Value: t.Specialization}, - {Key: "Widgets", Value: templateWidgets}, - } - templates = append(templates, template) - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(lv.ID))}, - {Key: "$Type", Value: "Forms$ListView"}, - {Key: "Appearance", Value: serializeAppearance(lv.Class, lv.Style, lv.DynamicClasses, lv.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(lv.ClickAction)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: dataSource}, - {Key: "Editable", Value: lv.Editable}, - {Key: "Name", Value: lv.Name}, - {Key: "NumberOfColumns", Value: int64(1)}, - {Key: "PageSize", Value: int64(pageSize)}, - {Key: "PullDownAction", Value: serializeClientAction(nil)}, - {Key: "ScrollDirection", Value: "Vertical"}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Templates", Value: templates}, - {Key: "Widgets", Value: contentWidgets}, - } - - return doc -} - -// emptyListViewXPathSource is the fallback empty database source used when a -// ListView (or Gallery-as-ListView) has no datasource yet. It must carry the same -// metamodel-valid shape as a populated source — a Forms$GridSortBar with SortItems -// and a Forms$ListViewSearch with SearchRefs — so the Mendix client can read -// .length of those lists instead of crashing on an absent/misnamed array. -func emptyListViewXPathSource() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ListViewXPathSource"}, - {Key: "ForceFullObjects", Value: false}, - {Key: "EntityRef", Value: nil}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: bson.A{int32(2)}}, - }}, - {Key: "Search", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ListViewSearch"}, - {Key: "SearchRefs", Value: bson.A{int32(3)}}, - }}, - {Key: "XPathConstraint", Value: ""}, - } -} - -// serializeListViewDataSource serializes a datasource for ListView widgets. -// Supports DatabaseSource (XPath), MicroflowSource, NanoflowSource, and AssociationSource. -func serializeListViewDataSource(ds pages.DataSource) bson.D { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // EntityRef for database source - use EntityName (qualified name) not EntityID - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - // Sorting lives on a Forms$GridSortBar / Forms$GridSortItem list (SortItems), - // exactly like the pluggable CustomWidgetXPathSource — NOT a Forms$ListViewSort. - // ListViewXPathSource has no `Sort` property; emitting one (and a `Paths` key on - // Search) produced a datasource whose client model omitted the arrays the Mendix - // client reads .length of, crashing retrieveByXPath/processResult. Mirror the - // GridSortBar shape and use the metamodel's SearchRefs list (searchPaths was - // removed in 7.11.0). - sortItems := bson.A{int32(2)} // typed-array marker, matches Studio Pro even when empty - for _, sort := range d.Sorting { - sortItems = append(sortItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortItem"}, - {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: sort.AttributePath}, - {Key: "EntityRef", Value: nil}, - }}, - {Key: "SortDirection", Value: string(sort.Direction)}, - }) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$ListViewXPathSource"}, - {Key: "ForceFullObjects", Value: false}, - {Key: "EntityRef", Value: entityRef}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: sortItems}, - }}, - {Key: "Search", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ListViewSearch"}, - {Key: "SearchRefs", Value: bson.A{int32(3)}}, - }}, - {Key: "XPathConstraint", Value: d.XPathConstraint}, - } - case *pages.MicroflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: d.Microflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } - case *pages.NanoflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: d.Nanoflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } - case *pages.AssociationSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$AssociationSource"}, - {Key: "EntityRef", Value: nil}, - } - default: - return nil - } -} - -// serializeDynamicText serializes a DynamicText widget. -func serializeDynamicText(dt *pages.DynamicText) bson.D { - renderMode := string(dt.RenderMode) - if renderMode == "" { - renderMode = "Text" - } - - // Create fallback text from AttributePath for backward compatibility - var fallbackText *model.Text - if dt.AttributePath != "" && dt.Content == nil { - fallbackText = &model.Text{ - Translations: map[string]string{model.AuthoringLanguage(): dt.AttributePath}, - } - } - - // Build content as ClientTemplate - content := serializeClientTemplate(dt.Content, fallbackText, "Dynamic Text") - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dt.ID))}, - {Key: "$Type", Value: "Forms$DynamicText"}, - {Key: "Appearance", Value: serializeAppearance(dt.Class, dt.Style, dt.DynamicClasses, dt.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Content", Value: content}, - {Key: "Name", Value: dt.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "NativeTextStyle", Value: "Text"}, - {Key: "RenderMode", Value: renderMode}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// serializeActionButton serializes an ActionButton widget. -func serializeActionButton(ab *pages.ActionButton) bson.D { - buttonStyle := string(ab.ButtonStyle) - if buttonStyle == "" { - buttonStyle = "Default" - } - - // RenderType distinguishes a normal action button ("Button") from a - // link-rendered one ("Link", authored as `linkbutton`). - renderType := string(ab.RenderMode) - if renderType == "" { - renderType = "Button" - } - - // Build caption as ClientTemplate - caption := serializeClientTemplate(ab.CaptionTemplate, ab.Caption, "Button") - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ab.ID))}, - {Key: "$Type", Value: "Forms$ActionButton"}, - {Key: "Action", Value: serializeClientAction(ab.Action)}, - {Key: "Appearance", Value: serializeAppearance(ab.Class, ab.Style, ab.DynamicClasses, ab.DesignProperties)}, - {Key: "AriaRole", Value: "Button"}, - {Key: "ButtonStyle", Value: buttonStyle}, - {Key: "CaptionTemplate", Value: caption}, // Must be CaptionTemplate, not Caption - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Icon", Value: buildWidgetIconBson(ab.Icon)}, - {Key: "Name", Value: ab.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "RenderType", Value: renderType}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Tooltip", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - } - return doc -} - -// buildWidgetIconBson serializes a widget's icon element, or nil when there is -// none. -// -// This key was hardcoded to nil, so under `--engine legacy` a button's icon was -// dropped on every write — silently, since a null Icon is what an iconless -// button stores and nothing downstream could tell the two apart. The icon- -// collection form has been authorable since #602 and was only ever written by -// the modelsdk engine. -// -// It dispatches on the kind for the same reason buildMenuIconBson does: an -// icon-collection icon and an image icon are both a qualified name, into -// different documents, so nothing in the payload distinguishes them and a writer -// that guesses turns one into the other (mendixlabs/mxcli#1059). -func buildWidgetIconBson(icon *pages.Icon) interface{} { - if icon == nil { - return nil - } - storage := types.MenuIconStorageType(icon.Kind) - if storage == "" { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: storage}, - } - if icon.Kind == types.MenuIconGlyph { - // A glyph with no code identifies no glyph. Emit no icon rather than an - // element nobody can see. - if icon.Code == 0 { - return nil - } - return append(doc, bson.E{Key: "Code", Value: int32(icon.Code)}) - } - if icon.Image == "" { - return nil - } - return append(doc, bson.E{Key: "Image", Value: icon.Image}) -} - -// serializeStaticText serializes a static Text widget. -func serializeStaticText(t *pages.Text) bson.D { - textValue := "Text" - if t.Caption != nil { - for _, text := range t.Caption.Translations { - textValue = text - break - } - } - - renderMode := string(t.RenderMode) - if renderMode == "" { - renderMode = "Text" - } - - // Mendix uses [3] as version marker, followed by array items - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(t.ID))}, - {Key: "$Type", Value: "Forms$Text"}, - {Key: "Appearance", Value: serializeAppearance(t.Class, t.Style, t.DynamicClasses, t.DesignProperties)}, - {Key: "Caption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: textValue}, - }}}, - }}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: t.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "NativeTextStyle", Value: "Text"}, - {Key: "RenderMode", Value: renderMode}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// serializeTitle serializes a Title widget. -func serializeTitle(t *pages.Title) bson.D { - textValue := "Title" - if t.Caption != nil { - for _, text := range t.Caption.Translations { - textValue = text - break - } - } - - // Mendix uses [3] as version marker, followed by array items - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(t.ID))}, - {Key: "$Type", Value: "Forms$Title"}, - {Key: "Appearance", Value: serializeAppearance(t.Class, t.Style, t.DynamicClasses, t.DesignProperties)}, - {Key: "Caption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: textValue}, - }}}, - }}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: t.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// dataViewLabelWidth resolves the LabelWidth to write to BSON. The rule lives on -// the model (pages.DataView.ResolvedLabelWidth) so this writer and the modelsdk one -// cannot drift — only this one used to translate FormOrientation, which is how -// `FormOrientation: Vertical` came to be silently dropped on the default engine -// (mendixlabs/mxcli#762). -func dataViewLabelWidth(dv *pages.DataView) int64 { - return int64(dv.ResolvedLabelWidth()) -} - -// serializeDataView serializes a DataView widget with all required properties. -func serializeDataView(dv *pages.DataView) bson.D { - // Build NoEntityMessage as Texts$Text - noEntityMessage := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - } - - // Build data source - DataView requires a non-null DataSource (EntityWidget) - var dataSource any - if dv.DataSource != nil { - dataSource = serializeDataViewDataSource(dv.DataSource) - } - // Fallback: provide empty DataViewSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: nil}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: nil}, - } - } - - // Build widgets - widgets := serializeWidgetArray(dv.Widgets) - - // Build footer widgets - footerWidgets := serializeWidgetArray(dv.FooterWidgets) - - // Determine editability - editability := "Always" - if dv.ReadOnly { - editability = "Never" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dv.ID))}, - {Key: "$Type", Value: "Forms$DataView"}, - {Key: "Appearance", Value: serializeAppearance(dv.Class, dv.Style, dv.DynamicClasses, dv.DesignProperties)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: dataSource}, - {Key: "Editability", Value: editability}, - {Key: "FooterWidgets", Value: footerWidgets}, - {Key: "LabelWidth", Value: dataViewLabelWidth(dv)}, - {Key: "Name", Value: dv.Name}, - {Key: "NoEntityMessage", Value: noEntityMessage}, - {Key: "ReadOnlyStyle", Value: "Control"}, - {Key: "ShowFooter", Value: dv.ShowFooter}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Widgets", Value: widgets}, - } - - return doc -} - -// serializeDataViewDataSource serializes a data source for DataView widgets. -// DataView requires Forms$DataViewSource with EntityRef and SourceVariable for parameter references. -func serializeDataViewDataSource(ds pages.DataSource) any { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DataViewSource: - // DataView using page parameter - needs Forms$DataViewSource with EntityRef and SourceVariable - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - - // Build SourceVariable as Forms$PageVariable - var sourceVariable any - if d.ParameterName != "" { - // Determine if this is a snippet parameter or page parameter - pageParam := d.ParameterName - snippetParam := "" - if d.IsSnippetParameter { - pageParam = "" - snippetParam = d.ParameterName - } - sourceVariable = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "LocalVariable", Value: ""}, - {Key: "PageParameter", Value: pageParam}, - {Key: "SnippetParameter", Value: snippetParam}, - {Key: "SubKey", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: sourceVariable}, - } - case *pages.DatabaseSource: - // For database source in DataView, use standard serialization - return serializeDataSource(d) - case *pages.MicroflowSource: - return serializeDataSource(d) - case *pages.NanoflowSource: - return serializeDataSource(d) - case *pages.ListenToWidgetSource: - // ListenTargetSource - listens to another widget's selection - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$ListenTargetSource"}, - {Key: "ForceFullObjects", Value: false}, - {Key: "ListenTarget", Value: d.WidgetName}, - } - case *pages.AssociationSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$AssociationSource"}, - {Key: "EntityRef", Value: nil}, - } - default: - // Fallback to generic datasource serialization - return nil - } -} - -// serializeDataGrid serializes a DataGrid widget with columns. -func serializeDataGrid(dg *pages.DataGrid) bson.D { - // Build data source - DataGrid requires a non-null DataSource (EntityWidget) - var dataSource any - if dg.DataSource != nil { - dataSource = serializeDataGridDataSource(dg.DataSource) - } - // Fallback: provide empty NewGridDatabaseSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NewGridDatabaseSource"}, - {Key: "EntityRef", Value: nil}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: bson.A{int32(3)}}, - }}, - {Key: "XPathConstraint", Value: ""}, - } - } - - // Build columns - columns := bson.A{int32(3)} // Start with empty marker - if len(dg.Columns) > 0 { - columns = bson.A{int32(2)} - for _, col := range dg.Columns { - columns = append(columns, serializeDataGridColumn(col)) - } - } - - // Build control bar widgets - controlBarWidgets := serializeWidgetArray(dg.ControlBarWidgets) - - // Selection mode - selectionMode := "Single" - switch dg.SelectionMode { - case pages.SelectionModeMulti: - selectionMode = "Multi" - case pages.SelectionModeNone: - selectionMode = "No" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dg.ID))}, - {Key: "$Type", Value: "Forms$DataGrid"}, - {Key: "Appearance", Value: serializeAppearance(dg.Class, dg.Style, dg.DynamicClasses, dg.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(nil)}, - {Key: "Columns", Value: columns}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "ControlBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ControlBar"}, - {Key: "DefaultButton", Value: nil}, - {Key: "Widgets", Value: controlBarWidgets}, - }}, - {Key: "DataSource", Value: dataSource}, - {Key: "IsControlBarVisible", Value: len(dg.ControlBarWidgets) > 0}, - {Key: "Name", Value: dg.Name}, - {Key: "NumberOfRows", Value: int64(20)}, - {Key: "RefreshTime", Value: int64(0)}, - {Key: "SelectFirst", Value: dg.SelectFirst}, - {Key: "SelectionMode", Value: selectionMode}, - {Key: "ShowEmptyRows", Value: dg.ShowEmptyRows}, - {Key: "ShowPagingBar", Value: "YesWithTotalCount"}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "TooltipForm", Value: nil}, - {Key: "WidthUnit", Value: "Percentage"}, - } - - return doc -} - -// serializeDataGridColumn serializes a DataGridColumn. -func serializeDataGridColumn(col *pages.DataGridColumn) bson.D { - // Build caption text - var caption any - if col.Caption != nil { - caption = serializeText(col.Caption) - } else { - caption = serializeEmptyText() - } - - // Build attribute reference - var attrRef any - if col.AttributePath != "" { - attrRef = serializeAttributeRef(col.AttributePath) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(col.ID))}, - {Key: "$Type", Value: "Forms$DataGridColumn"}, - {Key: "AggregateCaption", Value: serializeEmptyText()}, - {Key: "AggregateFunction", Value: "None"}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "AttributeRef", Value: attrRef}, - {Key: "Caption", Value: caption}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "Editable", Value: col.Editable}, - {Key: "FormatType", Value: "Attribute"}, - {Key: "Name", Value: col.Name}, - {Key: "ShowTooltip", Value: true}, - {Key: "Width", Value: int64(100)}, - } - - return doc -} - -// serializeDataGridDataSource serializes a data source for DataGrid widgets. -func serializeDataGridDataSource(ds pages.DataSource) any { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // Build entity reference - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - - // Build sort bar - var sortBar any - if len(d.Sorting) > 0 { - sortItems := bson.A{int32(2)} - for _, sort := range d.Sorting { - sortDir := "Ascending" - if sort.Direction == pages.SortDirectionDescending { - sortDir = "Descending" - } - sortItem := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSort"}, - {Key: "AttributeRef", Value: serializeAttributeRef(sort.AttributePath)}, - {Key: "SortOrder", Value: sortDir}, - } - sortItems = append(sortItems, sortItem) - } - sortBar = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: sortItems}, - } - } else { - sortBar = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: bson.A{int32(3)}}, - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NewGridDatabaseSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "SortBar", Value: sortBar}, - {Key: "XPathConstraint", Value: d.XPathConstraint}, - } - default: - return nil - } -} - -// serializeNavigationList serializes a NavigationList widget. -func serializeNavigationList(nl *pages.NavigationList) bson.D { - // Build items array - items := bson.A{int32(3)} // Empty marker - hasItems := false - for _, item := range nl.Items { - if !hasItems { - items = bson.A{int32(2)} // First item: change to version 2 - hasItems = true - } - items = append(items, serializeNavigationListItem(item)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(nl.ID))}, - {Key: "$Type", Value: "Forms$NavigationList"}, - {Key: "Appearance", Value: serializeAppearance(nl.Class, nl.Style, nl.DynamicClasses, nl.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Items", Value: items}, - {Key: "Name", Value: nl.Name}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// serializeNavigationListItem serializes a NavigationListItem. -func serializeNavigationListItem(item *pages.NavigationListItem) bson.D { - var widgets bson.A - - if len(item.Widgets) > 0 { - // Item has explicit child widgets - serialize them directly - widgets = bson.A{int32(2)} - for _, w := range item.Widgets { - widgetDoc := serializeWidget(w) - if widgetDoc != nil { - widgets = append(widgets, widgetDoc) - } - } - } else { - // No explicit widgets - create a DynamicText from the Caption field - captionText := "Item" - if item.Caption != nil { - for _, text := range item.Caption.Translations { - captionText = text - break - } - } - - dt := &pages.DynamicText{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: model.ID(generateUUID()), - TypeName: "Forms$DynamicText", - }, - Name: "text_" + item.Name, - }, - Content: &pages.ClientTemplate{ - BaseElement: model.BaseElement{ - ID: model.ID(generateUUID()), - TypeName: "Forms$ClientTemplate", - }, - Template: &model.Text{ - BaseElement: model.BaseElement{ - ID: model.ID(generateUUID()), - TypeName: "Texts$Text", - }, - Translations: map[string]string{model.AuthoringLanguage(): captionText}, - }, - }, - RenderMode: pages.TextRenderModeText, - } - widgets = bson.A{int32(2), serializeDynamicText(dt)} - } - - // Build action - action := serializeClientAction(item.Action) - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(item.ID))}, - {Key: "$Type", Value: "Forms$NavigationListItem"}, - {Key: "Action", Value: action}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: item.Name}, - {Key: "Widgets", Value: widgets}, - } -} - -// serializeStaticImage serializes a StaticImage widget. -func serializeStaticImage(img *pages.StaticImage) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(img.ID))}, - {Key: "$Type", Value: "Forms$StaticImageViewer"}, - // AlternativeText is not optional — generated/metamodel declares it - // without omitempty and all three Studio-Pro-authored static images in - // ako/TestApp carry it. It used to be omitted here. - {Key: "AlternativeText", Value: emptyAlternativeText()}, - {Key: "Appearance", Value: serializeAppearance(img.Class, img.Style, img.DynamicClasses, img.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(img.OnClickAction)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Height", Value: int64(img.Height)}, - {Key: "HeightUnit", Value: "Auto"}, - // An unset by-name reference is "", never null: measured 0 nulls against - // 4,400+ empty strings over 40 (type, property) pairs in ako/TestApp. - {Key: "Image", Value: ""}, - {Key: "Name", Value: img.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "Responsive", Value: img.Responsive}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Width", Value: int64(img.Width)}, - {Key: "WidthUnit", Value: "Auto"}, - } - return doc -} - -// emptyAlternativeText is the Forms$ClientTemplate an image widget carries when -// no alternative text has been set — an empty Template, an empty Fallback and no -// parameters. -// -// Pinned to the three Studio-Pro-authored Forms$StaticImageViewer widgets in -// ako/TestApp (FeedbackModule). The dynamic image used to build its own version -// of this carrying a "FallbackValue" string instead: Forms$ClientTemplate has no -// such property (generated/metamodel: Fallback / Parameters / Template), and an -// invented key is the failure Studio Pro reports as "Sequence contains no -// matching element" while mxbuild builds it at 0 errors. Note the empty -// Parameters list takes marker 2, not the 3 an empty Texts$Text takes. -func emptyAlternativeText() bson.D { - emptyText := func() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: emptyText()}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Template", Value: emptyText()}, - } -} - -// serializeDynamicImage serializes a DynamicImage widget. -func serializeDynamicImage(img *pages.DynamicImage) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(img.ID))}, - {Key: "$Type", Value: "Forms$ImageViewer"}, - {Key: "AlternativeText", Value: emptyAlternativeText()}, - {Key: "Appearance", Value: serializeAppearance(img.Class, img.Style, img.DynamicClasses, img.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(img.OnClickAction)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ImageViewerSource"}, - {Key: "EntityRef", Value: nil}, - }}, - // "" not null — an unset by-name reference; see serializeStaticImage. - {Key: "DefaultImage", Value: ""}, - {Key: "Height", Value: int64(img.Height)}, - {Key: "HeightUnit", Value: "Auto"}, - {Key: "Name", Value: img.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnClickEnlarge", Value: false}, - {Key: "Responsive", Value: img.Responsive}, - {Key: "ShowAsThumbnail", Value: false}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Width", Value: int64(img.Width)}, - {Key: "WidthUnit", Value: "Auto"}, - } - return doc -} diff --git a/sdk/mpr/writer_widgets_icon_test.go b/sdk/mpr/writer_widgets_icon_test.go deleted file mode 100644 index 42516b2f0e..0000000000 --- a/sdk/mpr/writer_widgets_icon_test.go +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/pages" -) - -// The legacy engine wrote `Icon: nil` on every action button, unconditionally. -// A button's icon has been authorable since #602 and only the modelsdk engine -// ever wrote one, so under `--engine legacy` the icon was dropped on every -// write — silently, because a null Icon is exactly what an iconless button -// stores and nothing downstream could tell the two apart (mendixlabs/mxcli#1059). -// -// These assert on the encoded document, which is the layer the defect lived in: -// the model carried the icon correctly the whole time. - -// iconOf serializes a button and returns its Icon element as a plain map, or -// nil when the icon was written as null. -func iconOf(t *testing.T, icon *pages.Icon) map[string]any { - t.Helper() - doc := serializeActionButton(&pages.ActionButton{ - BaseWidget: pages.BaseWidget{Name: "btnEdit"}, - Icon: icon, - }) - for _, e := range doc { - if e.Key != "Icon" { - continue - } - if e.Value == nil { - return nil - } - nested, ok := e.Value.(bson.D) - if !ok { - t.Fatalf("Icon is a %T, want bson.D", e.Value) - } - out := make(map[string]any, len(nested)) - for _, f := range nested { - out[f.Key] = f.Value - } - return out - } - t.Fatal("serialized button has no Icon key at all") - return nil -} - -func TestSerializeActionButton_WritesEachIconElement(t *testing.T) { - cases := []struct { - name string - icon *pages.Icon - wantType string - wantImage string - wantCode int32 - }{{ - name: "collection", - icon: &pages.Icon{Kind: types.MenuIconCollection, Image: "Atlas_Core.Atlas_Filled.pencil"}, - wantType: "Forms$IconCollectionIcon", - wantImage: "Atlas_Core.Atlas_Filled.pencil", - }, { - name: "image", - icon: &pages.Icon{Kind: types.MenuIconImage, Image: "DesignSystem.Icons_SVG.edit"}, - wantType: "Forms$ImageIcon", - wantImage: "DesignSystem.Icons_SVG.edit", - }, { - name: "glyph", - icon: &pages.Icon{Kind: types.MenuIconGlyph, Code: 57377}, - wantType: "Forms$GlyphIcon", - wantCode: 57377, - }} - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := iconOf(t, tc.icon) - if got == nil { - t.Fatal("the icon was written as null — the legacy drop") - } - if got["$Type"] != tc.wantType { - t.Errorf("$Type = %v, want %q", got["$Type"], tc.wantType) - } - if tc.wantImage != "" && got["Image"] != tc.wantImage { - t.Errorf("Image = %v, want %q", got["Image"], tc.wantImage) - } - if tc.wantCode != 0 && got["Code"] != tc.wantCode { - t.Errorf("Code = %v (%T), want %d as int32", got["Code"], got["Code"], tc.wantCode) - } - // A glyph has no name and a named icon has no code. Writing the - // other variant's payload alongside is how a reader would then have - // to guess which one the element really is. - if tc.wantCode == 0 && got["Code"] != nil { - t.Errorf("a named icon carries Code = %v", got["Code"]) - } - if tc.wantImage == "" && got["Image"] != nil { - t.Errorf("a glyph icon carries Image = %v", got["Image"]) - } - }) - } -} - -// CONTROL: an iconless button must still write a null Icon — that is what -// Studio Pro stores, and it is the TypeDefault the rest of the document expects. -func TestSerializeActionButton_NoIconStaysNull(t *testing.T) { - if got := iconOf(t, nil); got != nil { - t.Errorf("an iconless button wrote an icon element: %v", got) - } -} - -// CONTROL: an icon that identifies nothing is written as no icon rather than as -// an element nobody can see. The executor refuses these before they get here, so -// this pins the writer's own behaviour for the paths that build a pages.Icon -// directly. -func TestSerializeActionButton_AnIconIdentifyingNothingIsNotWritten(t *testing.T) { - for _, icon := range []*pages.Icon{ - {Kind: types.MenuIconGlyph}, // no code - {Kind: types.MenuIconImage}, // no name - {Kind: types.MenuIconCollection}, // no name - {Kind: types.MenuIconKind("Forms$SomeFuture")}, // a kind this build does not know - } { - if got := iconOf(t, icon); got != nil { - t.Errorf("%+v was written as %v, want no icon", icon, got) - } - } -} diff --git a/sdk/mpr/writer_widgets_image_test.go b/sdk/mpr/writer_widgets_image_test.go deleted file mode 100644 index 3403bc0786..0000000000 --- a/sdk/mpr/writer_widgets_image_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// The image widgets, pinned to the shape Mendix stores. -// -// This is the legacy half of the pair; the modelsdk half is -// mdl/backend/modelsdk/widget_write_legacy_gaps_test.go and asserts the same -// things about the same widgets. Keeping both is the point: the two engines had -// silently drifted apart here, and only one of them was right. -// -// Ground truth is the three Studio-Pro-authored Forms$StaticImageViewer widgets -// ako/TestApp inherits from FeedbackModule, plus generated/metamodel (the -// arbiter per CLAUDE.md) for Forms$ImageViewer, which no reference project in -// reach carries. - -package mpr - -import ( - "sort" - "strings" - "testing" - - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -func imgKeys(doc bson.D) string { - out := make([]string, 0, len(doc)) - for _, e := range doc { - out = append(out, e.Key) - } - sort.Strings(out) - return strings.Join(out, ",") -} - -// TestStaticImageMatchesStudioPro — legacy used to omit AlternativeText, which -// generated/metamodel declares without omitempty and all three references carry, -// and to write BSON null for the unset Image. An unset by-name reference is the -// empty string: measured 0 nulls against 4,400+ empty strings over 40 -// (type, property) pairs in ako/TestApp. -func TestStaticImageMatchesStudioPro(t *testing.T) { - img := &pages.StaticImage{Responsive: true} - img.Name = "i1" - doc := serializeStaticImage(img) - - want := "$ID,$Type,AlternativeText,Appearance,ClickAction," + - "ConditionalVisibilitySettings,Height,HeightUnit,Image,Name," + - "NativeAccessibilitySettings,Responsive,TabIndex,Width,WidthUnit" - if got := imgKeys(doc); got != want { - t.Errorf("keys\n got %s\n want %s", got, want) - } - if got := bsonLookup(doc, "Image"); got != "" { - t.Errorf("Image = %#v, want the empty string", got) - } - assertEmptyClientTemplateBSON(t, doc, "AlternativeText") -} - -// TestDynamicImageMatchesMetamodel — legacy's AlternativeText here was -// hand-rolled and carried a FallbackValue key that Forms$ClientTemplate does not -// have, four lines after a comment in the shared serializer saying exactly that -// ("Must be Fallback object, not FallbackValue string"). -func TestDynamicImageMatchesMetamodel(t *testing.T) { - img := &pages.DynamicImage{Responsive: true} - img.Name = "i2" - doc := serializeDynamicImage(img) - - want := "$ID,$Type,AlternativeText,Appearance,ClickAction," + - "ConditionalVisibilitySettings,DataSource,DefaultImage,Height,HeightUnit," + - "Name,NativeAccessibilitySettings,OnClickEnlarge,Responsive," + - "ShowAsThumbnail,TabIndex,Width,WidthUnit" - if got := imgKeys(doc); got != want { - t.Errorf("keys\n got %s\n want %s", got, want) - } - if got := bsonLookup(doc, "DefaultImage"); got != "" { - t.Errorf("DefaultImage = %#v, want the empty string", got) - } - assertEmptyClientTemplateBSON(t, doc, "AlternativeText") -} - -func assertEmptyClientTemplateBSON(t *testing.T, parent bson.D, key string) { - t.Helper() - ct := bsonSubDoc(t, parent, key) - if got := bsonLookup(ct, "$Type"); got != "Forms$ClientTemplate" { - t.Errorf("%s.$Type = %v", key, got) - } - if bsonLookup(ct, "FallbackValue") != nil { - t.Errorf("%s carries a FallbackValue; Forms$ClientTemplate has no such property "+ - "(metamodel: Fallback / Parameters / Template). Studio Pro refuses to open a "+ - "document with an unknown property; mxbuild builds it at 0 errors", key) - } - for _, sub := range []string{"Fallback", "Template"} { - txt := bsonSubDoc(t, ct, sub) - if got := bsonLookup(txt, "$Type"); got != "Texts$Text" { - t.Errorf("%s.%s.$Type = %v", key, sub, got) - } - items, ok := bsonLookup(txt, "Items").(bson.A) - if !ok || len(items) != 1 || items[0] != int32(3) { - t.Errorf("%s.%s.Items = %#v, want [3]", key, sub, bsonLookup(txt, "Items")) - } - } - params, ok := bsonLookup(ct, "Parameters").(bson.A) - if !ok || len(params) != 1 || params[0] != int32(2) { - t.Errorf("%s.Parameters = %#v, want [2]", key, bsonLookup(ct, "Parameters")) - } -} diff --git a/sdk/mpr/writer_widgets_input.go b/sdk/mpr/writer_widgets_input.go deleted file mode 100644 index 8757bcc7e1..0000000000 --- a/sdk/mpr/writer_widgets_input.go +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeTextBox serializes a TextBox widget. -func serializeTextBox(tb *pages.TextBox) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(tb.ID))}, - {Key: "$Type", Value: "Forms$TextBox"}, - {Key: "Appearance", Value: serializeAppearance(tb.Class, tb.Style, tb.DynamicClasses, tb.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(tb.AttributePath)}, - {Key: "AutoFocus", Value: false}, - {Key: "Autocomplete", Value: true}, - {Key: "AutocompletePurpose", Value: "On"}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "FormattingInfo", Value: serializeFormattingInfo()}, - {Key: "InputMask", Value: ""}, - {Key: "IsPasswordBox", Value: tb.IsPassword}, - {Key: "KeyboardType", Value: "Default"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(tb.Label)}, - {Key: "MaxLengthCode", Value: int64(-1)}, - {Key: "Name", Value: tb.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(tb.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(tb.OnEnterAction)}, - {Key: "OnEnterKeyPressAction", Value: serializeClientAction(nil)}, - {Key: "OnLeaveAction", Value: serializeClientAction(nil)}, - {Key: "PlaceholderTemplate", Value: serializePlaceholderTemplate(tb.Placeholder)}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "SubmitBehaviour", Value: "OnEndEditing"}, - {Key: "SubmitOnInputDelay", Value: int64(300)}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeTextArea serializes a TextArea widget. -func serializeTextArea(ta *pages.TextArea) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ta.ID))}, - {Key: "$Type", Value: "Forms$TextArea"}, - {Key: "Appearance", Value: serializeAppearance(ta.Class, ta.Style, ta.DynamicClasses, ta.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(ta.AttributePath)}, - {Key: "AutoFocus", Value: false}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "CounterMessage", Value: serializeEmptyText()}, - {Key: "Editable", Value: "Always"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(ta.Label)}, - {Key: "MaxLengthCode", Value: int64(-1)}, - {Key: "Name", Value: ta.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "NumberOfLines", Value: int64(5)}, - {Key: "OnChangeAction", Value: serializeClientAction(ta.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "OnLeaveAction", Value: serializeClientAction(nil)}, - {Key: "PlaceholderTemplate", Value: serializeEmptyPlaceholderTemplate()}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "SubmitBehaviour", Value: "OnEndEditing"}, - {Key: "SubmitOnInputDelay", Value: int64(300)}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeDatePicker serializes a DatePicker widget. -func serializeDatePicker(dp *pages.DatePicker) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dp.ID))}, - {Key: "$Type", Value: "Forms$DatePicker"}, - {Key: "Appearance", Value: serializeAppearance(dp.Class, dp.Style, dp.DynamicClasses, dp.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(dp.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DateFormat", Value: "Date"}, - {Key: "Editable", Value: "Always"}, - {Key: "FormattingInfo", Value: serializeFormattingInfo()}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(dp.Label)}, - {Key: "Name", Value: dp.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(dp.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "PlaceholderTemplate", Value: serializeEmptyPlaceholderTemplate()}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeCheckBox serializes a CheckBox widget. -func serializeCheckBox(cb *pages.CheckBox) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cb.ID))}, - {Key: "$Type", Value: "Forms$CheckBox"}, - {Key: "Appearance", Value: serializeAppearance(cb.Class, cb.Style, cb.DynamicClasses, cb.DesignProperties)}, - {Key: "AttributeRef", Value: serializeAttributeRef(cb.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(cb.Label)}, - {Key: "Name", Value: cb.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(cb.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeRadioButtons serializes a RadioButtons widget. -func serializeRadioButtons(rb *pages.RadioButtons) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(rb.ID))}, - {Key: "$Type", Value: "Forms$RadioButtonGroup"}, - {Key: "Appearance", Value: serializeAppearance(rb.Class, rb.Style, rb.DynamicClasses, rb.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(rb.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(rb.Label)}, - {Key: "Name", Value: rb.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(rb.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "Orientation", Value: "Horizontal"}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeDropDown serializes a DropDown widget. -func serializeDropDown(dd *pages.DropDown) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dd.ID))}, - {Key: "$Type", Value: "Forms$DropDown"}, - {Key: "Appearance", Value: serializeAppearance(dd.Class, dd.Style, dd.DynamicClasses, dd.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(dd.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "EmptyOptionCaption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(dd.Label)}, - {Key: "Name", Value: dd.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(dd.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "OnLeaveAction", Value: serializeClientAction(nil)}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} diff --git a/sdk/mpr/writer_widgets_layout.go b/sdk/mpr/writer_widgets_layout.go deleted file mode 100644 index 28a34733e1..0000000000 --- a/sdk/mpr/writer_widgets_layout.go +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeContainer serializes a Container widget. -func serializeContainer(c *pages.Container) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(c.ID))}, - {Key: "$Type", Value: "Forms$DivContainer"}, - {Key: "Appearance", Value: serializeAppearance(c.Class, c.Style, c.DynamicClasses, c.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: c.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnClickAction", Value: serializeClientAction(c.OnClickAction)}, - {Key: "RenderMode", Value: "Div"}, - {Key: "ScreenReaderHidden", Value: false}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Widgets", Value: serializeWidgetArray(c.Widgets)}, - } - return doc -} - -// serializeGroupBox serializes a GroupBox widget. -func serializeGroupBox(gb *pages.GroupBox) bson.D { - collapsible := gb.Collapsible - if collapsible == "" { - collapsible = "No" - } - headerMode := gb.HeaderMode - if headerMode == "" { - headerMode = "Div" - } - - // Serialize CaptionTemplate - var captionTemplate bson.D - if gb.Caption != nil { - captionTemplate = serializeClientTemplate(gb.Caption, nil, "") - } else { - captionTemplate = serializeClientTemplate(nil, nil, "") - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(gb.ID))}, - {Key: "$Type", Value: "Forms$GroupBox"}, - {Key: "Appearance", Value: serializeAppearance(gb.Class, gb.Style, gb.DynamicClasses, gb.DesignProperties)}, - {Key: "CaptionTemplate", Value: captionTemplate}, - {Key: "Collapsible", Value: collapsible}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "HeaderMode", Value: headerMode}, - {Key: "Name", Value: gb.Name}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Widgets", Value: serializeWidgetArray(gb.Widgets)}, - } - return doc -} - -// serializeTabContainer serializes a TabContainer widget. -func serializeTabContainer(tc *pages.TabContainer) bson.D { - tabPages := bson.A{int32(3)} // marker=3 for TabPages array - var defaultPageID []byte - for i, tp := range tc.TabPages { - tpDoc := serializeTabPage(tp) - tabPages = append(tabPages, tpDoc) - if i == 0 { - // Default to first tab - defaultPageID = idToBsonBinary(string(tp.ID)).Data - } - } - if tc.DefaultPageID != "" { - defaultPageID = idToBsonBinary(string(tc.DefaultPageID)).Data - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(tc.ID))}, - {Key: "$Type", Value: "Forms$TabControl"}, - {Key: "ActivePageAttributeRef", Value: nil}, - {Key: "ActivePageOnChangeAction", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "ActivePageSourceVariable", Value: nil}, - {Key: "Appearance", Value: serializeAppearance(tc.Class, tc.Style, tc.DynamicClasses, tc.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DefaultPagePointer", Value: defaultPageID}, - {Key: "Name", Value: tc.Name}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "TabPages", Value: tabPages}, - } - return doc -} - -// serializeTabPage serializes a TabPage within a TabContainer. -func serializeTabPage(tp *pages.TabPage) bson.D { - // Caption - var caption bson.D - if tp.Caption != nil { - caption = serializeText(tp.Caption) - } else { - caption = serializeText(&model.Text{ - BaseElement: model.BaseElement{ - ID: model.ID(GenerateID()), - TypeName: "Texts$Text", - }, - Translations: map[string]string{model.AuthoringLanguage(): tp.Name}, - }) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(tp.ID))}, - {Key: "$Type", Value: "Forms$TabPage"}, - {Key: "Badge", Value: nil}, - {Key: "Caption", Value: caption}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: tp.Name}, - {Key: "RefreshOnShow", Value: tp.RefreshOnShow}, - {Key: "Widgets", Value: serializeWidgetArray(tp.Widgets)}, - } - return doc -} - -// serializeLayoutGrid serializes a LayoutGrid widget. -func serializeLayoutGrid(lg *pages.LayoutGrid) bson.D { - // Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays - // Items go directly after the version marker, NOT nested in another array - rows := bson.A{int32(3)} // Start with empty marker - hasRows := false - for _, row := range lg.Rows { - if !hasRows { - rows = bson.A{int32(2)} // First item: change to version 2 - hasRows = true - } - rows = append(rows, serializeLayoutGridRow(row)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(lg.ID))}, - {Key: "$Type", Value: "Forms$LayoutGrid"}, - {Key: "Appearance", Value: serializeAppearance(lg.Class, lg.Style, lg.DynamicClasses, lg.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: lg.Name}, - {Key: "Rows", Value: rows}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Width", Value: "FullWidth"}, - } - return doc -} - -// serializeLayoutGridRow serializes a LayoutGridRow. -func serializeLayoutGridRow(row *pages.LayoutGridRow) bson.D { - // Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays - // Items go directly after the version marker, NOT nested in another array - cols := bson.A{int32(3)} // Start with empty marker - hasCols := false - for _, col := range row.Columns { - if !hasCols { - cols = bson.A{int32(2)} // First item: change to version 2 - hasCols = true - } - cols = append(cols, serializeLayoutGridColumn(col)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(row.ID))}, - {Key: "$Type", Value: "Forms$LayoutGridRow"}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "Columns", Value: cols}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "HorizontalAlignment", Value: "None"}, - {Key: "SpacingBetweenColumns", Value: true}, - {Key: "VerticalAlignment", Value: "None"}, - } -} - -// columnWeight returns the column weight, defaulting to -1 (auto) if 0. -func columnWeight(w int) int { - if w == 0 { - return -1 - } - return w -} - -// serializeLayoutGridColumn serializes a LayoutGridColumn. -func serializeLayoutGridColumn(col *pages.LayoutGridColumn) bson.D { - // Weight for column width: -1 means auto-fill, 1-12 are explicit widths - weight := col.Weight - if weight == 0 { - weight = -1 // Default to auto-fill - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(col.ID))}, - {Key: "$Type", Value: "Forms$LayoutGridColumn"}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "PhoneWeight", Value: int64(columnWeight(col.PhoneWeight))}, - {Key: "PreviewWidth", Value: int64(-1)}, // Default preview width - {Key: "TabletWeight", Value: int64(columnWeight(col.TabletWeight))}, - {Key: "VerticalAlignment", Value: "None"}, - {Key: "Weight", Value: int64(weight)}, // Desktop weight - {Key: "Widgets", Value: serializeWidgetArray(col.Widgets)}, - } -} diff --git a/sdk/mpr/writer_widgets_linkbutton_test.go b/sdk/mpr/writer_widgets_linkbutton_test.go deleted file mode 100644 index 593f783156..0000000000 --- a/sdk/mpr/writer_widgets_linkbutton_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" -) - -// TestSerializeActionButton_RenderType verifies that a link-rendered action -// button (authored as `linkbutton`) serializes with RenderType "Link", while a -// normal action button keeps "Button". Previously RenderType was hardcoded to -// "Button", so linkbutton output was indistinguishable from actionbutton. -func TestSerializeActionButton_RenderType(t *testing.T) { - cases := []struct { - name string - render pages.ButtonRenderMode - want string - }{ - {"linkbutton", pages.ButtonRenderModeLink, "Link"}, - {"actionbutton", pages.ButtonRenderModeButton, "Button"}, - {"default empty", "", "Button"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ab := &pages.ActionButton{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Name: "btn", - }, - RenderMode: tc.render, - } - doc := serializeActionButton(ab) - got := "" - for _, e := range doc { - if e.Key == "RenderType" { - got, _ = e.Value.(string) - } - } - if got != tc.want { - t.Errorf("RenderType = %q, want %q", got, tc.want) - } - if doc[0].Key != "$ID" { - t.Errorf("first key = %q, want $ID", doc[0].Key) - } - }) - } -} diff --git a/sdk/mpr/writer_widgets_snippet_test.go b/sdk/mpr/writer_widgets_snippet_test.go deleted file mode 100644 index 6bdb99aa99..0000000000 --- a/sdk/mpr/writer_widgets_snippet_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSnippetCall_ParameterMapping_CorrectBSONType verifies that -// Forms$SnippetParameterMapping (not Forms$PageParameterMapping) is written -// for snippet call parameter mappings (issue #291 / #295 follow-up). -// Studio Pro throws InvalidOperationException when it finds PageParameterMapping -// inside a SnippetCall container. -func TestSnippetCall_ParameterMapping_CorrectBSONType(t *testing.T) { - sc := &pages.SnippetCallWidget{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: "sc-id"}, - Name: "snippetCall1", - }, - SnippetName: "Mod.MySnippet", - ParameterMappings: []pages.SnippetParamMapping{ - {ParamName: "Asset", Argument: "$Asset"}, - }, - } - - doc := serializeSnippetCall(sc) - if doc == nil { - t.Fatal("serializeSnippetCall returned nil") - } - - // Navigate to FormCall.ParameterMappings - var formCall bson.D - for _, e := range doc { - if e.Key == "FormCall" { - formCall, _ = e.Value.(bson.D) - } - } - if formCall == nil { - t.Fatal("FormCall is nil") - } - - var paramMappings bson.A - for _, e := range formCall { - if e.Key == "ParameterMappings" { - paramMappings, _ = e.Value.(bson.A) - } - } - if len(paramMappings) < 2 { - t.Fatalf("ParameterMappings: want count+1 elements, got %d", len(paramMappings)) - } - - // Element 0 is int32 count; element 1 is the first mapping - mapping, ok := paramMappings[1].(bson.D) - if !ok { - t.Fatalf("ParameterMappings[1] is not bson.D, got %T", paramMappings[1]) - } - - var bsonType, argument, parameter string - var variable any - for _, e := range mapping { - switch e.Key { - case "$Type": - bsonType, _ = e.Value.(string) - case "Argument": - argument, _ = e.Value.(string) - case "Parameter": - parameter, _ = e.Value.(string) - case "Variable": - variable = e.Value - } - } - - if bsonType != "Forms$SnippetParameterMapping" { - t.Errorf("$Type = %q, want %q (PageParameterMapping is wrong for snippet context)", bsonType, "Forms$SnippetParameterMapping") - } - if argument != "" { - t.Errorf("Argument = %q, want %q (variable belongs in Variable.PageParameter)", argument, "") - } - if parameter != "Mod.MySnippet.Asset" { - t.Errorf("Parameter = %q, want %q", parameter, "Mod.MySnippet.Asset") - } - if variable == nil { - t.Fatal("Variable is nil — Forms$SnippetParameterMapping requires non-null Forms$PageVariable") - } - - varDoc, ok := variable.(bson.D) - if !ok { - t.Fatalf("Variable is not bson.D, got %T", variable) - } - - var varType, pageParam string - for _, e := range varDoc { - switch e.Key { - case "$Type": - varType, _ = e.Value.(string) - case "PageParameter": - pageParam, _ = e.Value.(string) - } - } - if varType != "Forms$PageVariable" { - t.Errorf("Variable.$Type = %q, want %q", varType, "Forms$PageVariable") - } - if pageParam != "Asset" { - t.Errorf("Variable.PageParameter = %q, want %q (stripped $)", pageParam, "Asset") - } -} diff --git a/sdk/mpr/writer_widgets_test.go b/sdk/mpr/writer_widgets_test.go deleted file mode 100644 index fdf1be7475..0000000000 --- a/sdk/mpr/writer_widgets_test.go +++ /dev/null @@ -1,465 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeDataView(t *testing.T) { - // Create a DataView with a DataViewSource (parameter reference) - dataView := &pages.DataView{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: "test-dataview-id", - TypeName: "Forms$DataView", - }, - Name: "customerForm", - }, - DataSource: &pages.DataViewSource{ - BaseElement: model.BaseElement{ - ID: "test-datasource-id", - TypeName: "Forms$DataViewSource", - }, - EntityID: "test-entity-id", - EntityName: "TestModule.Customer", - ParameterName: "Customer", - }, - ShowFooter: true, - Editable: true, - } - - result := serializeDataView(dataView) - - // Check that result is a BSON document - if result == nil { - t.Fatal("serializeDataView returned nil") - } - - // Check $Type - var foundType string - for _, elem := range result { - if elem.Key == "$Type" { - foundType = elem.Value.(string) - } - } - if foundType != "Forms$DataView" { - t.Errorf("Expected $Type to be 'Forms$DataView', got '%s'", foundType) - } - - // Check DataSource is present - var foundDataSource any - for _, elem := range result { - if elem.Key == "DataSource" { - foundDataSource = elem.Value - } - } - if foundDataSource == nil { - t.Error("DataSource is nil, expected it to be set") - } - - // Check DataSource type - if ds, ok := foundDataSource.(bson.D); ok { - var dsType string - for _, elem := range ds { - if elem.Key == "$Type" { - dsType = elem.Value.(string) - } - } - if dsType != "Forms$DataViewSource" { - t.Errorf("Expected DataSource.$Type to be 'Forms$DataViewSource', got '%s'", dsType) - } - - // Check EntityRef is present - var entityRef any - for _, elem := range ds { - if elem.Key == "EntityRef" { - entityRef = elem.Value - } - } - if entityRef == nil { - t.Error("EntityRef is nil, expected it to be set") - } - - // Check SourceVariable is present - var sourceVar any - for _, elem := range ds { - if elem.Key == "SourceVariable" { - sourceVar = elem.Value - } - } - if sourceVar == nil { - t.Error("SourceVariable is nil, expected it to be set") - } - - // Check SourceVariable contains PageParameter - if sv, ok := sourceVar.(bson.D); ok { - var pageParam string - var svType string - for _, elem := range sv { - if elem.Key == "PageParameter" { - pageParam = elem.Value.(string) - } - if elem.Key == "$Type" { - svType = elem.Value.(string) - } - } - if svType != "Forms$PageVariable" { - t.Errorf("Expected SourceVariable.$Type to be 'Forms$PageVariable', got '%s'", svType) - } - if pageParam != "Customer" { - t.Errorf("Expected PageParameter to be 'Customer', got '%s'", pageParam) - } - } else { - t.Error("SourceVariable is not a bson.D") - } - - // Check EntityRef structure - if er, ok := entityRef.(bson.D); ok { - var erType string - var entity string - for _, elem := range er { - if elem.Key == "$Type" { - erType = elem.Value.(string) - } - if elem.Key == "Entity" { - entity = elem.Value.(string) - } - } - if erType != "DomainModels$DirectEntityRef" { - t.Errorf("Expected EntityRef.$Type to be 'DomainModels$DirectEntityRef', got '%s'", erType) - } - if entity != "TestModule.Customer" { - t.Errorf("Expected Entity to be 'TestModule.Customer', got '%s'", entity) - } - } else { - t.Error("EntityRef is not a bson.D") - } - } else { - t.Error("DataSource is not a bson.D") - } -} - -func TestSerializeDataViewDataSource(t *testing.T) { - ds := &pages.DataViewSource{ - BaseElement: model.BaseElement{ - ID: "test-ds-id", - TypeName: "Forms$DataViewSource", - }, - EntityID: "entity-123", - EntityName: "MyModule.MyEntity", - ParameterName: "MyParam", - } - - result := serializeDataViewDataSource(ds) - if result == nil { - t.Fatal("serializeDataViewDataSource returned nil") - } - - bsonResult, ok := result.(bson.D) - if !ok { - t.Fatalf("Expected bson.D, got %T", result) - } - - // Check structure - var foundType, foundEntityRef, foundSourceVar bool - for _, elem := range bsonResult { - switch elem.Key { - case "$Type": - if elem.Value.(string) != "Forms$DataViewSource" { - t.Errorf("Expected $Type 'Forms$DataViewSource', got '%v'", elem.Value) - } - foundType = true - case "EntityRef": - if elem.Value != nil { - foundEntityRef = true - } - case "SourceVariable": - if elem.Value != nil { - foundSourceVar = true - } - } - } - - if !foundType { - t.Error("$Type not found in result") - } - if !foundEntityRef { - t.Error("EntityRef not found or is nil") - } - if !foundSourceVar { - t.Error("SourceVariable not found or is nil") - } -} - -func TestSerializeTextBox(t *testing.T) { - tb := &pages.TextBox{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: "test-textbox-id", - TypeName: "Forms$TextBox", - }, - Name: "txtEmail", - }, - AttributePath: "MyModule.Customer.Email", - } - - result := serializeTextBox(tb) - - // Check $Type - var foundType, foundAttrRef, foundName bool - for _, elem := range result { - switch elem.Key { - case "$Type": - if elem.Value.(string) != "Forms$TextBox" { - t.Errorf("Expected $Type 'Forms$TextBox', got '%v'", elem.Value) - } - foundType = true - case "AttributeRef": - if elem.Value != nil { - foundAttrRef = true - // Check AttributeRef structure - if ar, ok := elem.Value.(bson.D); ok { - var attrType, attrValue string - for _, arElem := range ar { - if arElem.Key == "$Type" { - attrType = arElem.Value.(string) - } - if arElem.Key == "Attribute" { - attrValue = arElem.Value.(string) - } - } - if attrType != "DomainModels$AttributeRef" { - t.Errorf("Expected AttributeRef.$Type 'DomainModels$AttributeRef', got '%s'", attrType) - } - if attrValue != "MyModule.Customer.Email" { - t.Errorf("Expected Attribute 'MyModule.Customer.Email', got '%s'", attrValue) - } - } - } - case "Name": - if elem.Value.(string) == "txtEmail" { - foundName = true - } - } - } - - if !foundType { - t.Error("$Type not found") - } - if !foundAttrRef { - t.Error("AttributeRef not found or is nil") - } - if !foundName { - t.Error("Name not found or incorrect") - } -} - -// TestSerializeTextBox_PlaceholderAndOnChange guards finding #9: placeholder and -// onchange were hardcoded to empty on the legacy write path (silently dropped). -// They must now serialize from the model's Placeholder / OnChangeAction. -func TestSerializeTextBox_PlaceholderAndOnChange(t *testing.T) { - tb := &pages.TextBox{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: "tb-id", TypeName: "Forms$TextBox"}, - Name: "txtQuery", - }, - AttributePath: "M.Filter.Query", - Placeholder: &model.Text{ - BaseElement: model.BaseElement{ID: "ph-id", TypeName: "Texts$Text"}, - Translations: map[string]string{"en_US": "Search all articles"}, - }, - OnChangeAction: &pages.MicroflowClientAction{ - MicroflowName: "M.ACT_Search", - }, - } - - result := serializeTextBox(tb) - - // PlaceholderTemplate must carry the placeholder text (not the empty template). - var placeholderText string - var onChangeType string - for _, elem := range result { - switch elem.Key { - case "PlaceholderTemplate": - if d, ok := elem.Value.(bson.D); ok { - placeholderText = extractTemplateText(d) - } - case "OnChangeAction": - if d, ok := elem.Value.(bson.D); ok { - for _, e := range d { - if e.Key == "$Type" { - onChangeType, _ = e.Value.(string) - } - } - } - } - } - if placeholderText != "Search all articles" { - t.Errorf("PlaceholderTemplate text = %q, want %q", placeholderText, "Search all articles") - } - if onChangeType == "" || onChangeType == "Forms$NoAction" { - t.Errorf("OnChangeAction should be a real action, got $Type = %q", onChangeType) - } -} - -// extractTemplateText pulls the first Translation Text out of a Forms$ClientTemplate. -func extractTemplateText(ct bson.D) string { - for _, e := range ct { - if e.Key != "Template" { - continue - } - tmpl, ok := e.Value.(bson.D) - if !ok { - continue - } - for _, te := range tmpl { - if te.Key != "Items" { - continue - } - items, ok := te.Value.(bson.A) - if !ok { - continue - } - for _, it := range items { - trans, ok := it.(bson.D) - if !ok { - continue - } - for _, tr := range trans { - if tr.Key == "Text" { - if s, ok := tr.Value.(string); ok { - return s - } - } - } - } - } - } - return "" -} - -func TestSerializeDataViewLabelWidth(t *testing.T) { - five := 5 - zero := 0 - cases := []struct { - name string - dv *pages.DataView - want int64 - }{ - {"default is Horizontal=3", &pages.DataView{}, 3}, - {"FormOrientation Vertical -> 0", &pages.DataView{FormOrientation: pages.FormOrientationVertical}, 0}, - {"FormOrientation Horizontal -> 3", &pages.DataView{FormOrientation: pages.FormOrientationHorizontal}, 3}, - {"explicit LabelWidth=5", &pages.DataView{LabelWidth: &five}, 5}, - {"explicit LabelWidth=0 wins over Horizontal", &pages.DataView{LabelWidth: &zero, FormOrientation: pages.FormOrientationHorizontal}, 0}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := serializeDataView(tc.dv) - var lw int64 = -1 - for _, elem := range got { - if elem.Key == "LabelWidth" { - lw = elem.Value.(int64) - } - } - if lw != tc.want { - t.Errorf("LabelWidth = %d, want %d", lw, tc.want) - } - }) - } -} - -func TestSerializeRadioButtons(t *testing.T) { - rb := &pages.RadioButtons{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: "test-radio-id", - TypeName: "Forms$RadioButtonGroup", - }, - Name: "rbIsActive", - }, - AttributePath: "MyModule.Customer.IsActive", - } - - result := serializeRadioButtons(rb) - - // Check $Type - var foundType string - for _, elem := range result { - if elem.Key == "$Type" { - foundType = elem.Value.(string) - } - } - if foundType != "Forms$RadioButtonGroup" { - t.Errorf("Expected $Type 'Forms$RadioButtonGroup', got '%s'", foundType) - } -} - -// dgetForTest returns the value of the first field in d with the given key. -func dgetForTest(d bson.D, key string) any { - for _, e := range d { - if e.Key == key { - return e.Value - } - } - return nil -} - -// TestSerializeDesignProperties_Compound guards the WRITE side of compound -// (nested) design properties. Before the fix, serializeDesignProperties handled -// only toggle/option/custom and dropped a "compound" value via `default: continue`, -// so authoring e.g. Atlas `Spacing: [margin-top: Large]` (or `use building block` -// on a block that uses it) silently lost the nested property. Verified valid by -// `mx check` (0 errors) on a real 11.12.1 project. -func TestSerializeDesignProperties_Compound(t *testing.T) { - props := []pages.DesignPropertyValue{ - {Key: "Card style", ValueType: "toggle"}, - {Key: "Spacing", ValueType: "compound", Compound: []pages.DesignPropertyValue{ - {Key: "margin-top", ValueType: "option", Option: "Large"}, - {Key: "margin-bottom", ValueType: "option", Option: "Medium"}, - }}, - } - - arr := serializeDesignProperties(props) - // marker + toggle + compound - if len(arr) != 3 { - t.Fatalf("expected 3 elements (marker + 2 props), got %d", len(arr)) - } - - var compound bson.D - for _, e := range arr[1:] { - d, ok := e.(bson.D) - if !ok { - continue - } - if dgetForTest(d, "Key") == "Spacing" { - compound, _ = dgetForTest(d, "Value").(bson.D) - } - } - if compound == nil { - t.Fatal("Spacing compound entry was dropped, not serialized") - } - if got := dgetForTest(compound, "$Type"); got != "Forms$CompoundDesignPropertyValue" { - t.Fatalf("compound $Type = %v, want Forms$CompoundDesignPropertyValue", got) - } - sub, ok := dgetForTest(compound, "Properties").(bson.A) - if !ok { - t.Fatalf("Properties is not a bson.A: %T", dgetForTest(compound, "Properties")) - } - if len(sub) != 3 { // marker + 2 sub-entries - t.Fatalf("expected 3 sub-elements (marker + 2), got %d", len(sub)) - } - subKeys := map[string]bool{} - for _, s := range sub[1:] { - if d, ok := s.(bson.D); ok { - subKeys[dgetForTest(d, "Key").(string)] = true - } - } - if !subKeys["margin-top"] || !subKeys["margin-bottom"] { - t.Errorf("sub-properties missing, got keys %v", subKeys) - } -} diff --git a/sdk/mpr/writer_workflow.go b/sdk/mpr/writer_workflow.go deleted file mode 100644 index b4a34acda9..0000000000 --- a/sdk/mpr/writer_workflow.go +++ /dev/null @@ -1,876 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/workflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateWorkflow creates a new workflow in the MPR. -func (w *Writer) CreateWorkflow(wf *workflows.Workflow) error { - if wf.ID == "" { - wf.ID = model.ID(generateUUID()) - } - wf.TypeName = "Workflows$Workflow" - - contents, err := w.serializeWorkflow(wf) - if err != nil { - return fmt.Errorf("failed to serialize workflow: %w", err) - } - - return w.insertUnit(string(wf.ID), string(wf.ContainerID), "Documents", "Workflows$Workflow", contents) -} - -// UpdateWorkflow replaces an existing workflow unit in the MPR, preserving its UUID. -func (w *Writer) UpdateWorkflow(wf *workflows.Workflow) error { - wf.TypeName = "Workflows$Workflow" - - contents, err := w.serializeWorkflow(wf) - if err != nil { - return fmt.Errorf("failed to serialize workflow: %w", err) - } - - return w.updateUnit(string(wf.ID), contents) -} - -// DeleteWorkflow deletes a workflow from the MPR. -func (w *Writer) DeleteWorkflow(id model.ID) error { - return w.deleteUnit(string(id)) -} - -func (w *Writer) serializeWorkflow(wf *workflows.Workflow) ([]byte, error) { - // AdminPage is a PartProperty (object or null), not a string. - // When empty, it must be null, not "". - var adminPageValue any - if wf.AdminPage != "" { - adminPageValue = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: wf.AdminPage}, - } - } - - // Annotation is a PartProperty (object or null). - var annotationValue any - if wf.Annotation != "" { - annotationValue = serializeAnnotation(wf.Annotation) - } - - // Flow - var flowValue bson.D - if wf.Flow != nil { - flowValue = serializeWorkflowFlow(wf.Flow) - } else { - emptyFlow := &workflows.Flow{} - emptyFlow.ID = model.ID(generateUUID()) - flowValue = serializeWorkflowFlow(emptyFlow) - } - - // Title defaults to workflow display name or Name - title := wf.WorkflowName - if title == "" { - title = wf.Name - } - - // Build doc in alphabetical key order matching Studio Pro BSON layout - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(wf.ID))}, - {Key: "$Type", Value: "Workflows$Workflow"}, - {Key: "AdminPage", Value: adminPageValue}, - {Key: "Annotation", Value: annotationValue}, - {Key: "Documentation", Value: wf.Documentation}, - {Key: "DueDate", Value: wf.DueDate}, - {Key: "Excluded", Value: wf.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Flow", Value: flowValue}, - {Key: "Name", Value: wf.Name}, - {Key: "OnWorkflowEvent", Value: serializeWorkflowEventHandlers(wf.EventHandlers)}, - } - - // Parameter - if wf.Parameter != nil { - doc = append(doc, bson.E{Key: "Parameter", Value: serializeWorkflowParameter(wf.Parameter)}) - } - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "Title", Value: title}, - bson.E{Key: "WorkflowDescription", Value: serializeWorkflowStringTemplate(wf.WorkflowDescription)}, - bson.E{Key: "WorkflowMetaData", Value: nil}, - bson.E{Key: "WorkflowName", Value: serializeWorkflowStringTemplate(wf.WorkflowName)}, - bson.E{Key: "WorkflowV2", Value: false}, - ) - - // NOTE: OverviewPage was deleted in Mendix 9.11.0 — do not serialize it. - // NOTE: AllowedModuleRoles is not present in Studio Pro BSON — omitted. - - pv := w.reader.ProjectVersion() - renameCallMicroflowTypeBSON(doc, pv != nil && pv.IsAtLeast(11, 9)) - return marshalUnitIDFirst(doc) -} - -// renameCallMicroflowTypeBSON rewrites every "Workflows$CallMicroflowTask" $Type -// in a serialized workflow tree to the 11.9+ "Workflows$CallMicroflowActivity" -// name when useActivity is set. Mendix 11.9 (WOR-2802) split MicroflowBasedActivity -// into CallMicroflowActivity + AIAgentTaskActivity; writing the pre-11.9 name to an -// 11.9+ project makes the runtime fail to load the whole model (FINDINGS #39). The -// modelsdk engine does the same via applyCallMicroflowStorageName. -func renameCallMicroflowTypeBSON(v any, useActivity bool) { - if !useActivity { - return - } - renameCallMicroflowWalk(v) -} - -func renameCallMicroflowWalk(v any) { - switch t := v.(type) { - case bson.D: - for i := range t { - if t[i].Key == "$Type" { - if s, ok := t[i].Value.(string); ok && s == "Workflows$CallMicroflowTask" { - t[i].Value = "Workflows$CallMicroflowActivity" - } - continue - } - renameCallMicroflowWalk(t[i].Value) - } - case bson.A: - for i := range t { - renameCallMicroflowWalk(t[i]) - } - } -} - -// serializeWorkflowEventHandlers writes OnWorkflowEvent: a marker-2 list of -// Workflows$WorkflowEventHandler, each with its event types as a marker-1 string -// list — the shape ako/TestApp (11.14.0) stores. -func serializeWorkflowEventHandlers(handlers []*workflows.WorkflowEventHandler) bson.A { - out := bson.A{int32(2)} - for _, h := range handlers { - types := bson.A{int32(1)} - for _, t := range h.EventTypes { - types = append(types, t) - } - id := string(h.ID) - if id == "" { - id = generateUUID() - } - out = append(out, bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Workflows$WorkflowEventHandler"}, - {Key: "Description", Value: h.Description}, - {Key: "Documentation", Value: h.Documentation}, - {Key: "EventTypes", Value: types}, - {Key: "MicroflowEventHandler", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowEventHandler"}, - {Key: "Microflow", Value: h.Microflow}, - }}, - }) - } - return out -} - -// serializeOnCreatedEvent writes a user task's OnCreatedEvent part: the microflow -// when there is one, the NoEvent marker otherwise. -func serializeOnCreatedEvent(microflow string) bson.D { - if microflow == "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$NoEvent"}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowBasedEvent"}, - {Key: "Microflow", Value: microflow}, - } -} - -// serializeWorkflowStringTemplate creates a minimal Mendix StringTemplate BSON structure for workflows. -func serializeWorkflowStringTemplate(text string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Text", Value: text}, - } -} - -// serializeWorkflowParameter serializes a workflow parameter. -// Since Mendix 9.10.0, EntityRef (PartProperty) was replaced by Entity (ByNameReferenceProperty). -func serializeWorkflowParameter(param *workflows.WorkflowParameter) bson.D { - paramID := string(param.ID) - if paramID == "" { - paramID = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramID)}, - {Key: "$Type", Value: "Workflows$Parameter"}, - {Key: "Entity", Value: param.EntityRef}, - {Key: "Name", Value: "WorkflowContext"}, - } -} - -// serializeAnnotation serializes a workflow annotation if non-empty. -func serializeAnnotation(annotation string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$Annotation"}, - {Key: "Description", Value: annotation}, - } -} - -// appendActivityBaseFields appends common activity fields to a BSON doc. -// If annotation is non-empty, it serializes as an object; otherwise null. -func appendActivityBaseFields(doc bson.D, annotation string) bson.D { - var annotationValue any - if annotation != "" { - annotationValue = serializeAnnotation(annotation) - } - return append(doc, - bson.E{Key: "Annotation", Value: annotationValue}, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) -} - -// serializeBoundaryEvents serializes boundary events for workflow activities. -func serializeBoundaryEvents(events []*workflows.BoundaryEvent) bson.A { - arr := bson.A{int32(2)} // array type marker (BoundaryEvents use marker 2) - for _, event := range events { - eventID := string(event.ID) - if eventID == "" { - eventID = generateUUID() - } - - typeName := "Workflows$InterruptingTimerBoundaryEvent" - switch event.EventType { - case "NonInterruptingTimer": - typeName = "Workflows$NonInterruptingTimerBoundaryEvent" - case "Timer": - typeName = "Workflows$TimerBoundaryEvent" - case "InterruptingTimer": - typeName = "Workflows$InterruptingTimerBoundaryEvent" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(eventID)}, - {Key: "$Type", Value: typeName}, - {Key: "Caption", Value: event.Caption}, - } - - if event.TimerDelay != "" { - doc = append(doc, bson.E{Key: "FirstExecutionTime", Value: event.TimerDelay}) - } - - if event.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(event.Flow)}) - } - - doc = append(doc, bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}) - - if typeName == "Workflows$NonInterruptingTimerBoundaryEvent" { - doc = append(doc, bson.E{Key: "Recurrence", Value: nil}) - } - - arr = append(arr, doc) - } - return arr -} - -// emptyBoundaryEvents returns an empty boundary events array marker. -func emptyBoundaryEvents() bson.A { - return bson.A{int32(2)} -} - -// serializeWorkflowFlow serializes a workflow flow with its activities. -func serializeWorkflowFlow(flow *workflows.Flow) bson.D { - flowID := string(flow.ID) - if flowID == "" { - flowID = generateUUID() - } - - activities := bson.A{int32(3)} // array type marker - for _, act := range flow.Activities { - actDoc := serializeWorkflowActivity(act) - if actDoc != nil { - activities = append(activities, actDoc) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(flowID)}, - {Key: "$Type", Value: "Workflows$Flow"}, - {Key: "Activities", Value: activities}, - } -} - -// SerializeWorkflowActivity dispatches to the correct activity serializer. -// Exported for use by the ALTER WORKFLOW executor. -func SerializeWorkflowActivity(act workflows.WorkflowActivity, useCallMicroflowActivityName bool) bson.D { - d := serializeWorkflowActivity(act) - renameCallMicroflowTypeBSON(d, useCallMicroflowActivityName) - return d -} - -// serializeWorkflowActivity dispatches to the correct serializer. -func serializeWorkflowActivity(act workflows.WorkflowActivity) bson.D { - switch a := act.(type) { - case *workflows.UserTask: - return serializeUserTask(a) - case *workflows.CallMicroflowTask: - return serializeCallMicroflowTask(a) - case *workflows.CallWorkflowActivity: - return serializeCallWorkflowActivity(a) - case *workflows.ExclusiveSplitActivity: - return serializeExclusiveSplit(a) - case *workflows.ParallelSplitActivity: - return serializeParallelSplit(a) - case *workflows.JumpToActivity: - return serializeJumpTo(a) - case *workflows.WaitForTimerActivity: - return serializeWaitForTimer(a) - case *workflows.WaitForNotificationActivity: - return serializeWaitForNotification(a) - case *workflows.StartWorkflowActivity: - return serializeStartWorkflow(a) - case *workflows.EndWorkflowActivity: - return serializeEndWorkflow(a) - case *workflows.EndOfParallelSplitPathActivity: - return serializeEndOfPath("Workflows$EndOfParallelSplitPathActivity", &a.BaseWorkflowActivity) - case *workflows.EndOfBoundaryEventPathActivity: - return serializeEndOfPath("Workflows$EndOfBoundaryEventPathActivity", &a.BaseWorkflowActivity) - case *workflows.WorkflowAnnotationActivity: - return serializeWorkflowAnnotationActivity(a) - default: - return nil - } -} - -func activityID(a *workflows.BaseWorkflowActivity) string { - if string(a.ID) != "" { - return string(a.ID) - } - return generateUUID() -} - -func serializeUserTask(a *workflows.UserTask) bson.D { - // UserTask was deleted in Mendix 10.12.0, replaced by SingleUserTaskActivity. - typeName := "Workflows$SingleUserTaskActivity" - if a.IsMulti { - typeName = "Workflows$MultiUserTaskActivity" - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: typeName}, - } - - // Annotation (null or object) - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // AutoAssignSingleTargetUser - doc = append(doc, bson.E{Key: "AutoAssignSingleTargetUser", Value: false}) - - // AwaitAllUsers (MultiUserTaskActivity only) - if a.IsMulti { - doc = append(doc, bson.E{Key: "AwaitAllUsers", Value: false}) - } - - // BoundaryEvents (always present, even if empty) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - ) - - // CompletionCriteria (MultiUserTaskActivity only) — must reference first outcome ID - if a.IsMulti { - // Pre-assign ID to first outcome so FallbackOutcomePointer can reference it - if len(a.Outcomes) > 0 && a.Outcomes[0].ID == "" { - a.Outcomes[0].ID = model.ID(generateUUID()) - } - fallbackID := "" - if len(a.Outcomes) > 0 { - fallbackID = string(a.Outcomes[0].ID) - } else { - fallbackID = generateUUID() - } - doc = append(doc, bson.E{Key: "CompletionCriteria", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$ConsensusCompletionCriteria"}, - {Key: "FallbackOutcomePointer", Value: idToBsonBinary(fallbackID)}, - }}) - } - - doc = append(doc, - bson.E{Key: "DueDate", Value: a.DueDate}, - bson.E{Key: "Name", Value: a.Name}, - ) - - doc = append(doc, bson.E{Key: "OnCreatedEvent", Value: serializeOnCreatedEvent(a.OnCreated)}) - - // Outcomes - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomes = append(outcomes, serializeUserTaskOutcome(outcome)) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) - - // TaskDescription - doc = append(doc, bson.E{Key: "TaskDescription", Value: serializeWorkflowStringTemplate(a.TaskDescription)}) - - // TaskName - taskName := a.TaskName - if taskName == "" { - taskName = a.Caption - } - doc = append(doc, bson.E{Key: "TaskName", Value: serializeWorkflowStringTemplate(taskName)}) - - // TaskPage (PageReference - required, never null) - doc = append(doc, bson.E{Key: "TaskPage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: a.Page}, - }}) - - // TargetUserInput (MultiUserTaskActivity only) — always AllUserInput - if a.IsMulti { - doc = append(doc, bson.E{Key: "TargetUserInput", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$AllUserInput"}, - }}) - } - - // UserTargeting (NoUserTargeting when not specified) - if a.UserSource != nil { - doc = append(doc, bson.E{Key: "UserTargeting", Value: serializeUserTargeting(a.UserSource)}) - } else { - doc = append(doc, bson.E{Key: "UserTargeting", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$NoUserTargeting"}, - }}) - } - - return doc -} - -func serializeUserTargeting(source workflows.UserSource) bson.D { - switch s := source.(type) { - case *workflows.MicroflowBasedUserSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowUserTargeting"}, - {Key: "Microflow", Value: s.Microflow}, - } - case *workflows.XPathBasedUserSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$XPathUserTargeting"}, - {Key: "XPathConstraint", Value: s.XPath}, - } - case *workflows.MicroflowGroupSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowGroupTargeting"}, - {Key: "Microflow", Value: s.Microflow}, - } - case *workflows.XPathGroupSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$XPathGroupTargeting"}, - {Key: "XPathConstraint", Value: s.XPath}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$NoUserTargeting"}, - } - } -} - -func serializeUserTaskOutcome(outcome *workflows.UserTaskOutcome) bson.D { - outcomeID := string(outcome.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$UserTaskOutcome"}, - } - - if outcome.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(outcome.Flow)}) - } - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "Value", Value: outcome.Value}, - ) - - return doc -} - -func serializeCallMicroflowTask(a *workflows.CallMicroflowTask) bson.D { - typeName := "Workflows$CallMicroflowTask" - if a.IsAgent { - typeName = "Workflows$AIAgentTaskActivity" // same shape, 11.9+ - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: typeName}, - } - - // Annotation - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // BoundaryEvents (always present) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Microflow", Value: a.Microflow}, - bson.E{Key: "Name", Value: a.Name}, - ) - - // Outcomes - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomes = append(outcomes, serializeConditionOutcome(outcome)) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - // ParameterMappings (always present) - mappings := bson.A{int32(2)} - for _, pm := range a.ParameterMappings { - pmID := string(pm.ID) - if pmID == "" { - pmID = generateUUID() - } - mappings = append(mappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(pmID)}, - {Key: "$Type", Value: "Workflows$MicroflowCallParameterMapping"}, - {Key: "Expression", Value: pm.Expression}, - {Key: "Parameter", Value: pm.Parameter}, - }) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) - - return doc -} - -func serializeCallWorkflowActivity(a *workflows.CallWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$CallWorkflowActivity"}, - } - - // Annotation - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // BoundaryEvents (always present) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "ExecuteAsync", Value: false}, - bson.E{Key: "Name", Value: a.Name}, - ) - - // ParameterMappings (always present, marker int32(2)) - paramMappings := bson.A{int32(2)} - for _, pm := range a.ParameterMappings { - pmID := string(pm.ID) - if pmID == "" { - pmID = generateUUID() - } - paramMappings = append(paramMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(pmID)}, - {Key: "$Type", Value: "Workflows$WorkflowCallParameterMapping"}, - {Key: "Expression", Value: pm.Expression}, - {Key: "Parameter", Value: pm.Parameter}, - }) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: paramMappings}) - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - bson.E{Key: "Workflow", Value: a.Workflow}, - ) - - return doc -} - -func serializeExclusiveSplit(a *workflows.ExclusiveSplitActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$ExclusiveSplitActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Expression", Value: a.Expression}, - bson.E{Key: "Name", Value: a.Name}, - ) - - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomes = append(outcomes, serializeConditionOutcome(outcome)) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - return doc -} - -func serializeConditionOutcome(outcome workflows.ConditionOutcome) bson.D { - switch o := outcome.(type) { - case *workflows.BooleanConditionOutcome: - outcomeID := string(o.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, - {Key: "Value", Value: o.Value}, - } - if o.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(o.Flow)}) - } - return doc - case *workflows.EnumerationValueConditionOutcome: - outcomeID := string(o.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$EnumerationValueConditionOutcome"}, - {Key: "Value", Value: o.Value}, - } - if o.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(o.Flow)}) - } - return doc - case *workflows.VoidConditionOutcome: - outcomeID := string(o.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$VoidConditionOutcome"}, - } - if o.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(o.Flow)}) - } - return doc - default: - return nil - } -} - -func serializeParallelSplit(a *workflows.ParallelSplitActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$ParallelSplitActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) - - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomeID := string(outcome.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - outDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, - } - if outcome.Flow != nil { - outDoc = append(outDoc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(outcome.Flow)}) - } - outDoc = append(outDoc, bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}) - outcomes = append(outcomes, outDoc) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - return doc -} - -func serializeJumpTo(a *workflows.JumpToActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$JumpToActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - bson.E{Key: "TargetActivity", Value: a.TargetActivity}, - ) - - return doc -} - -func serializeWaitForTimer(a *workflows.WaitForTimerActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$WaitForTimerActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Delay", Value: a.DelayExpression}, - bson.E{Key: "Name", Value: a.Name}, - ) - - return doc -} - -func serializeWaitForNotification(a *workflows.WaitForNotificationActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$WaitForNotificationActivity"}, - } - - // Annotation - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // BoundaryEvents (always present) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) - - return doc -} - -func serializeStartWorkflow(a *workflows.StartWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$StartWorkflowActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) - - return doc -} - -func serializeEndWorkflow(a *workflows.EndWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$EndWorkflowActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) - - return doc -} - -// serializeEndOfPath writes the end-of-path marker Mendix stores as the last -// activity of a parallel split path or a boundary event path. Same shape as -// serializeEndWorkflow; only the $Type differs. -func serializeEndOfPath(typeName string, a *workflows.BaseWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(a))}, - {Key: "$Type", Value: typeName}, - } - doc = appendActivityBaseFields(doc, a.Annotation) - return append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) -} - -func serializeWorkflowAnnotationActivity(a *workflows.WorkflowAnnotationActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$Annotation"}, - {Key: "Description", Value: a.Description}, - } - doc = append(doc, bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}) - doc = append(doc, bson.E{Key: "RelativeMiddlePoint", Value: ""}) - doc = append(doc, bson.E{Key: "Size", Value: ""}) - return doc -} From cca53a82cc5365574c1d4f2b85aeefd8ab4a26ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:49:57 +0000 Subject: [PATCH 2/2] Point CLAUDE.md at the engine that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting sdk/mpr left the always-in-context routing document pointing at files that are gone. CLAUDE.md is where a session looks first, so a dead path there does not just go stale — it sends the next reader somewhere that cannot be opened, and every one of these was a routing instruction rather than prose. Eight sites. The architecture tree listed sdk/mpr's reader/writer/parser/utils and no modelsdk at all, which had it describing the deleted engine as the format layer and omitting the real one. The BSON storage-name procedure said to verify against sdk/mpr/parser_microflow.go; that now points at modelsdk/codec and modelsdk/gen. The write-choke-point list named both engines' files — there is one engine, so the list is now one entry and says so. The test-first checklist sent a parser test to sdk/mpr/ and a backend mutation test to mdl/backend/mpr/, neither of which exists. Useful Files offered sdk/mpr/parser.go and writer_widgets.go. Two checklist items were rules about a package that no longer exists, so they are restated as rules about the engine that does: "no sdk/mpr write imports in the executor" becomes "no engine internals in the executor" (modelsdk/mpr, modelsdk/codec, modelsdk/gen), with the note that a missing backend method gets implemented rather than bypassed — which is what the last five slices kept running into. The shared-types rule described sdk/mpr re-exporting aliases; it now states the rule directly and cites modelsdk/mpr/version.ProjectVersion as the cautionary case, since that one duplicates types.ProjectVersion instead of aliasing it and the two print under the same name. Every path the file now names was checked to exist. Docs under docs/ are deliberately untouched: they describe what the code used to do, and ADRs are immutable by convention. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- CLAUDE.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index beb58ae946..cb16d4d533 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,11 +98,14 @@ ModelSDKGo/ │ ├── widgets/ # Embedded widget templates for pluggable widgets │ │ ├── loader.go # template loading with go:embed │ │ └── templates/ # json widget type definitions by Mendix version -│ └── mpr/ # MPR file format handling -│ ├── reader.go # read-only MPR access -│ ├── writer.go # read-write MPR modification -│ ├── parser.go # BSON parsing and deserialization -│ └── utils.go # UUID generation utilities +│ └── versions/ # per-major feature registry (mendix-{9,10,11}.yaml) +│ +├── modelsdk/ # The MPR engine (sdk/mpr, the legacy one, is deleted) +│ ├── mpr/ # MPR file format: reader, writer, raw unit access +│ ├── codec/ # document <-> BSON encode/decode +│ ├── canon/ # canonical form, identity transplant, write elision +│ ├── gen/ # vendored metamodel types (see the storage-name note) +│ └── widgets/ # pluggable widget augmentation │ ├── mdl/ # MDL (Mendix Definition Language) parser & CLI │ ├── grammar/ # ANTLR4 grammar definition @@ -172,7 +175,7 @@ ModelSDKGo/ When adding new types, always verify the storage name by: 1. Examining existing MPR files with the `mx` tool or SQLite browser 2. Checking the reflection data in `reference/mendixmodellib/reflection-data/` -3. Looking at the parser cases in `sdk/mpr/parser_microflow.go` +3. Looking at the decoder in `modelsdk/codec/` and the types in `modelsdk/gen/microflows/` **IMPORTANT**: When unsure about the correct BSON structure for a new feature, **ask the user to create a working example in Mendix Studio Pro** so you can compare the generated BSON against a known-good reference. @@ -334,7 +337,8 @@ containment walk — because a rebuild mints a fresh random `$ID` per sub-elemen so comparing bytes would skip nothing. The policy lives in `modelsdk/canon` (`Reconcile`) and is called at the single write choke point of **both** engines: `modelsdk/mpr/writer_core.go` (`updateUnit` *and* `WriteTransaction.WriteUnit` — -`codec.Store` reaches storage through the latter) and `sdk/mpr/writer_units.go`. +`codec.Store` reaches storage through the latter). There is one engine, so that is +the whole list. When something *has* changed, `Reconcile` still does not let the rebuild's fresh `$ID`s reach disk: `canon.TransplantIDs` matches the incoming document against the @@ -586,7 +590,7 @@ When reviewing pull requests or validating work before commit, verify these item ### Bug fixes - [ ] **Fix-issue skill consulted** — start at [`docs-wiki/bug-patterns/`](docs-wiki/bug-patterns/) for the failure *class*, then `grep -i` `.claude/skills/fix-issue/findings/*.jsonl` for the *instance*; match before opening files. A pattern-page miss means the finding has not been digested yet, never that it has not been seen - [ ] **Finding recorded** — one JSON line appended to `.claude/skills/fix-issue/findings/.jsonl` if the symptom is not already covered, and `make check-findings` passes (it prints how far `docs-wiki/bug-patterns/` has fallen behind; `make digest-status` breaks it down by area). **If the class of failure keeps recurring, sync its pattern page** — the digest is on-demand and nothing else asks for it, which is how it went three months without a sync. Write the *insight* (what would have made it cheaper to find, what measurement settled it), not the changelog. `merge=union` in `.gitattributes` keeps both sides when two fixes append at once; order carries no meaning, since these are looked up by matching a symptom -- [ ] **Test written first** — failing test exists before implementation (parser test in `sdk/mpr/`, backend mutation test in `mdl/backend/mpr/`, executor handler test in `mdl/executor/` using `MockBackend`) +- [ ] **Test written first** — failing test exists before implementation (codec/parser test in `modelsdk/codec/` or `modelsdk/mpr/`, backend mutation test in `mdl/backend/modelsdk/`, executor handler test in `mdl/executor/` using `MockBackend`) - [ ] **Verified at the layer the symptom lives in** — a test proves something about the layer it exercises and nothing more. Parser/grammar → unit test. BSON we write → unit test on the encoded document. Files on disk after `mx` runs → integration test (`-tags integration`). **The rendered app's behaviour or appearance → `.claude/skills/verify-in-runtime.md`** (boot with `run --local`, assert in Playwright). A page can serialize to valid-looking BSON, pass `mx check`, build cleanly, and still render wrong — that was #812. - [ ] **Fix proven to be the cause** — revert the fix (or stub the guard) and confirm the test fails with the reported symptom. A test that only passes against fixed code has not been shown to detect anything; two bugs this week had a green suite while live (#812 a clobbered `RegisterTypeDefaults`, #808 an integration test that had only ever skipped) @@ -614,14 +618,14 @@ New features that depend on a specific Mendix version must be version-gated: - [ ] **Skill updated** — `.claude/skills/version-awareness.md` updated if the feature has a workaround for older versions ### Backend abstraction compliance -All executor code must go through the backend abstraction layer — the executor must never import `sdk/mpr` for write paths. See [ADR-0002: Backend Abstraction Layer](docs/13-decisions/0002-backend-abstraction.md) for the context and alternatives. The codec (`modelsdk`) engine is the only local engine — the legacy `sdk/mpr` backend was deleted (`docs/plans/2026-09-14-retire-legacy-engine.md`), and `--engine`/`MXCLI_ENGINE` survive only as a warning-only no-op. It routes **all** document types — domain models included — through the codec, not a codec/legacy hybrid; see [ADR-0004: Full codec engine](docs/13-decisions/0004-full-codec-engine.md). Where the codec path cannot yet reproduce a construct, the backend **refuses** the op rather than dropping data. The backend interface speaks the **semantic model**, not gen/BSON or AST types — gen+codec are the MPR backend's internal storage adapter, one of several (MPR, MCP/PED, a future storage format); see [ADR-0005](docs/13-decisions/0005-semantic-model-interface-currency.md). CREATE is model→gen; fidelity-sensitive ALTER uses backend-internal gen-mutation, not a model round-trip. -- [ ] **No `sdk/mpr` write imports in executor** — executor files must not call `sdk/mpr` writer/parser types directly; use `ctx.Backend.*` instead +All executor code must go through the backend abstraction layer. **`sdk/mpr` no longer exists** — the package was deleted once its importer count reached zero, so reaching past the abstraction is now a compile error rather than a rule to remember. See [ADR-0002: Backend Abstraction Layer](docs/13-decisions/0002-backend-abstraction.md) for the context and alternatives. The codec (`modelsdk`) engine is the only local engine — the legacy `sdk/mpr` backend was deleted (`docs/plans/2026-09-14-retire-legacy-engine.md`), and `--engine`/`MXCLI_ENGINE` survive only as a warning-only no-op. It routes **all** document types — domain models included — through the codec, not a codec/legacy hybrid; see [ADR-0004: Full codec engine](docs/13-decisions/0004-full-codec-engine.md). Where the codec path cannot yet reproduce a construct, the backend **refuses** the op rather than dropping data. The backend interface speaks the **semantic model**, not gen/BSON or AST types — gen+codec are the MPR backend's internal storage adapter, one of several (MPR, MCP/PED, a future storage format); see [ADR-0005](docs/13-decisions/0005-semantic-model-interface-currency.md). CREATE is model→gen; fidelity-sensitive ALTER uses backend-internal gen-mutation, not a model round-trip. +- [ ] **No engine internals in the executor** — executor files must not reach into `modelsdk/mpr`, `modelsdk/codec` or `modelsdk/gen` directly; use `ctx.Backend.*` instead. A method missing from the backend gets implemented there, not bypassed - [ ] **New backend methods on the interface** — any new data access or mutation goes in the appropriate interface in `mdl/backend/` (e.g., `DomainModelBackend`, `MicroflowBackend`), not as a direct SDK call - [ ] **MPR implementation in `mdl/backend/mpr/`** — the concrete implementation lives here; all BSON/reader/writer logic stays in this package - [ ] **Mock stub in `mdl/backend/mock/`** — every new backend method has a `Func`-field stub with a descriptive `"MockBackend.X not configured"` error default (not `nil, nil`) - [ ] **Compile-time interface check** — new backend implementations have `var _ backend.SomeInterface = (*impl)(nil)` - [ ] **ALTER operations use mutator pattern** — page/workflow mutations go through `ctx.Backend.OpenPageForMutation()` / `OpenWorkflowForMutation()`, not inline BSON construction -- [ ] **New shared types in `mdl/types/`** — types used by both `mdl/` and `sdk/mpr` go in `mdl/types/`; `sdk/mpr` re-exports as type aliases (`type Foo = types.Foo`), never as duplicate definitions +- [ ] **New shared types in `mdl/types/`** — a type used by more than one layer goes in `mdl/types/` and the others alias it (`type Foo = types.Foo`), never as duplicate definitions. `modelsdk/mpr/version.ProjectVersion` is the cautionary case: it *duplicates* `types.ProjectVersion` instead of aliasing it, so the two are unrelated Go types that print under the same name - [ ] **Map iteration is deterministic** — any map iterated for serialization output must sort keys first (`sort.Strings(keys)` pattern); non-deterministic output causes flaky diffs and BSON instability - [ ] **Pluggable widgets via WidgetEngine** — new pluggable widget support uses `.def.json` + `WidgetRegistry`; no hardcoded BSON widget builders in the executor @@ -890,8 +894,9 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati - `api/api.go` - High-level fluent API entry point - `api/domainmodels.go` - Entity/Association/Attribute builders - `docs/01-project/SDK_EQUIVALENCE.md` - Detailed comparison with TypeScript SDK, gap analysis -- `sdk/mpr/parser.go` - BSON parsing logic (complex, handles polymorphic types) -- `sdk/mpr/writer_widgets.go` - Widget BSON serialization +- `modelsdk/codec/decoder.go` - BSON decoding (handles polymorphic types) +- `modelsdk/codec/encoder.go` - BSON encoding +- `mdl/backend/modelsdk/widget_pluggable_write.go` - Pluggable widget BSON, and the v1/v2 BSON driver conversion at the backend boundary - `sdk/widgets/templates/` - Embedded widget templates for pluggable widgets (ComboBox, DataGrid2, etc.) - `sdk/widgets/templates/README.md` - **Critical**: Template extraction requirements (must include both `type` AND `object`) - `generated/metamodel/enums.go` - All Mendix enumeration types