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/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
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 d9fa89f20f..0000000000
Binary files a/sdk/mpr/testdata/enumerations/PictureQuality.mxunit and /dev/null differ
diff --git a/sdk/mpr/testdata/microflows/ChangePassword.mxunit b/sdk/mpr/testdata/microflows/ChangePassword.mxunit
deleted file mode 100644
index 922464b44a..0000000000
Binary files a/sdk/mpr/testdata/microflows/ChangePassword.mxunit and /dev/null differ
diff --git a/sdk/mpr/testdata/pages/Account_Overview.mxunit b/sdk/mpr/testdata/pages/Account_Overview.mxunit
deleted file mode 100644
index ab4004fcbe..0000000000
Binary files a/sdk/mpr/testdata/pages/Account_Overview.mxunit and /dev/null differ
diff --git a/sdk/mpr/testdata/pages/WidgetDemo_Showcase.mxunit b/sdk/mpr/testdata/pages/WidgetDemo_Showcase.mxunit
deleted file mode 100644
index 1035e09fe2..0000000000
Binary files a/sdk/mpr/testdata/pages/WidgetDemo_Showcase.mxunit and /dev/null differ
diff --git a/sdk/mpr/testdata/workflows/WorkflowBaseline.Sub_Workflow.bson b/sdk/mpr/testdata/workflows/WorkflowBaseline.Sub_Workflow.bson
deleted file mode 100644
index 19251a0649..0000000000
Binary files a/sdk/mpr/testdata/workflows/WorkflowBaseline.Sub_Workflow.bson and /dev/null differ
diff --git a/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson b/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson
deleted file mode 100644
index 5710694385..0000000000
Binary files a/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson and /dev/null differ
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
-}