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 -}